From e93b45a540df55ec879760d5c424dda7cb59cfc6 Mon Sep 17 00:00:00 2001 From: James Fellows Yates Date: Sat, 25 May 2024 21:18:44 +0200 Subject: [PATCH 01/25] Add basic skeleton with welcome screen --- nf_core/__main__.py | 34 ++++++++ nf_core/configs/__init__.py | 1 + nf_core/configs/create/__init__.py | 63 ++++++++++++++ nf_core/configs/create/create.tcss | 135 +++++++++++++++++++++++++++++ nf_core/configs/create/utils.py | 41 +++++++++ nf_core/configs/create/welcome.py | 44 ++++++++++ 6 files changed, 318 insertions(+) create mode 100644 nf_core/configs/__init__.py create mode 100644 nf_core/configs/create/__init__.py create mode 100644 nf_core/configs/create/create.tcss create mode 100644 nf_core/configs/create/utils.py create mode 100644 nf_core/configs/create/welcome.py diff --git a/nf_core/__main__.py b/nf_core/__main__.py index 67af238b5c..6375f68cff 100644 --- a/nf_core/__main__.py +++ b/nf_core/__main__.py @@ -36,6 +36,7 @@ "commands": [ "list", "launch", + "configs", "create-params-file", "download", "licences", @@ -82,6 +83,12 @@ "commands": ["create", "test", "lint"], }, ], + "nf-core configs": [ + { + "name": "Config commands", + "commands": ["create"], + }, + ], } click.rich_click.OPTION_GROUPS = { "nf-core modules list local": [{"options": ["--dir", "--json", "--help"]}], @@ -302,6 +309,33 @@ def launch( if not launcher.launch_pipeline(): sys.exit(1) +# nf-core configs +@nf_core_cli.group() +@click.pass_context +def configs(ctx): + """ + Commands to create and 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) + + +@configs.command("create") +def create_configs(): + """ + 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 create-params-file @nf_core_cli.command() 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..8dfa10263c --- /dev/null +++ b/nf_core/configs/create/__init__.py @@ -0,0 +1,63 @@ +"""A Textual app to create a config.""" + +import logging + +## Textual objects +from textual.app import App +from textual.widgets import Button + +## General utilities +from nf_core.configs.create.utils import ( + CreateConfig, + CustomLogHandler, + LoggingConsole, +) + +## nf-core question page imports +from nf_core.configs.create.welcome import WelcomeScreen + +## Logging +log_handler = CustomLogHandler( + console=LoggingConsole(classes="log_console"), + rich_tracebacks=True, + show_time=False, + show_path=False, + markup=True, +) +logging.basicConfig( + level="INFO", + handlers=[log_handler], + format="%(message)s", +) +log_handler.setLevel("INFO") + +## Main workflow +class ConfigsCreateApp(App[CreateConfig]): + """A Textual app to create nf-core configs.""" + + CSS_PATH = "create.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() + } + + # Log handler + LOG_HANDLER = log_handler + # Logging state + LOGGING_STATE = None + + ## Question dialogue order defined here + def on_mount(self) -> None: + self.push_screen("welcome") + + ## User theme options + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark: bool = not self.dark diff --git a/nf_core/configs/create/create.tcss b/nf_core/configs/create/create.tcss new file mode 100644 index 0000000000..67394a9de3 --- /dev/null +++ b/nf_core/configs/create/create.tcss @@ -0,0 +1,135 @@ +#logo { + width: 100%; + content-align-horizontal: center; + content-align-vertical: middle; +} +.cta { + layout: horizontal; + margin-bottom: 1; +} +.cta Button { + margin: 0 3; +} + +.pipeline-type-grid { + height: auto; + margin-bottom: 2; +} + +.custom_grid { + height: auto; +} +.custom_grid Switch { + width: auto; +} +.custom_grid Static { + width: 1fr; + margin: 1 8; +} +.custom_grid Button { + width: auto; +} + +.field_help { + padding: 1 1 0 1; + color: $text-muted; + text-style: italic; +} +.validation_msg { + padding: 0 1; + color: $error; +} +.-valid { + border: tall $success-darken-3; +} + +Horizontal{ + width: 100%; + height: auto; +} +.column { + width: 1fr; +} + +HorizontalScroll { + width: 100%; +} +.feature_subtitle { + color: grey; +} + +Vertical{ + height: auto; +} + +.features-container { + padding: 0 4 1 4; +} + +/* Display help messages */ + +.help_box { + background: #333333; + padding: 1 3 0 3; + margin: 0 5 2 5; + overflow-y: auto; + transition: height 50ms; + display: none; + height: 0; +} +.displayed .help_box { + display: block; + height: 12; +} +#show_help { + display: block; +} +#hide_help { + display: none; +} +.displayed #show_help { + display: none; +} +.displayed #hide_help { + display: block; +} + +/* Show password */ + +#show_password { + display: block; +} +#hide_password { + display: none; +} +.displayed #show_password { + display: none; +} +.displayed #hide_password { + display: block; +} + +/* Logging console */ + +.log_console { + height: auto; + background: #333333; + padding: 1 3; + margin: 0 4 2 4; +} + +.hide { + display: none; +} + +/* Layouts */ +.col-2 { + grid-size: 2 1; +} + +.ghrepo-cols { + margin: 0 4; +} +.ghrepo-cols Button { + margin-top: 2; +} diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py new file mode 100644 index 0000000000..868d791dfb --- /dev/null +++ b/nf_core/configs/create/utils.py @@ -0,0 +1,41 @@ +from logging import LogRecord +from typing import Optional + +from pydantic import BaseModel +from rich.logging import RichHandler +from textual.message import Message +from textual.widget import Widget +from textual.widgets import RichLog + +## Logging (TODO: move to common place and share with pipelines logging?) + +class LoggingConsole(RichLog): + file = False + console: Widget + + def print(self, content): + self.write(content) + +class CustomLogHandler(RichHandler): + """A Logging handler which extends RichHandler to write to a Widget and handle a Textual App.""" + + def emit(self, record: LogRecord) -> None: + """Invoked by logging.""" + try: + _app = active_app.get() + except LookupError: + pass + else: + super().emit(record) + +class ShowLogs(Message): + """Custom message to show the logging messages.""" + + pass + +## Config model template + +class CreateConfig(BaseModel): + """Pydantic model for the nf-core create config.""" + + config_type: Optional[str] = None diff --git a/nf_core/configs/create/welcome.py b/nf_core/configs/create/welcome.py new file mode 100644 index 0000000000..659182a37c --- /dev/null +++ b/nf_core/configs/create/welcome.py @@ -0,0 +1,44 @@ +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** and **pipeline-specific** configs. + +## Config Types + +- **Infrastructure configs** allow you to define the computational environment you +will run the pipelines on (memory, CPUs, scheduling system, container engine +etc.). +- **Pipeline configs** allow you to tweak resources of a particular step of a +pipeline. For example process X should request 8.GB of memory. + +## Using Configs + +The resulting config file can be used with a pipeline with `-c .conf`. + +They can also be added to the centralised +[nf-core/configs](https://github.com/nf-core/configs) repository, where they +can be used by anyone running nf-core pipelines on your infrastructure directly +using `-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="start", variant="success"), classes="cta") From 266a1633459b25de78f42c72eed2b319dd507290 Mon Sep 17 00:00:00 2001 From: James Fellows Yates Date: Sat, 25 May 2024 21:22:13 +0200 Subject: [PATCH 02/25] Update changelgo --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16262bd1c3..443d31c7af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ ### Components +### 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 - Update pre-commit hook astral-sh/ruff-pre-commit to v0.4.4 ([#2974](https://github.com/nf-core/tools/pull/2974)) From 71a14752168382769fcd4f660813851c90f4e8da Mon Sep 17 00:00:00 2001 From: James Fellows Yates Date: Sat, 25 May 2024 21:23:07 +0200 Subject: [PATCH 03/25] Fix linting failure --- nf_core/configs/create/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 868d791dfb..f969c4ac86 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from rich.logging import RichHandler +from textual._context import active_app from textual.message import Message from textual.widget import Widget from textual.widgets import RichLog From a583018dc0ac367a5a66628e99004f3e94732ef7 Mon Sep 17 00:00:00 2001 From: James Fellows Yates Date: Sat, 25 May 2024 21:24:04 +0200 Subject: [PATCH 04/25] Linting --- nf_core/__main__.py | 2 ++ nf_core/configs/create/__init__.py | 5 ++--- nf_core/configs/create/utils.py | 5 +++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/nf_core/__main__.py b/nf_core/__main__.py index 6375f68cff..6d5e624181 100644 --- a/nf_core/__main__.py +++ b/nf_core/__main__.py @@ -309,6 +309,7 @@ def launch( if not launcher.launch_pipeline(): sys.exit(1) + # nf-core configs @nf_core_cli.group() @click.pass_context @@ -337,6 +338,7 @@ def create_configs(): log.error(e) sys.exit(1) + # nf-core create-params-file @nf_core_cli.command() @click.argument("pipeline", required=False, metavar="") diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 8dfa10263c..29237c0531 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -31,6 +31,7 @@ ) log_handler.setLevel("INFO") + ## Main workflow class ConfigsCreateApp(App[CreateConfig]): """A Textual app to create nf-core configs.""" @@ -44,9 +45,7 @@ class ConfigsCreateApp(App[CreateConfig]): ] ## New question screens (sections) loaded here - SCREENS = { - "welcome": WelcomeScreen() - } + SCREENS = {"welcome": WelcomeScreen()} # Log handler LOG_HANDLER = log_handler diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index f969c4ac86..f89873a426 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -10,6 +10,7 @@ ## Logging (TODO: move to common place and share with pipelines logging?) + class LoggingConsole(RichLog): file = False console: Widget @@ -17,6 +18,7 @@ class LoggingConsole(RichLog): def print(self, content): self.write(content) + class CustomLogHandler(RichHandler): """A Logging handler which extends RichHandler to write to a Widget and handle a Textual App.""" @@ -29,13 +31,16 @@ def emit(self, record: LogRecord) -> None: else: super().emit(record) + class ShowLogs(Message): """Custom message to show the logging messages.""" pass + ## Config model template + class CreateConfig(BaseModel): """Pydantic model for the nf-core create config.""" From 5e1dc525093e1a59ffd888bc4c3d612a0dd832dc Mon Sep 17 00:00:00 2001 From: James Fellows Yates Date: Sun, 26 May 2024 13:44:56 +0200 Subject: [PATCH 05/25] Move common util functions/classes to common location --- nf_core/configs/create/__init__.py | 11 ++-- nf_core/configs/create/utils.py | 38 -------------- nf_core/pipelines/create/__init__.py | 7 +-- nf_core/pipelines/create/basicdetails.py | 3 +- nf_core/pipelines/create/finaldetails.py | 3 +- nf_core/pipelines/create/githubrepo.py | 3 +- nf_core/pipelines/create/loggingscreen.py | 3 +- nf_core/pipelines/create/utils.py | 62 ++--------------------- nf_core/utils.py | 62 +++++++++++++++++++++++ 9 files changed, 80 insertions(+), 112 deletions(-) diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 29237c0531..5e0ce83417 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -6,16 +6,17 @@ from textual.app import App from textual.widgets import Button +from nf_core.configs.create.utils import CreateConfig + +## nf-core question page imports +from nf_core.configs.create.welcome import WelcomeScreen + ## General utilities -from nf_core.configs.create.utils import ( - CreateConfig, +from nf_core.utils import ( CustomLogHandler, LoggingConsole, ) -## nf-core question page imports -from nf_core.configs.create.welcome import WelcomeScreen - ## Logging log_handler = CustomLogHandler( console=LoggingConsole(classes="log_console"), diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index f89873a426..61099c4206 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -1,44 +1,6 @@ -from logging import LogRecord from typing import Optional from pydantic import BaseModel -from rich.logging import RichHandler -from textual._context import active_app -from textual.message import Message -from textual.widget import Widget -from textual.widgets import RichLog - -## Logging (TODO: move to common place and share with pipelines logging?) - - -class LoggingConsole(RichLog): - file = False - console: Widget - - def print(self, content): - self.write(content) - - -class CustomLogHandler(RichHandler): - """A Logging handler which extends RichHandler to write to a Widget and handle a Textual App.""" - - def emit(self, record: LogRecord) -> None: - """Invoked by logging.""" - try: - _app = active_app.get() - except LookupError: - pass - else: - super().emit(record) - - -class ShowLogs(Message): - """Custom message to show the logging messages.""" - - pass - - -## Config model template class CreateConfig(BaseModel): diff --git a/nf_core/pipelines/create/__init__.py b/nf_core/pipelines/create/__init__.py index da6a693220..ba25053168 100644 --- a/nf_core/pipelines/create/__init__.py +++ b/nf_core/pipelines/create/__init__.py @@ -14,12 +14,9 @@ from nf_core.pipelines.create.loggingscreen import LoggingScreen from nf_core.pipelines.create.nfcorepipeline import NfcorePipeline from nf_core.pipelines.create.pipelinetype import ChoosePipelineType -from nf_core.pipelines.create.utils import ( - CreateConfig, - CustomLogHandler, - LoggingConsole, -) +from nf_core.pipelines.create.utils import CreateConfig from nf_core.pipelines.create.welcome import WelcomeScreen +from nf_core.utils import CustomLogHandler, LoggingConsole log_handler = CustomLogHandler( console=LoggingConsole(classes="log_console"), diff --git a/nf_core/pipelines/create/basicdetails.py b/nf_core/pipelines/create/basicdetails.py index b88ede10d0..6459c5353c 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 CreateConfig, TextInput +from nf_core.utils import 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/finaldetails.py b/nf_core/pipelines/create/finaldetails.py index bd15cf9ddd..7da0edd946 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 99e7b09ab8..ccfe7f5858 100644 --- a/nf_core/pipelines/create/githubrepo.py +++ b/nf_core/pipelines/create/githubrepo.py @@ -13,7 +13,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 6006452baf..19c0e7818c 100644 --- a/nf_core/pipelines/create/utils.py +++ b/nf_core/pipelines/create/utils.py @@ -1,18 +1,15 @@ import re -from logging import LogRecord from pathlib import Path from typing import Optional, Union from pydantic import BaseModel, ConfigDict, ValidationError, field_validator -from rich.logging import RichHandler from textual import on -from textual._context import active_app from textual.app import ComposeResult from textual.containers import 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 + +from nf_core.utils import HelpText class CreateConfig(BaseModel): @@ -123,21 +120,6 @@ def validate(self, value: str) -> ValidationResult: 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.""" @@ -173,44 +155,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 CustomLogHandler(RichHandler): - """A Logging handler which extends RichHandler to write to a Widget and handle a Textual App.""" - - def emit(self, record: LogRecord) -> None: - """Invoked by logging.""" - try: - _app = active_app.get() - except LookupError: - pass - else: - super().emit(record) - - -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") - - ## Markdown text to reuse in different screens markdown_genomes = """ Nf-core pipelines are configured to use a copy of the most common reference genome files. diff --git a/nf_core/utils.py b/nf_core/utils.py index 8c50f0a49f..305e75e536 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -18,6 +18,7 @@ import sys import time from contextlib import contextmanager +from logging import LogRecord from pathlib import Path from typing import Generator, Tuple, Union @@ -30,7 +31,12 @@ import yaml from packaging.version import Version from rich.live import Live +from rich.logging import RichHandler from rich.spinner import Spinner +from textual._context import active_app +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Markdown, RichLog import nf_core @@ -1220,3 +1226,59 @@ def set_wd(path: Path) -> Generator[None, None, None]: yield finally: os.chdir(start_wd) + + +# 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 CustomLogHandler(RichHandler): + """A Logging handler which extends RichHandler to write to a Widget and handle a Textual App.""" + + def emit(self, record: LogRecord) -> None: + """Invoked by logging.""" + try: + _app = active_app.get() + except LookupError: + pass + else: + super().emit(record) + + +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") From 03eaa52efb548c9fa8c0bb3ef2522b4f733c5df9 Mon Sep 17 00:00:00 2001 From: James Fellows Yates Date: Sun, 26 May 2024 13:49:04 +0200 Subject: [PATCH 06/25] Move textual CSS to common place --- MANIFEST.in | 2 +- nf_core/configs/create/__init__.py | 2 +- nf_core/pipelines/create/__init__.py | 2 +- nf_core/{pipelines/create/create.tcss => textual.tcss} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename nf_core/{pipelines/create/create.tcss => textual.tcss} (100%) diff --git a/MANIFEST.in b/MANIFEST.in index 68f115d97f..2226dad230 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,4 +9,4 @@ 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 diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 5e0ce83417..05ec9979fb 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -37,7 +37,7 @@ class ConfigsCreateApp(App[CreateConfig]): """A Textual app to create nf-core configs.""" - CSS_PATH = "create.tcss" + CSS_PATH = "../../textual.tcss" TITLE = "nf-core configs create" SUB_TITLE = "Create a new nextflow config with an interactive interface" BINDINGS = [ diff --git a/nf_core/pipelines/create/__init__.py b/nf_core/pipelines/create/__init__.py index ba25053168..fd1d6c680a 100644 --- a/nf_core/pipelines/create/__init__.py +++ b/nf_core/pipelines/create/__init__.py @@ -36,7 +36,7 @@ class PipelineCreateApp(App[CreateConfig]): """A Textual app to manage stopwatches.""" - CSS_PATH = "create.tcss" + CSS_PATH = "../../textual.tcss" TITLE = "nf-core create" SUB_TITLE = "Create a new pipeline with the nf-core pipeline template" BINDINGS = [ 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 From b82b6fb5ea189aef2cbd088127cb7c41bd8881c3 Mon Sep 17 00:00:00 2001 From: James Fellows Yates Date: Sun, 26 May 2024 14:19:10 +0200 Subject: [PATCH 07/25] Add config type question --- nf_core/configs/create/__init__.py | 14 ++++-- nf_core/configs/create/configtype.py | 74 ++++++++++++++++++++++++++++ nf_core/configs/create/welcome.py | 21 ++++---- 3 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 nf_core/configs/create/configtype.py diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 05ec9979fb..0aff5260a5 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -6,9 +6,9 @@ from textual.app import App from textual.widgets import Button -from nf_core.configs.create.utils import CreateConfig - ## nf-core question page imports +from nf_core.configs.create.configtype import ChooseConfigType +from nf_core.configs.create.utils import CreateConfig from nf_core.configs.create.welcome import WelcomeScreen ## General utilities @@ -46,7 +46,10 @@ class ConfigsCreateApp(App[CreateConfig]): ] ## New question screens (sections) loaded here - SCREENS = {"welcome": WelcomeScreen()} + SCREENS = { + "welcome": WelcomeScreen(), + "choose_type": ChooseConfigType(), + } # Log handler LOG_HANDLER = log_handler @@ -57,6 +60,11 @@ class ConfigsCreateApp(App[CreateConfig]): 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") + ## User theme options def action_toggle_dark(self) -> None: """An action to toggle dark mode.""" diff --git a/nf_core/configs/create/configtype.py b/nf_core/configs/create/configtype.py new file mode 100644 index 0000000000..dfd2479017 --- /dev/null +++ b/nf_core/configs/create/configtype.py @@ -0,0 +1,74 @@ +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("Pipeline config", id="type_infrastructure", variant="success")), + ), + Center( + Markdown(markdown_type_custom), + Center(Button("Infrastructure config", id="type_pipeline", variant="primary")), + ), + classes="col-2 pipeline-type-grid", + ) + yield Markdown(markdown_details) diff --git a/nf_core/configs/create/welcome.py b/nf_core/configs/create/welcome.py index 659182a37c..94dfe2d955 100644 --- a/nf_core/configs/create/welcome.py +++ b/nf_core/configs/create/welcome.py @@ -9,24 +9,21 @@ # Welcome to the nf-core config creation wizard This app will help you create **Nextflow configuration files** -for both **infrastructure** and **pipeline-specific** configs. +for both: -## Config Types - -- **Infrastructure configs** allow you to define the computational environment you -will run the pipelines on (memory, CPUs, scheduling system, container engine -etc.). -- **Pipeline configs** allow you to tweak resources of a particular step of a -pipeline. For example process X should request 8.GB of memory. +- **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 `-c .conf`. +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 by anyone running nf-core pipelines on your infrastructure directly -using `-profile `. +can be used directly by anyone running nf-core pipelines on your infrastructure +specifying `nextflow run -profile `. """ @@ -41,4 +38,4 @@ def compose(self) -> ComposeResult: id="logo", ) yield Markdown(markdown) - yield Center(Button("Let's go!", id="start", variant="success"), classes="cta") + yield Center(Button("Let's go!", id="lets_go", variant="success"), classes="cta") From 5f6b2a4195b29e667b560208c06b865e22b7de26 Mon Sep 17 00:00:00 2001 From: James Fellows Yates Date: Sun, 26 May 2024 14:56:25 +0200 Subject: [PATCH 08/25] Start adding basic details screen. Missing: validation. Not working: Hide URL Question of pipeline configs --- nf_core/configs/create/__init__.py | 22 +++- nf_core/configs/create/basicdetails.py | 119 ++++++++++++++++++++++ nf_core/configs/create/configtype.py | 2 + nf_core/configs/create/create.tcss | 135 ------------------------- nf_core/configs/create/utils.py | 2 + nf_core/configs/create/welcome.py | 2 + 6 files changed, 142 insertions(+), 140 deletions(-) create mode 100644 nf_core/configs/create/basicdetails.py delete mode 100644 nf_core/configs/create/create.tcss diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 0aff5260a5..90f0ddd9fb 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -6,7 +6,8 @@ from textual.app import App from textual.widgets import Button -## nf-core question page imports +## 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.utils import CreateConfig from nf_core.configs.create.welcome import WelcomeScreen @@ -46,10 +47,10 @@ class ConfigsCreateApp(App[CreateConfig]): ] ## New question screens (sections) loaded here - SCREENS = { - "welcome": WelcomeScreen(), - "choose_type": ChooseConfigType(), - } + SCREENS = {"welcome": WelcomeScreen(), "choose_type": ChooseConfigType(), "basic_details": BasicDetails()} + + # Tracking variables + CONFIG_TYPE = None # Log handler LOG_HANDLER = log_handler @@ -64,6 +65,17 @@ 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" + self.push_screen("basic_details") + elif event.button.id == "type_pipeline": + self.CONFIG_TYPE = "pipeline" + 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: diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py new file mode 100644 index 0000000000..6dd7c95dec --- /dev/null +++ b/nf_core/configs/create/basicdetails.py @@ -0,0 +1,119 @@ +"""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.app import ComposeResult +from textual.containers import Center, Horizontal +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Markdown + +from nf_core.pipelines.create.utils import TextInput + +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( + "config_name", + "custom", + "Config Name. Used for naming resulting file.", + "", + classes="column", + ) + with Horizontal(): + yield TextInput( + "authorname", + "Boaty McBoatFace", + "Author full name.", + classes="column", + ) + + yield TextInput( + "authorhandle", + "@BoatyMcBoatFace", + "Author Git(Hub) handle.", + classes="column", + ) + + yield TextInput( + "description", + "Description", + "A short description of your config.", + ) + yield TextInput( + "institutional_url", + "https://nf-co.re", + "URL of infrastructure website or owning institutional.", + disabled=self.parent.CONFIG_TYPE == "pipeline", ## TODO not working, why? + ) + ## TODO: reactivate once validation ready + # yield Markdown(dedent(config_exists_warn), id="exist_warn", classes="hide") + yield Center( + Button("Back", id="back", variant="default"), + Button("Next", id="next", variant="success"), + classes="cta", + ) + + ## TODO: update functions + # @on(Input.Changed) + # @on(Input.Submitted) + # def show_exists_warn(self): + # """Check if the pipeline exists on every input change or submitted. + # If the pipeline exists, show warning message saying that it will be overriden.""" + # config = {} + # for text_input in self.query("TextInput"): + # this_input = text_input.query_one(Input) + # config[text_input.field_id] = this_input.value + # if Path(config["org"] + "-" + config["name"]).is_dir(): + # remove_hide_class(self.parent, "exist_warn") + # else: + # add_hide_class(self.parent, "exist_warn") + + # def on_screen_resume(self): + # """Hide warn message on screen resume. + # Update displayed value on screen resume.""" + # add_hide_class(self.parent, "exist_warn") + # for text_input in self.query("TextInput"): + # if text_input.field_id == "org": + # text_input.disabled = self.parent.CONFIG_TYPE == "infrastructure" + + # @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 = CreateConfig(**config) + # if event.button.id == "next": + # if self.parent.CONFIG_TYPE == "infrastructure": + # self.parent.push_screen("type_infrastructure") + # elif self.parent.CONFIG_TYPE == "pipeline": + # self.parent.push_screen("type_pipeline") + # except ValueError: + # pass diff --git a/nf_core/configs/create/configtype.py b/nf_core/configs/create/configtype.py index dfd2479017..28269cd0df 100644 --- a/nf_core/configs/create/configtype.py +++ b/nf_core/configs/create/configtype.py @@ -1,3 +1,5 @@ +"""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 diff --git a/nf_core/configs/create/create.tcss b/nf_core/configs/create/create.tcss deleted file mode 100644 index 67394a9de3..0000000000 --- a/nf_core/configs/create/create.tcss +++ /dev/null @@ -1,135 +0,0 @@ -#logo { - width: 100%; - content-align-horizontal: center; - content-align-vertical: middle; -} -.cta { - layout: horizontal; - margin-bottom: 1; -} -.cta Button { - margin: 0 3; -} - -.pipeline-type-grid { - height: auto; - margin-bottom: 2; -} - -.custom_grid { - height: auto; -} -.custom_grid Switch { - width: auto; -} -.custom_grid Static { - width: 1fr; - margin: 1 8; -} -.custom_grid Button { - width: auto; -} - -.field_help { - padding: 1 1 0 1; - color: $text-muted; - text-style: italic; -} -.validation_msg { - padding: 0 1; - color: $error; -} -.-valid { - border: tall $success-darken-3; -} - -Horizontal{ - width: 100%; - height: auto; -} -.column { - width: 1fr; -} - -HorizontalScroll { - width: 100%; -} -.feature_subtitle { - color: grey; -} - -Vertical{ - height: auto; -} - -.features-container { - padding: 0 4 1 4; -} - -/* Display help messages */ - -.help_box { - background: #333333; - padding: 1 3 0 3; - margin: 0 5 2 5; - overflow-y: auto; - transition: height 50ms; - display: none; - height: 0; -} -.displayed .help_box { - display: block; - height: 12; -} -#show_help { - display: block; -} -#hide_help { - display: none; -} -.displayed #show_help { - display: none; -} -.displayed #hide_help { - display: block; -} - -/* Show password */ - -#show_password { - display: block; -} -#hide_password { - display: none; -} -.displayed #show_password { - display: none; -} -.displayed #hide_password { - display: block; -} - -/* Logging console */ - -.log_console { - height: auto; - background: #333333; - padding: 1 3; - margin: 0 4 2 4; -} - -.hide { - display: none; -} - -/* Layouts */ -.col-2 { - grid-size: 2 1; -} - -.ghrepo-cols { - margin: 0 4; -} -.ghrepo-cols Button { - margin-top: 2; -} diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 61099c4206..9d00e037a8 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -1,3 +1,5 @@ +"""Config creation specific functions and classes""" + from typing import Optional from pydantic import BaseModel diff --git a/nf_core/configs/create/welcome.py b/nf_core/configs/create/welcome.py index 94dfe2d955..7bca8100d0 100644 --- a/nf_core/configs/create/welcome.py +++ b/nf_core/configs/create/welcome.py @@ -1,3 +1,5 @@ +"""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 17971e60243132363173ebf55605e872688e3a77 Mon Sep 17 00:00:00 2001 From: "James A. Fellows Yates" Date: Sat, 1 Jun 2024 20:43:28 +0200 Subject: [PATCH 09/25] Fix function calling due to move to generic location --- nf_core/pipelines/create/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nf_core/pipelines/create/__init__.py b/nf_core/pipelines/create/__init__.py index 00bab8de7d..efdb1a3769 100644 --- a/nf_core/pipelines/create/__init__.py +++ b/nf_core/pipelines/create/__init__.py @@ -19,8 +19,8 @@ from nf_core.pipelines.create.welcome import WelcomeScreen from nf_core.utils import CustomLogHandler, LoggingConsole -log_handler = utils.CustomLogHandler( - console=utils.LoggingConsole(classes="log_console"), +log_handler = CustomLogHandler( + console=LoggingConsole(classes="log_console"), rich_tracebacks=True, show_time=False, show_path=False, From 15f5be80d8ebd0ffd1cbe81071cd3f69779dbe09 Mon Sep 17 00:00:00 2001 From: "James A. Fellows Yates" Date: Sat, 1 Jun 2024 21:47:32 +0200 Subject: [PATCH 10/25] Copy over @mirpedrol 's writing functions --- nf_core/configs/create/__init__.py | 13 ++- nf_core/configs/create/basicdetails.py | 64 +++++++------- nf_core/configs/create/configtype.py | 12 ++- nf_core/configs/create/create.py | 16 ++++ nf_core/configs/create/final.py | 43 ++++++++++ nf_core/configs/create/utils.py | 112 ++++++++++++++++++++++++- nf_core/pipelines/create/__init__.py | 2 +- 7 files changed, 225 insertions(+), 37 deletions(-) create mode 100644 nf_core/configs/create/create.py create mode 100644 nf_core/configs/create/final.py diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 90f0ddd9fb..f3ec38c7df 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -9,6 +9,7 @@ ## 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.utils import CreateConfig from nf_core.configs.create.welcome import WelcomeScreen @@ -47,7 +48,15 @@ class ConfigsCreateApp(App[CreateConfig]): ] ## New question screens (sections) loaded here - SCREENS = {"welcome": WelcomeScreen(), "choose_type": ChooseConfigType(), "basic_details": BasicDetails()} + SCREENS = { + "welcome": WelcomeScreen(), + "choose_type": ChooseConfigType(), + "basic_details": BasicDetails(), + "final": FinalScreen(), + } + + # Initialise config as empty + TEMPLATE_CONFIG = CreateConfig() # Tracking variables CONFIG_TYPE = None @@ -71,6 +80,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.button.id == "type_pipeline": self.CONFIG_TYPE = "pipeline" self.push_screen("basic_details") + elif event.button.id == "next": + self.push_screen("final") ## General options if event.button.id == "close_app": self.exit(return_code=0) diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index 6dd7c95dec..93529f068e 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -3,12 +3,16 @@ 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, Markdown +from textual.widgets import Button, Footer, Header, Input, Markdown -from nf_core.pipelines.create.utils import TextInput +from nf_core.configs.create.utils import ( + CreateConfig, + TextInput, +) ## TODO Move somewhere common? config_exists_warn = """ > ⚠️ **The config file you are trying to create already exists.** @@ -33,7 +37,7 @@ def compose(self) -> ComposeResult: ) ## TODO Add validation, .conf already exists? yield TextInput( - "config_name", + "general_config_name", "custom", "Config Name. Used for naming resulting file.", "", @@ -41,29 +45,31 @@ def compose(self) -> ComposeResult: ) with Horizontal(): yield TextInput( - "authorname", + "param_profilecontact", "Boaty McBoatFace", "Author full name.", classes="column", ) yield TextInput( - "authorhandle", + "param_profilecontacthandle", "@BoatyMcBoatFace", "Author Git(Hub) handle.", classes="column", ) yield TextInput( - "description", + "param_configprofiledescription", "Description", "A short description of your config.", ) yield TextInput( - "institutional_url", + "param_configprofileurl", "https://nf-co.re", - "URL of infrastructure website or owning institutional.", - disabled=self.parent.CONFIG_TYPE == "pipeline", ## TODO not working, why? + "URL of infrastructure website or owning institution (only for infrastructure configs).", + disabled=( + self.parent.CONFIG_TYPE == "pipeline" + ), ## TODO update TextInput to accept replace with visibility: https://textual.textualize.io/styles/visibility/ ) ## TODO: reactivate once validation ready # yield Markdown(dedent(config_exists_warn), id="exist_warn", classes="hide") @@ -96,24 +102,22 @@ def compose(self) -> ComposeResult: # if text_input.field_id == "org": # text_input.disabled = self.parent.CONFIG_TYPE == "infrastructure" - # @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 = CreateConfig(**config) - # if event.button.id == "next": - # if self.parent.CONFIG_TYPE == "infrastructure": - # self.parent.push_screen("type_infrastructure") - # elif self.parent.CONFIG_TYPE == "pipeline": - # self.parent.push_screen("type_pipeline") - # except ValueError: - # pass + ## Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the CreateConfig 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 = CreateConfig(**config) + except ValueError: + pass diff --git a/nf_core/configs/create/configtype.py b/nf_core/configs/create/configtype.py index 28269cd0df..c0adc1f458 100644 --- a/nf_core/configs/create/configtype.py +++ b/nf_core/configs/create/configtype.py @@ -65,11 +65,19 @@ def compose(self) -> ComposeResult: yield Grid( Center( Markdown(markdown_type_nfcore), - Center(Button("Pipeline config", id="type_infrastructure", variant="success")), + Center( + Button( + "Infrastructure config", + id="type_infrastructure", + variant="success", + ) + ), ), Center( Markdown(markdown_type_custom), - Center(Button("Infrastructure config", id="type_pipeline", variant="primary")), + Center( + Button("Pipeline config", id="type_pipeline", variant="primary") + ), ), classes="col-2 pipeline-type-grid", ) diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py new file mode 100644 index 0000000000..0379006ab8 --- /dev/null +++ b/nf_core/configs/create/create.py @@ -0,0 +1,16 @@ +import json + +from nf_core.configs.create.utils import CreateConfig + + +class ConfigCreate: + def __init__(self, template_config: CreateConfig): + self.template_config = template_config + + ## TODO: pull variable and file name so it's using the parameter name -> currently the written json shows that self.template_config.general_config_name is `null` + ## TODO: replace the json.dumping with proper self.template_config parsing and config writing function + + def write_to_file(self): + filename = self.template_config.general_config_name + ".conf" + with open(filename, "w+") as file: + file.write(json.dumps(dict(self.template_config))) diff --git a/nf_core/configs/create/final.py b/nf_core/configs/create/final.py new file mode 100644 index 0000000000..f075faf0b0 --- /dev/null +++ b/nf_core/configs/create/final.py @@ -0,0 +1,43 @@ +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/utils.py b/nf_core/configs/create/utils.py index 9d00e037a8..d013d389ff 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -1,11 +1,117 @@ """Config creation specific functions and classes""" -from typing import Optional +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Any, Dict, Iterator, Optional, Union -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, ValidationError +from textual import on +from textual.app import ComposeResult +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 class CreateConfig(BaseModel): """Pydantic model for the nf-core create config.""" - config_type: Optional[str] = None + general_config_type: str = None + general_config_name: str = None + param_profilecontact: str = None + param_profilecontacthandle: str = None + param_configprofiledescription: str = None + param_configprofileurl: Optional[str] = None + + 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(), + ) + + +## 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 Static(self.description, classes="field_help") + yield Input( + placeholder=self.placeholder, + validators=[ValidateConfig(self.field_id)], + value=self.default, + password=self.password, + ) + yield Static(classes="validation_msg") + + @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.""" + if not event.validation_result.is_valid: + self.query_one(".validation_msg").update( + "\n".join(event.validation_result.failure_descriptions) + ) + else: + self.query_one(".validation_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_infrastructure": CONFIG_ISINFRASTRUCTURE_GLOBAL}): + CreateConfig(**{f"{self.key}": value}) + return self.success() + except ValidationError as e: + return self.failure(", ".join([err["msg"] for err in e.errors()])) diff --git a/nf_core/pipelines/create/__init__.py b/nf_core/pipelines/create/__init__.py index efdb1a3769..c11a3ef674 100644 --- a/nf_core/pipelines/create/__init__.py +++ b/nf_core/pipelines/create/__init__.py @@ -58,7 +58,7 @@ class PipelineCreateApp(App[utils.CreateConfig]): } # Initialise config as empty - TEMPLATE_CONFIG = utils.CreateConfig() + TEMPLATE_CONFIG = CreateConfig() # Initialise pipeline type NFCORE_PIPELINE = True From 4af6e9910abe327f1159f8906f1eea8e43859206 Mon Sep 17 00:00:00 2001 From: "James A. Fellows Yates" Date: Sun, 2 Jun 2024 15:07:08 +0200 Subject: [PATCH 11/25] Start making config writing function actually write nextflow configs --- nf_core/configs/create/basicdetails.py | 8 ++-- nf_core/configs/create/create.py | 58 ++++++++++++++++++++++++-- nf_core/configs/create/utils.py | 14 +++++-- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index 93529f068e..48bb13c18a 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -45,26 +45,26 @@ def compose(self) -> ComposeResult: ) with Horizontal(): yield TextInput( - "param_profilecontact", + "profile_contact", "Boaty McBoatFace", "Author full name.", classes="column", ) yield TextInput( - "param_profilecontacthandle", + "profile_contact_handle", "@BoatyMcBoatFace", "Author Git(Hub) handle.", classes="column", ) yield TextInput( - "param_configprofiledescription", + "config_profile_description", "Description", "A short description of your config.", ) yield TextInput( - "param_configprofileurl", + "config_profile_url", "https://nf-co.re", "URL of infrastructure website or owning institution (only for infrastructure configs).", disabled=( diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py index 0379006ab8..d26529fe41 100644 --- a/nf_core/configs/create/create.py +++ b/nf_core/configs/create/create.py @@ -1,16 +1,66 @@ import json -from nf_core.configs.create.utils import CreateConfig +from nf_core.configs.create.utils import CreateConfig, generate_config_entry class ConfigCreate: def __init__(self, template_config: CreateConfig): self.template_config = template_config - ## TODO: pull variable and file name so it's using the parameter name -> currently the written json shows that self.template_config.general_config_name is `null` - ## TODO: replace the json.dumping with proper self.template_config parsing and config writing function + def construct_contents(self): + parsed_contents = { + "params": { + "config_profile_description": self.template_config.config_profile_description, + "config_profile_contact": "Boaty McBoatFace (@BoatyMcBoatFace)", + } + } + + return parsed_contents def write_to_file(self): + ## File name option filename = self.template_config.general_config_name + ".conf" + + ## Collect all config entries per scope, for later checking scope needs to be written + validparams = { + "config_profile_contact": self.template_config.config_profile_contact, + "config_profile_handle": self.template_config.config_profile_handle, + "config_profile_description": self.template_config.config_profile_description, + } + + print(validparams) + with open(filename, "w+") as file: - file.write(json.dumps(dict(self.template_config))) + + ## Write params + if any(validparams): + file.write("params {\n") + for entry_key, entry_value in validparams.items(): + print(entry_key) + if entry_value is not None: + file.write(generate_config_entry(self, entry_key, entry_value)) + else: + continue + file.write("}\n") + + +# ( +# file.write( +# ' config_profile_contact = "' +# + self.template_config.param_profilecontact +# + " (@" +# + self.template_config.param_profilecontacthandle +# + ')"\n' +# ) +# if self.template_config.param_profilecontact +# else None +# ), +# ( +# file.write( +# ' config_profile_description = "' +# + self.template_config.param_configprofiledescription +# + '"\n' +# ) +# if self.template_config.param_configprofiledescription +# else None +# ), diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index d013d389ff..be59e90b49 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -32,10 +32,10 @@ class CreateConfig(BaseModel): general_config_type: str = None general_config_name: str = None - param_profilecontact: str = None - param_profilecontacthandle: str = None - param_configprofiledescription: str = None - param_configprofileurl: Optional[str] = None + config_profile_contact: str = None + config_profile_handle: str = None + config_profile_description: str = None + config_profile_url: Optional[str] = None model_config = ConfigDict(extra="allow") @@ -115,3 +115,9 @@ def validate(self, value: str) -> ValidationResult: 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' + print(parsed_entry) + return parsed_entry From 2d7863a4799baaaa6031cbb5b016ff4f53a4f064 Mon Sep 17 00:00:00 2001 From: "James A. Fellows Yates" Date: Sat, 8 Jun 2024 11:48:38 +0200 Subject: [PATCH 12/25] Add URL saving, start adding validation: problem unless everything filled in 'NoneType + str' error --- nf_core/configs/create/basicdetails.py | 31 ++---------- nf_core/configs/create/create.py | 65 +++++++++++--------------- nf_core/configs/create/utils.py | 44 +++++++++++++++-- 3 files changed, 71 insertions(+), 69 deletions(-) diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index 48bb13c18a..3db1c4be28 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -45,14 +45,14 @@ def compose(self) -> ComposeResult: ) with Horizontal(): yield TextInput( - "profile_contact", + "config_profile_contact", "Boaty McBoatFace", "Author full name.", classes="column", ) yield TextInput( - "profile_contact_handle", + "config_profile_handle", "@BoatyMcBoatFace", "Author Git(Hub) handle.", classes="column", @@ -66,42 +66,17 @@ def compose(self) -> ComposeResult: yield TextInput( "config_profile_url", "https://nf-co.re", - "URL of infrastructure website or owning institution (only for infrastructure configs).", + "URL of infrastructure website or owning institution (infrastructure configs only).", disabled=( self.parent.CONFIG_TYPE == "pipeline" ), ## TODO update TextInput to accept replace with visibility: https://textual.textualize.io/styles/visibility/ ) - ## TODO: reactivate once validation ready - # yield Markdown(dedent(config_exists_warn), id="exist_warn", classes="hide") yield Center( Button("Back", id="back", variant="default"), Button("Next", id="next", variant="success"), classes="cta", ) - ## TODO: update functions - # @on(Input.Changed) - # @on(Input.Submitted) - # def show_exists_warn(self): - # """Check if the pipeline exists on every input change or submitted. - # If the pipeline exists, show warning message saying that it will be overriden.""" - # config = {} - # for text_input in self.query("TextInput"): - # this_input = text_input.query_one(Input) - # config[text_input.field_id] = this_input.value - # if Path(config["org"] + "-" + config["name"]).is_dir(): - # remove_hide_class(self.parent, "exist_warn") - # else: - # add_hide_class(self.parent, "exist_warn") - - # def on_screen_resume(self): - # """Hide warn message on screen resume. - # Update displayed value on screen resume.""" - # add_hide_class(self.parent, "exist_warn") - # for text_input in self.query("TextInput"): - # if text_input.field_id == "org": - # text_input.disabled = self.parent.CONFIG_TYPE == "infrastructure" - ## Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the CreateConfig class) with the values from the text inputs @on(Button.Pressed) def on_button_pressed(self, event: Button.Pressed) -> None: diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py index d26529fe41..8d1da0753e 100644 --- a/nf_core/configs/create/create.py +++ b/nf_core/configs/create/create.py @@ -7,28 +7,40 @@ class ConfigCreate: def __init__(self, template_config: CreateConfig): self.template_config = template_config - def construct_contents(self): - parsed_contents = { - "params": { - "config_profile_description": self.template_config.config_profile_description, - "config_profile_contact": "Boaty McBoatFace (@BoatyMcBoatFace)", - } - } + def construct_params(self, contact, handle, description, url): + final_params = {} - return parsed_contents + if contact != "" or not None: + if handle != "" or not None: + config_contact = contact + " (" + handle + ")" + else: + config_contact = contact + final_params["config_profile_contact"] = config_contact + elif handle != "" or not None: + final_params["config_contact"] = handle + else: + pass + + if description != "" or not None: + final_params["config_profile_description"] = description + + if url != "" or not None: + final_params["config_profile_url"] = url + + return final_params def write_to_file(self): ## File name option + print(self.template_config) filename = self.template_config.general_config_name + ".conf" ## Collect all config entries per scope, for later checking scope needs to be written - validparams = { - "config_profile_contact": self.template_config.config_profile_contact, - "config_profile_handle": self.template_config.config_profile_handle, - "config_profile_description": self.template_config.config_profile_description, - } - - print(validparams) + 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: @@ -36,31 +48,8 @@ def write_to_file(self): if any(validparams): file.write("params {\n") for entry_key, entry_value in validparams.items(): - print(entry_key) if entry_value is not None: file.write(generate_config_entry(self, entry_key, entry_value)) else: continue file.write("}\n") - - -# ( -# file.write( -# ' config_profile_contact = "' -# + self.template_config.param_profilecontact -# + " (@" -# + self.template_config.param_profilecontacthandle -# + ')"\n' -# ) -# if self.template_config.param_profilecontact -# else None -# ), -# ( -# file.write( -# ' config_profile_description = "' -# + self.template_config.param_configprofiledescription -# + '"\n' -# ) -# if self.template_config.param_configprofiledescription -# else None -# ), diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index be59e90b49..b010c02301 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -1,10 +1,12 @@ """Config creation specific functions and classes""" +import re + from contextlib import contextmanager from contextvars import ContextVar from typing import Any, Dict, Iterator, Optional, Union -from pydantic import BaseModel, ConfigDict, ValidationError +from pydantic import BaseModel, ConfigDict, ValidationError, field_validator from textual import on from textual.app import ComposeResult from textual.validation import ValidationResult, Validator @@ -47,6 +49,43 @@ def __init__(self, /, **data: Any) -> None: 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_profile_handle", + ) + @classmethod + def handle_prefix(cls, v: str) -> str: + """Check that GitHub handles start with '@'.""" + if 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 '@'.") + return v + + @field_validator( + "config_profile_url", + ) + @classmethod + def url_prefix(cls, v: str) -> str: + """Check that institutional web links start with valid URL prefix.""" + if 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): @@ -118,6 +157,5 @@ def validate(self, value: str) -> ValidationResult: def generate_config_entry(self, key, value): - parsed_entry = key + ' = "' + value + '"\n' - print(parsed_entry) + parsed_entry = " " + key + ' = "' + value + '"\n' return parsed_entry From f0cb5e9ca9508c27d4556740f3926af290f1f43e Mon Sep 17 00:00:00 2001 From: "James A. Fellows Yates" Date: Sun, 30 Jun 2024 11:55:33 +0200 Subject: [PATCH 13/25] Small debugging, now know the issue --- nf_core/configs/create/create.py | 4 ++++ nf_core/configs/create/utils.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py index 8d1da0753e..9cd27ade17 100644 --- a/nf_core/configs/create/create.py +++ b/nf_core/configs/create/create.py @@ -23,10 +23,14 @@ def construct_params(self, contact, handle, description, url): if description != "" or not None: final_params["config_profile_description"] = description + else: + pass if url != "" or not None: final_params["config_profile_url"] = url + print("final_params") + print(final_params) return final_params def write_to_file(self): diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index b010c02301..41591a07ca 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -35,8 +35,8 @@ class CreateConfig(BaseModel): general_config_type: str = None general_config_name: str = None config_profile_contact: str = None - config_profile_handle: str = None - config_profile_description: str = None + config_profile_handle: Optional[str] = None + config_profile_description: Optional[str] = None config_profile_url: Optional[str] = None model_config = ConfigDict(extra="allow") From 66227b7b22cf11e8cb9916a1616763eadfb070f1 Mon Sep 17 00:00:00 2001 From: "James A. Fellows Yates" Date: Sun, 30 Jun 2024 14:13:27 +0200 Subject: [PATCH 14/25] Fixing writing of parameters to the input file when no input from user --- nf_core/configs/create/create.py | 24 ++++++++------- nf_core/configs/create/utils.py | 52 ++++++++++++++++---------------- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py index 9cd27ade17..333e79e907 100644 --- a/nf_core/configs/create/create.py +++ b/nf_core/configs/create/create.py @@ -10,23 +10,22 @@ def __init__(self, template_config: CreateConfig): def construct_params(self, contact, handle, description, url): final_params = {} - if contact != "" or not None: - if handle != "" or not None: + print("c:" + contact) + print("h: " + handle) + + if contact != "": + if handle != "": config_contact = contact + " (" + handle + ")" else: config_contact = contact final_params["config_profile_contact"] = config_contact - elif handle != "" or not None: - final_params["config_contact"] = handle - else: - pass + elif handle != "": + final_params["config_profile_contact"] = handle - if description != "" or not None: + if description != "": final_params["config_profile_description"] = description - else: - pass - if url != "" or not None: + if url != "": final_params["config_profile_url"] = url print("final_params") @@ -46,13 +45,16 @@ def write_to_file(self): self.template_config.config_profile_url, ) + print("validparams") + print(validparams) + 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 is not None: + if entry_value != "": file.write(generate_config_entry(self, entry_key, entry_value)) else: continue diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 41591a07ca..de961723ea 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -59,32 +59,32 @@ def notempty(cls, v: str) -> str: raise ValueError("Cannot be left empty.") return v - @field_validator( - "config_profile_handle", - ) - @classmethod - def handle_prefix(cls, v: str) -> str: - """Check that GitHub handles start with '@'.""" - if 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 '@'.") - return v - - @field_validator( - "config_profile_url", - ) - @classmethod - def url_prefix(cls, v: str) -> str: - """Check that institutional web links start with valid URL prefix.""" - if 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 + # @field_validator( + # "config_profile_handle", + # ) + # @classmethod + # def handle_prefix(cls, v: str) -> str: + # """Check that GitHub handles start with '@'.""" + # if 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 '@'.") + # return v + + # @field_validator( + # "config_profile_url", + # ) + # @classmethod + # def url_prefix(cls, v: str) -> str: + # """Check that institutional web links start with valid URL prefix.""" + # if 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) From 46c4c0a2069e03f2e60e3e6bad500e7aa9eb602a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Mir=20Pedrol?= Date: Wed, 2 Apr 2025 12:02:53 +0200 Subject: [PATCH 15/25] Add back configs create command Co-authored-by: James A. Fellows Yates --- nf_core/__main__.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/nf_core/__main__.py b/nf_core/__main__.py index 0af7ace992..50eb55fa06 100644 --- a/nf_core/__main__.py +++ b/nf_core/__main__.py @@ -1847,6 +1847,37 @@ 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 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) + + ## DEPRECATED commands since v3.0.0 From dc22d8f4f2f6fcbd8fa8a6f9a103e6bb6b0efd48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Mir=20Pedrol?= Date: Wed, 2 Apr 2025 13:31:04 +0200 Subject: [PATCH 16/25] update to new version of textual and rename CreateConfig classes --- nf_core/configs/create/__init__.py | 14 +++++++------- nf_core/configs/create/basicdetails.py | 6 +++--- nf_core/configs/create/create.py | 8 ++++++-- nf_core/configs/create/utils.py | 6 +++--- nf_core/pipelines/create/__init__.py | 5 ++--- nf_core/pipelines/create/basicdetails.py | 4 ++-- nf_core/pipelines/create/create.py | 18 +++++++++--------- nf_core/pipelines/create/utils.py | 6 +++--- 8 files changed, 35 insertions(+), 32 deletions(-) diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 7a8ea151b1..cb78680d5d 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -13,7 +13,7 @@ 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.utils import CreateConfig +from nf_core.configs.create.utils import ConfigsCreateConfig from nf_core.configs.create.welcome import WelcomeScreen ## General utilities @@ -34,7 +34,7 @@ ## Main workflow -class ConfigsCreateApp(App[CreateConfig]): +class ConfigsCreateApp(App[ConfigsCreateConfig]): """A Textual app to create nf-core configs.""" CSS_PATH = "../../textual.tcss" @@ -47,14 +47,14 @@ class ConfigsCreateApp(App[CreateConfig]): ## New question screens (sections) loaded here SCREENS = { - "welcome": WelcomeScreen(), - "choose_type": ChooseConfigType(), - "basic_details": BasicDetails(), - "final": FinalScreen(), + "welcome": WelcomeScreen, + "choose_type": ChooseConfigType, + "basic_details": BasicDetails, + "final": FinalScreen, } # Initialise config as empty - TEMPLATE_CONFIG = CreateConfig() + TEMPLATE_CONFIG = ConfigsCreateConfig() # Tracking variables CONFIG_TYPE = None diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index 826ec87ef8..e59a7f0a08 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -10,7 +10,7 @@ from textual.widgets import Button, Footer, Header, Input, Markdown from nf_core.configs.create.utils import ( - CreateConfig, + ConfigsCreateConfig, TextInput, ) ## TODO Move somewhere common? @@ -77,7 +77,7 @@ def compose(self) -> ComposeResult: classes="cta", ) - ## Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the CreateConfig class) with the values from the text inputs + ## 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.""" @@ -91,6 +91,6 @@ 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 = ConfigsCreateConfig(**config) except ValueError: pass diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py index 0f49d6c617..f6b6f90b97 100644 --- a/nf_core/configs/create/create.py +++ b/nf_core/configs/create/create.py @@ -1,8 +1,12 @@ -from nf_core.configs.create.utils import CreateConfig, generate_config_entry +"""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: CreateConfig): + def __init__(self, template_config: ConfigsCreateConfig): self.template_config = template_config def construct_params(self, contact, handle, description, url): diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 9eccdc265a..ed86085f61 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -27,8 +27,8 @@ def init_context(value: Dict[str, Any]) -> Iterator[None]: CONFIG_ISINFRASTRUCTURE_GLOBAL: bool = True -class CreateConfig(BaseModel): - """Pydantic model for the nf-core create config.""" +class ConfigsCreateConfig(BaseModel): + """Pydantic model for the nf-core configs create config.""" general_config_type: Optional[str] = None general_config_name: Optional[str] = None @@ -142,7 +142,7 @@ def validate(self, value: str) -> ValidationResult: If it fails, return the error messages.""" try: with init_context({"is_infrastructure": CONFIG_ISINFRASTRUCTURE_GLOBAL}): - CreateConfig(**{f"{self.key}": value}) + ConfigsCreateConfig(**{f"{self.key}": value}) return self.success() except ValidationError as e: return self.failure(", ".join([err["msg"] for err in e.errors()])) diff --git a/nf_core/pipelines/create/__init__.py b/nf_core/pipelines/create/__init__.py index 1b4cb8eff9..bb103ce1fa 100644 --- a/nf_core/pipelines/create/__init__.py +++ b/nf_core/pipelines/create/__init__.py @@ -17,7 +17,6 @@ from nf_core.pipelines.create.loggingscreen import LoggingScreen from nf_core.pipelines.create.nfcorepipeline import NfcorePipeline from nf_core.pipelines.create.pipelinetype import ChoosePipelineType -from nf_core.pipelines.create.utils import CreateConfig from nf_core.pipelines.create.welcome import WelcomeScreen from nf_core.utils import LoggingConsole @@ -34,7 +33,7 @@ logger.addHandler(rich_log_handler) -class PipelineCreateApp(App[utils.CreateConfig]): +class PipelineCreateApp(App[utils.PipelinesCreateConfig]): """A Textual app to manage stopwatches.""" CSS_PATH = "../../textual.tcss" @@ -59,7 +58,7 @@ class PipelineCreateApp(App[utils.CreateConfig]): } # Initialise config as empty - TEMPLATE_CONFIG = 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 c222e1a80e..e511945d9a 100644 --- a/nf_core/pipelines/create/basicdetails.py +++ b/nf_core/pipelines/create/basicdetails.py @@ -9,7 +9,7 @@ from textual.screen import Screen from textual.widgets import Button, Footer, Header, Input, Markdown -from nf_core.pipelines.create.utils import CreateConfig, TextInput +from nf_core.pipelines.create.utils import PipelinesCreateConfig, TextInput from nf_core.utils import add_hide_class, remove_hide_class pipeline_exists_warn = """ @@ -101,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/utils.py b/nf_core/pipelines/create/utils.py index 56c13ed881..a0ce68567e 100644 --- a/nf_core/pipelines/create/utils.py +++ b/nf_core/pipelines/create/utils.py @@ -35,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") @@ -150,7 +150,7 @@ 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()])) From 544fd89685e3087e36a9227f7c5346b6ff81e494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Mir=20Pedrol?= Date: Wed, 2 Apr 2025 15:15:21 +0200 Subject: [PATCH 17/25] add screen asking if the config is nf-core --- nf_core/configs/create/__init__.py | 11 +++- nf_core/configs/create/nfcorequestion.py | 64 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 nf_core/configs/create/nfcorequestion.py diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index cb78680d5d..2acb70e37e 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -13,6 +13,7 @@ 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.nfcorequestion import ChooseNfcoreConfig from nf_core.configs.create.utils import ConfigsCreateConfig from nf_core.configs.create.welcome import WelcomeScreen @@ -49,6 +50,7 @@ class ConfigsCreateApp(App[ConfigsCreateConfig]): SCREENS = { "welcome": WelcomeScreen, "choose_type": ChooseConfigType, + "nfcore_question": ChooseNfcoreConfig, "basic_details": BasicDetails, "final": FinalScreen, } @@ -58,6 +60,7 @@ class ConfigsCreateApp(App[ConfigsCreateConfig]): # Tracking variables CONFIG_TYPE = None + NFCORE_CONFIG = None # Log handler LOG_HANDLER = rich_log_handler @@ -74,9 +77,15 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.push_screen("choose_type") elif event.button.id == "type_infrastructure": self.CONFIG_TYPE = "infrastructure" + self.push_screen("nfcore_question") + elif event.button.id == "type_nfcore": + self.NFCORE_CONFIG = True self.push_screen("basic_details") elif event.button.id == "type_pipeline": self.CONFIG_TYPE = "pipeline" + self.push_screen("nfcore_question") + elif event.button.id == "type_custom": + self.NFCORE_CONFIG = False self.push_screen("basic_details") elif event.button.id == "next": self.push_screen("final") @@ -89,4 +98,4 @@ def on_button_pressed(self, event: Button.Pressed) -> None: ## User theme options def action_toggle_dark(self) -> None: """An action to toggle dark mode.""" - self.dark: bool = not self.dark + self.theme: str = "textual-dark" if self.theme == "textual-light" else "textual-light" diff --git a/nf_core/configs/create/nfcorequestion.py b/nf_core/configs/create/nfcorequestion.py new file mode 100644 index 0000000000..4775297135 --- /dev/null +++ b/nf_core/configs/create/nfcorequestion.py @@ -0,0 +1,64 @@ +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) From 33e88ddd0d6bf55958d20c0fbfc0632380776d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Mir=20Pedrol?= Date: Wed, 2 Apr 2025 15:31:41 +0200 Subject: [PATCH 18/25] add validation of basicdetails --- nf_core/configs/create/__init__.py | 2 -- nf_core/configs/create/basicdetails.py | 2 ++ nf_core/configs/create/utils.py | 43 ++++++++++++++++++-------- nf_core/utils.py | 2 +- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 2acb70e37e..8d798d4313 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -87,8 +87,6 @@ def on_button_pressed(self, event: Button.Pressed) -> None: elif event.button.id == "type_custom": self.NFCORE_CONFIG = False self.push_screen("basic_details") - elif event.button.id == "next": - self.push_screen("final") ## General options if event.button.id == "close_app": self.exit(return_code=0) diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index e59a7f0a08..649d1b1b88 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -92,5 +92,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: text_input.query_one(".validation_msg").update("") try: 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 ed86085f61..95cd0b120b 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, ValidationError, 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 @@ -25,17 +26,26 @@ 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 class ConfigsCreateConfig(BaseModel): """Pydantic model for the nf-core configs create config.""" general_config_type: Optional[str] = None + """ Config file type (infrastructure or 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") @@ -106,28 +116,35 @@ def __init__(self, field_id, placeholder, description, default=None, password=No self.password: bool = password def compose(self) -> ComposeResult: - yield Static(self.description, classes="field_help") - yield Input( - placeholder=self.placeholder, - validators=[ValidateConfig(self.field_id)], - value=self.default, - password=self.password, + 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", ) - yield Static(classes="validation_msg") @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.""" - if not event.validation_result.is_valid: - self.query_one(".validation_msg").update("\n".join(event.validation_result.failure_descriptions)) + 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: - self.query_one(".validation_msg").update("") + 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.""" @@ -141,7 +158,7 @@ def validate(self, value: str) -> ValidationResult: If it fails, return the error messages.""" try: - with init_context({"is_infrastructure": CONFIG_ISINFRASTRUCTURE_GLOBAL}): + with init_context({"is_nfcore": NFCORE_CONFIG_GLOBAL}): ConfigsCreateConfig(**{f"{self.key}": value}) return self.success() except ValidationError as e: diff --git a/nf_core/utils.py b/nf_core/utils.py index dd02dae55b..9faa0a2d3f 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -1143,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") From 227abc20a059ec9175b72473ddece7cb88796804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Mir=20Pedrol?= Date: Wed, 2 Apr 2025 17:07:49 +0200 Subject: [PATCH 19/25] conditional validation for nf-core configs --- nf_core/configs/create/__init__.py | 11 ++-- nf_core/configs/create/basicdetails.py | 1 + nf_core/configs/create/utils.py | 87 ++++++++++++++++++-------- 3 files changed, 68 insertions(+), 31 deletions(-) diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 8d798d4313..e6eab1933c 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -9,12 +9,13 @@ 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.nfcorequestion import ChooseNfcoreConfig -from nf_core.configs.create.utils import ConfigsCreateConfig from nf_core.configs.create.welcome import WelcomeScreen ## General utilities @@ -35,7 +36,7 @@ ## Main workflow -class ConfigsCreateApp(App[ConfigsCreateConfig]): +class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): """A Textual app to create nf-core configs.""" CSS_PATH = "../../textual.tcss" @@ -56,11 +57,11 @@ class ConfigsCreateApp(App[ConfigsCreateConfig]): } # Initialise config as empty - TEMPLATE_CONFIG = ConfigsCreateConfig() + TEMPLATE_CONFIG = utils.ConfigsCreateConfig() # Tracking variables CONFIG_TYPE = None - NFCORE_CONFIG = None + NFCORE_CONFIG = True # Log handler LOG_HANDLER = rich_log_handler @@ -80,12 +81,14 @@ def on_button_pressed(self, event: Button.Pressed) -> None: 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" 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": diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index 649d1b1b88..536f5dc232 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -85,6 +85,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: for text_input in self.query("TextInput"): this_input = text_input.query_one(Input) validation_result = this_input.validate(this_input.value) + print(f"validation result {validation_result}") 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)) diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 95cd0b120b..c053a22228 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -1,10 +1,11 @@ """Config creation specific functions and classes""" +import re from contextlib import contextmanager from contextvars import ContextVar from typing import Any, Dict, Iterator, Optional, Union -from pydantic import BaseModel, ConfigDict, ValidationError, field_validator +from pydantic import BaseModel, ConfigDict, ValidationError, ValidationInfo, field_validator from textual import on from textual.app import ComposeResult from textual.containers import Grid @@ -67,32 +68,63 @@ def notempty(cls, v: str) -> str: raise ValueError("Cannot be left empty.") return v - # @field_validator( - # "config_profile_handle", - # ) - # @classmethod - # def handle_prefix(cls, v: str) -> str: - # """Check that GitHub handles start with '@'.""" - # if 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 '@'.") - # return v - - # @field_validator( - # "config_profile_url", - # ) - # @classmethod - # def url_prefix(cls, v: str) -> str: - # """Check that institutional web links start with valid URL prefix.""" - # if 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 + @field_validator("config_profile_contact", "config_profile_description") + @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"]: + print("here") + 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) @@ -159,6 +191,7 @@ def validate(self, value: str) -> ValidationResult: If it fails, return the error messages.""" try: with init_context({"is_nfcore": NFCORE_CONFIG_GLOBAL}): + print(f"global config: {NFCORE_CONFIG_GLOBAL}") ConfigsCreateConfig(**{f"{self.key}": value}) return self.success() except ValidationError as e: From 48ce7a5b73b837a62d11b2ac4f787b3d5edd4d3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Mir=20Pedrol?= Date: Mon, 5 May 2025 14:25:26 +0200 Subject: [PATCH 20/25] remove config author and url from pipeline configs --- nf_core/configs/create/basicdetails.py | 45 ++++++++++++------------ nf_core/configs/create/create.py | 21 ++++------- nf_core/configs/create/nfcorequestion.py | 1 + nf_core/configs/create/utils.py | 2 -- 4 files changed, 30 insertions(+), 39 deletions(-) diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index 536f5dc232..7ca6253a55 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -43,34 +43,36 @@ def compose(self) -> ComposeResult: "", classes="column", ) - with Horizontal(): - yield TextInput( - "config_profile_contact", - "Boaty McBoatFace", - "Author full name.", - classes="column", - ) + if self.parent.CONFIG_TYPE == "infrastructure": + with Horizontal(): + yield TextInput( + "config_profile_contact", + "Boaty McBoatFace", + "Author full name.", + classes="column", + ) - yield TextInput( - "config_profile_handle", - "@BoatyMcBoatFace", - "Author Git(Hub) handle.", - classes="column", - ) + yield TextInput( + "config_profile_handle", + "@BoatyMcBoatFace", + "Author Git(Hub) handle.", + classes="column", + ) 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).", - disabled=( - self.parent.CONFIG_TYPE == "pipeline" - ), ## TODO update TextInput to accept replace with visibility: https://textual.textualize.io/styles/visibility/ - ) + if self.parent.CONFIG_TYPE == "infrastructure": + yield TextInput( + "config_profile_url", + "https://nf-co.re", + "URL of infrastructure website or owning institution (infrastructure configs only).", + disabled=( + self.parent.CONFIG_TYPE == "pipeline" + ), ## TODO update TextInput to accept replace with visibility: https://textual.textualize.io/styles/visibility/ + ) yield Center( Button("Back", id="back", variant="default"), Button("Next", id="next", variant="success"), @@ -85,7 +87,6 @@ def on_button_pressed(self, event: Button.Pressed) -> None: for text_input in self.query("TextInput"): this_input = text_input.query_one(Input) validation_result = this_input.validate(this_input.value) - print(f"validation result {validation_result}") 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)) diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py index f6b6f90b97..0d58551089 100644 --- a/nf_core/configs/create/create.py +++ b/nf_core/configs/create/create.py @@ -12,32 +12,26 @@ def __init__(self, template_config: ConfigsCreateConfig): def construct_params(self, contact, handle, description, url): final_params = {} - print("c:" + contact) - print("h: " + handle) - - if contact != "": - if handle != "": + 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 != "": + elif handle is not None: final_params["config_profile_contact"] = handle - if description != "": + if description is not None: final_params["config_profile_description"] = description - if url != "": + if url is not None: final_params["config_profile_url"] = url - print("final_params") - print(final_params) return final_params def write_to_file(self): ## File name option - print(self.template_config) - filename = self.template_config.general_config_name + ".conf" + 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( @@ -47,9 +41,6 @@ def write_to_file(self): self.template_config.config_profile_url, ) - print("validparams") - print(validparams) - with open(filename, "w+") as file: ## Write params if any(validparams): diff --git a/nf_core/configs/create/nfcorequestion.py b/nf_core/configs/create/nfcorequestion.py index 4775297135..f93ad035a9 100644 --- a/nf_core/configs/create/nfcorequestion.py +++ b/nf_core/configs/create/nfcorequestion.py @@ -62,3 +62,4 @@ def compose(self) -> ComposeResult: 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 index c053a22228..1351fa95fa 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -74,7 +74,6 @@ 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"]: - print("here") if v.strip() == "": raise ValueError("Cannot be left empty.") return v @@ -191,7 +190,6 @@ def validate(self, value: str) -> ValidationResult: If it fails, return the error messages.""" try: with init_context({"is_nfcore": NFCORE_CONFIG_GLOBAL}): - print(f"global config: {NFCORE_CONFIG_GLOBAL}") ConfigsCreateConfig(**{f"{self.key}": value}) return self.success() except ValidationError as e: From 078520a9492f89998773bc4551496bbacd1b5828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Mir=20Pedrol?= Date: Mon, 5 May 2025 15:23:50 +0200 Subject: [PATCH 21/25] fix custom fields using hide class and add pipeline name or path --- nf_core/configs/create/__init__.py | 2 + nf_core/configs/create/basicdetails.py | 74 ++++++++++++++++++-------- nf_core/configs/create/utils.py | 21 ++++++-- 3 files changed, 70 insertions(+), 27 deletions(-) diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index e6eab1933c..771c711054 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -78,6 +78,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: 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 @@ -85,6 +86,7 @@ def on_button_pressed(self, event: Button.Pressed) -> None: 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 diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index 7ca6253a55..5d3206150f 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -13,6 +13,7 @@ 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.** @@ -43,36 +44,44 @@ def compose(self) -> ComposeResult: "", classes="column", ) - if self.parent.CONFIG_TYPE == "infrastructure": - with Horizontal(): - yield TextInput( - "config_profile_contact", - "Boaty McBoatFace", - "Author full name.", - classes="column", - ) + with Horizontal(): + yield TextInput( + "config_profile_contact", + "Boaty McBoatFace", + "Author full name.", + classes="column" + " hide" if self.parent.CONFIG_TYPE == "pipeline" else "", + ) - yield TextInput( - "config_profile_handle", - "@BoatyMcBoatFace", - "Author Git(Hub) handle.", - classes="column", - ) + yield TextInput( + "config_profile_handle", + "@BoatyMcBoatFace", + "Author Git(Hub) handle.", + classes="column" + " hide" if self.parent.CONFIG_TYPE == "pipeline" else "", + ) + 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.", ) - if self.parent.CONFIG_TYPE == "infrastructure": - yield TextInput( - "config_profile_url", - "https://nf-co.re", - "URL of infrastructure website or owning institution (infrastructure configs only).", - disabled=( - self.parent.CONFIG_TYPE == "pipeline" - ), ## TODO update TextInput to accept replace with visibility: https://textual.textualize.io/styles/visibility/ - ) + 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"), @@ -98,3 +107,22 @@ def on_button_pressed(self, event: Button.Pressed) -> None: 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/utils.py b/nf_core/configs/create/utils.py index 1351fa95fa..12310322fd 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -3,6 +3,7 @@ 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 @@ -35,6 +36,10 @@ class ConfigsCreateConfig(BaseModel): 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 @@ -58,9 +63,7 @@ def __init__(self, /, **data: Any) -> None: context=_init_context_var.get(), ) - @field_validator( - "general_config_name", - ) + @field_validator("general_config_name") @classmethod def notempty(cls, v: str) -> str: """Check that string values are not empty.""" @@ -68,7 +71,17 @@ def notempty(cls, v: str) -> str: raise ValueError("Cannot be left empty.") return v - @field_validator("config_profile_contact", "config_profile_description") + @field_validator("config_pipeline_path") + @classmethod + def path_valid(cls, v: str) -> str: + """Check that a path is valid.""" + 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.""" From a55d26bfd1061f980ff65fd2ac650f7a324b9ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Mir=20Pedrol?= Date: Mon, 5 May 2025 16:48:16 +0200 Subject: [PATCH 22/25] add screen to select if config is for an HPC or not --- nf_core/configs/create/__init__.py | 2 + nf_core/configs/create/basicdetails.py | 10 +++-- nf_core/configs/create/hpcquestion.py | 52 ++++++++++++++++++++++++++ nf_core/configs/create/utils.py | 14 ++++--- 4 files changed, 68 insertions(+), 10 deletions(-) create mode 100644 nf_core/configs/create/hpcquestion.py diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 771c711054..364601132f 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -15,6 +15,7 @@ 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 @@ -54,6 +55,7 @@ class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): "nfcore_question": ChooseNfcoreConfig, "basic_details": BasicDetails, "final": FinalScreen, + "hpc_question": ChooseHpc, } # Initialise config as empty diff --git a/nf_core/configs/create/basicdetails.py b/nf_core/configs/create/basicdetails.py index 5d3206150f..d9fb416c72 100644 --- a/nf_core/configs/create/basicdetails.py +++ b/nf_core/configs/create/basicdetails.py @@ -49,14 +49,13 @@ def compose(self) -> ComposeResult: "config_profile_contact", "Boaty McBoatFace", "Author full name.", - classes="column" + " hide" if self.parent.CONFIG_TYPE == "pipeline" else "", + 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 "", + classes="column hide" if self.parent.CONFIG_TYPE == "pipeline" else "column", ) yield TextInput( "config_pipeline_name", @@ -104,7 +103,10 @@ def on_button_pressed(self, event: Button.Pressed) -> None: try: self.parent.TEMPLATE_CONFIG = ConfigsCreateConfig(**config) if event.button.id == "next": - self.parent.push_screen("final") + 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 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/utils.py b/nf_core/configs/create/utils.py index 12310322fd..1c5b91e94e 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -73,12 +73,14 @@ def notempty(cls, v: str) -> str: @field_validator("config_pipeline_path") @classmethod - def path_valid(cls, v: str) -> str: + def path_valid(cls, v: str, info: ValidationInfo) -> str: """Check that a path is valid.""" - if v.strip() == "": - raise ValueError("Cannot be left empty.") - if not Path(v).is_dir(): - raise ValueError("Must be a valid path.") + 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") @@ -202,7 +204,7 @@ def validate(self, value: str) -> ValidationResult: If it fails, return the error messages.""" try: - with init_context({"is_nfcore": NFCORE_CONFIG_GLOBAL}): + 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: From b0ad75992ad50148deb5604c480f3e4607d512e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Mir=20Pedrol?= Date: Fri, 5 Sep 2025 11:38:09 +0200 Subject: [PATCH 23/25] use 'local' for no HPC --- nf_core/configs/create/hpcquestion.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nf_core/configs/create/hpcquestion.py b/nf_core/configs/create/hpcquestion.py index 9663db1736..6556c2d9cd 100644 --- a/nf_core/configs/create/hpcquestion.py +++ b/nf_core/configs/create/hpcquestion.py @@ -12,10 +12,10 @@ You want to create a config file for an HPC. """ -markdown_type_pc = """ -## Choose _"PC"_ if: +markdown_type_local = """ +## Choose _"local"_ if: -You want to create a config file to run your pipeline on a personal computer. +You want to create a config file to run your pipeline on a local computer. """ markdown_details = """ @@ -43,8 +43,8 @@ def compose(self) -> ComposeResult: Center(Button("HPC", id="type_hpc", variant="success")), ), Center( - Markdown(markdown_type_pc), - Center(Button("PC", id="type_pc", variant="primary")), + Markdown(markdown_type_local), + Center(Button("local", id="type_local", variant="primary")), ), classes="col-2 pipeline-type-grid", ) From d557970efec65bb01dda51ae33539380ad67048d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Mir=20Pedrol?= Date: Fri, 5 Sep 2025 12:36:27 +0200 Subject: [PATCH 24/25] add HPC configuration screen --- nf_core/configs/create/__init__.py | 4 + nf_core/configs/create/hpccustomisation.py | 110 +++++++++++++++++++++ nf_core/configs/create/utils.py | 12 ++- 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 nf_core/configs/create/hpccustomisation.py diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 364601132f..21a95ca51b 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -15,6 +15,7 @@ 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.hpccustomisation import HpcCustomisation from nf_core.configs.create.hpcquestion import ChooseHpc from nf_core.configs.create.nfcorequestion import ChooseNfcoreConfig from nf_core.configs.create.welcome import WelcomeScreen @@ -56,6 +57,7 @@ class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): "basic_details": BasicDetails, "final": FinalScreen, "hpc_question": ChooseHpc, + "hpc_customisation": HpcCustomisation, } # Initialise config as empty @@ -94,6 +96,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.NFCORE_CONFIG = False utils.NFCORE_CONFIG_GLOBAL = False self.push_screen("basic_details") + elif event.button.id == "type_hpc": + self.push_screen("hpc_customisation") ## General options if event.button.id == "close_app": self.exit(return_code=0) diff --git a/nf_core/configs/create/hpccustomisation.py b/nf_core/configs/create/hpccustomisation.py new file mode 100644 index 0000000000..0928f392d9 --- /dev/null +++ b/nf_core/configs/create/hpccustomisation.py @@ -0,0 +1,110 @@ +import subprocess +from typing import Optional + +from textual.app import ComposeResult +from textual.containers import Center, Horizontal +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Markdown + +from nf_core.configs.create.utils import ( + TextInput, +) + +markdown_intro = """ +# Configure the options for your HPC +""" + + +class HpcCustomisation(Screen): + """Customise the options to create a config for an HPC.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + scheduler = self._get_scheduler() + queues = self._get_queues(scheduler) + module_system_used = self._detect_module_system() + yield Markdown(markdown_intro) + with Horizontal(): + yield TextInput( + "scheduler", + "Scheduler", + "The scheduler in your HPC.", + default=scheduler if scheduler is not None else "Scheduler", + classes="column", + ) + yield TextInput( + "queue", + "Queue", + "The queue in your HPC.", + classes="column", + suggestions=queues, + ) + yield TextInput( + "module_system", + "Other modules to load", + "Do you need to load other software using the module system for your compute nodes?", + classes="hide" if not module_system_used else "", + ) + yield Center( + Button("Back", id="back", variant="default"), + Button("Continue", id="toconfiguration", variant="success"), + classes="cta", + ) + + def _get_scheduler(self) -> Optional[str]: + """Get the used scheduler""" + try: + subprocess.run(["sinfo", "--version"]) + return "slurm" + except FileNotFoundError: + pass + except subprocess.CalledProcessError: + pass + try: + subprocess.run(["qstat", "--version"]) + return "pbs" + except FileNotFoundError: + pass + except subprocess.CalledProcessError: + pass + try: + subprocess.run(["qstat", "-help"]) + return "sge" + except FileNotFoundError: + pass + except subprocess.CalledProcessError: + pass + return None + + def _get_queues(self, scheduler: Optional[str]) -> list[str]: + """Get the available queues to use for the jobs""" + if scheduler == "slurm": + try: + queues = subprocess.check_output(["sinfo", "-o", '"%P,%c,%m,%l"']).decode("utf-8") + return queues.split("\n") + except subprocess.CalledProcessError: + pass + elif scheduler == "pbs": + try: + queues = subprocess.check_output(["qstat", "-q"]).decode("utf-8") + return queues.split("\n") + except subprocess.CalledProcessError: + pass + elif scheduler == "sge": + try: + queues = subprocess.check_output(["qhost", "-q"]).decode("utf-8") + return queues.split("\n") + except subprocess.CalledProcessError: + pass + return [] + + def _detect_module_system(self) -> bool: + """Detect if a module system is used""" + try: + subprocess.check_output(["module", "--version"]) + except FileNotFoundError: + return False + except subprocess.CalledProcessError: + return False + return True diff --git a/nf_core/configs/create/utils.py b/nf_core/configs/create/utils.py index 1c5b91e94e..3f7dd54e9d 100644 --- a/nf_core/configs/create/utils.py +++ b/nf_core/configs/create/utils.py @@ -1,15 +1,17 @@ """Config creation specific functions and classes""" import re +from collections.abc import Iterator from contextlib import contextmanager from contextvars import ContextVar from pathlib import Path -from typing import Any, Dict, Iterator, Optional, Union +from typing import Any, 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.suggester import SuggestFromList from textual.validation import ValidationResult, Validator from textual.widgets import Input, Static @@ -18,7 +20,7 @@ @contextmanager -def init_context(value: Dict[str, Any]) -> Iterator[None]: +def init_context(value: dict[str, Any]) -> Iterator[None]: token = _init_context_var.set(value) try: yield @@ -149,7 +151,9 @@ class TextInput(Static): and validation messages. """ - def __init__(self, field_id, placeholder, description, default=None, password=None, **kwargs) -> None: + def __init__( + self, field_id, placeholder, description, default=None, password=None, suggestions=[], **kwargs + ) -> None: """Initialise the widget with our values. Pass on kwargs upstream for standard usage.""" @@ -160,6 +164,7 @@ def __init__(self, field_id, placeholder, description, default=None, password=No self.description: str = description self.default: str = default self.password: bool = password + self.suggestions: list[str] = suggestions def compose(self) -> ComposeResult: yield Grid( @@ -169,6 +174,7 @@ def compose(self) -> ComposeResult: validators=[ValidateConfig(self.field_id)], value=self.default, password=self.password, + suggester=SuggestFromList(self.suggestions, case_sensitive=False), ), Static(classes="validation_msg"), classes="text-input-grid", From 580598b13abd7a79179dbd34913c76337ff71ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Mir=20Pedrol?= Date: Fri, 5 Sep 2025 16:57:14 +0200 Subject: [PATCH 25/25] add screen for final infrastructure config details - containers cache dir not working --- nf_core/configs/create/__init__.py | 6 + nf_core/configs/create/finalinfradetails.py | 166 ++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 nf_core/configs/create/finalinfradetails.py diff --git a/nf_core/configs/create/__init__.py b/nf_core/configs/create/__init__.py index 21a95ca51b..ef8bd8b92f 100644 --- a/nf_core/configs/create/__init__.py +++ b/nf_core/configs/create/__init__.py @@ -15,6 +15,7 @@ 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.finalinfradetails import FinalInfraDetails from nf_core.configs.create.hpccustomisation import HpcCustomisation from nf_core.configs.create.hpcquestion import ChooseHpc from nf_core.configs.create.nfcorequestion import ChooseNfcoreConfig @@ -58,6 +59,7 @@ class ConfigsCreateApp(App[utils.ConfigsCreateConfig]): "final": FinalScreen, "hpc_question": ChooseHpc, "hpc_customisation": HpcCustomisation, + "final_infra_details": FinalInfraDetails, } # Initialise config as empty @@ -98,6 +100,10 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self.push_screen("basic_details") elif event.button.id == "type_hpc": self.push_screen("hpc_customisation") + elif event.button.id == "toconfiguration": + self.push_screen("final_infra_details") + elif event.button.id == "finish": + self.push_screen("final") ## General options if event.button.id == "close_app": self.exit(return_code=0) diff --git a/nf_core/configs/create/finalinfradetails.py b/nf_core/configs/create/finalinfradetails.py new file mode 100644 index 0000000000..41e0d6015b --- /dev/null +++ b/nf_core/configs/create/finalinfradetails.py @@ -0,0 +1,166 @@ +import os +import subprocess +from typing import Optional + +from textual import on +from textual.app import ComposeResult +from textual.containers import Center, Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Input, Markdown, Static, Switch + +from nf_core.configs.create.utils import ( + TextInput, +) +from nf_core.utils import add_hide_class, remove_hide_class + +markdown_intro = """ +# Configure the options for your infrastructure config +""" + + +class FinalInfraDetails(Screen): + """Customise the options to create a config for an infrastructure.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.container_system = None + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown(markdown_intro) + container_systems = self._get_container_systems() + yield TextInput( + "container_system", + "Container system", + "What container or software system will you use to run your pipeline?", + classes="", + suggestions=container_systems, + ) + yield Markdown("## Maximum resources") + with Horizontal(): + yield TextInput( + "memory", + "Memory", + "Maximum memory available in your machine.", + classes="column", + ) + yield TextInput( + "cpus", + "CPUs", + "Maximum number of CPUs available in your machine.", + classes="column", + ) + yield TextInput( + "time", + "Time", + "Maximum time to run your jobs.", + classes="column", + ) + yield Markdown("## Do you want to define a global cache directory for containers or conda environments?") + with Horizontal(): + yield TextInput( + "envvar", + "Nextflow cachedir environment variable", + "Environment variable to define a global cache directory.", + classes="", + default=f"NXF_{self.container_system.upper()}_CACHEDIR" if self.container_system is not None else "", + ) + yield TextInput( + "cachedir", + f"NXF_{self.container_system.upper()}_CACHEDIR" if self.container_system is not None else "", + "Define a global cache direcotry.", + classes="", + default=self._get_set_directory(f"NXF_{self.container_system.upper()}_CACHEDIR") + if self.container_system is not None + else "", + ) + yield TextInput( + "igenomes_cachedir", + "iGenomes cache directory", + "If you have an iGenomes cache direcotry, specify it.", + classes="hide" if not self.parent.NFCORE_CONFIG else "", + ) + yield TextInput( + "scratch_dir", + "Scratch directory", + "If you have to use a specific scratch direcotry, specify it.", + classes="", + ) + with Horizontal(classes="ghrepo-cols"): + yield Switch(value=False, id="private") + with Vertical(): + yield Static("Delete work directory", classes="") + yield Markdown( + "Select if you want to delete the files in the `work/` directory on successful completion of a run.", + classes="feature_subtitle", + ) + yield TextInput( + "retries", + "Number of retries", + "Specify the number of retries for a failed job.", + classes="", + ) + yield Center( + Button("Back", id="back", variant="default"), + Button("Finish", id="finish", variant="success"), + classes="cta", + ) + + def _get_container_systems(self) -> list[str]: + """Get the available container systems to use for software handling.""" + module_system_used = self._detect_module_system() + container_systems = ["singularity", "docker", "apptainer", "charliecloud", "podman", "sarus", "shifter"] + available_systems = [] + if module_system_used: + for system in container_systems: + try: + output = subprocess.check_output(["module", "avail", "|", "grep", system]).decode("utf-8") + if output: + available_systems.append(system) + except subprocess.CalledProcessError: + continue + else: + for system in container_systems: + try: + output = subprocess.check_output([system]).decode("utf-8") + if output: + available_systems.append(system) + except FileNotFoundError: + continue + except subprocess.CalledProcessError: + continue + return available_systems + + def _detect_module_system(self) -> bool: + """Detect if a module system is used""" + try: + subprocess.check_output(["module", "--version"]) + except FileNotFoundError: + return False + except subprocess.CalledProcessError: + return False + return True + + def _get_set_directory(self, dir: str) -> Optional[str]: + """Get the available cache directories""" + if dir: + set_dir = os.environ.get(dir) + if set_dir: + return set_dir + return None + + @on(Input.Changed) + def get_container_system(self) -> None: + """Get the container system from the input.""" + self.container_system = None + for text_input in self.query("TextInput"): + this_input = text_input.query_one(Input) + if text_input.field_id == "container_system": + self.container_system = this_input.value + if self.container_system is not None: + add_hide_class(self.parent, "cachedir") + add_hide_class(self.parent, "envvar") + else: + remove_hide_class(self.parent, "cachedir") + remove_hide_class(self.parent, "envvar")