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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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)