diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d3f4f088c..efc17348ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ - The `modules_nfcore` tag in the `main.nf.test` file of modules/subworkflows now displays the organization name in custom modules repositories ([#3005](https://github.com/nf-core/tools/pull/3005)) +### 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)) 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/__main__.py b/nf_core/__main__.py index ea0018e2af..1c952b48e5 100644 --- a/nf_core/__main__.py +++ b/nf_core/__main__.py @@ -17,7 +17,12 @@ from nf_core.modules.modules_repo import NF_CORE_MODULES_REMOTE from nf_core.pipelines.download import DownloadError from nf_core.pipelines.params_file import ParamsFileBuilder -from nf_core.utils import check_if_outdated, nfcore_logo, rich_force_colors, setup_nfcore_dir +from nf_core.utils import ( + check_if_outdated, + nfcore_logo, + rich_force_colors, + setup_nfcore_dir, +) # Set up logging as the root logger # Submodules should all traverse back to this @@ -37,6 +42,7 @@ "pipelines", "modules", "subworkflows", + "configs", "interface", ], }, @@ -48,7 +54,14 @@ }, { "name": "For developers", - "commands": ["create", "lint", "bump-version", "sync", "schema", "create-logo"], + "commands": [ + "create", + "lint", + "bump-version", + "sync", + "schema", + "create-logo", + ], }, ], "nf-core modules": [ @@ -71,12 +84,13 @@ "commands": ["create", "lint", "test"], }, ], - "nf-core pipelines schema": [{"name": "Schema commands", "commands": ["validate", "build", "lint", "docs"]}], -} -click.rich_click.OPTION_GROUPS = { - "nf-core modules list local": [{"options": ["--dir", "--json", "--help"]}], + "nf-core configs": [ + { + "name": "Config commands", + "commands": ["create"], + }, + ], } - # Set up rich stderr console stderr = rich.console.Console(stderr=True, force_terminal=rich_force_colors()) stdout = rich.console.Console(force_terminal=rich_force_colors()) @@ -147,7 +161,9 @@ def run_nf_core(): command="interface", help="Launch the nf-core interface", ) -@click.group(context_settings=dict(help_option_names=["-h", "--help"]), cls=CustomRichGroup) +@click.group( + context_settings=dict(help_option_names=["-h", "--help"]), cls=CustomRichGroup +) @click.version_option(__version__) @click.option( "-v", @@ -156,8 +172,12 @@ def run_nf_core(): default=False, help="Print verbose output to the console.", ) -@click.option("--hide-progress", is_flag=True, default=False, help="Don't show progress bars.") -@click.option("-l", "--log-file", help="Save a verbose log to a file.", metavar="") +@click.option( + "--hide-progress", is_flag=True, default=False, help="Don't show progress bars." +) +@click.option( + "-l", "--log-file", help="Save a verbose log to a file.", metavar="" +) @click.pass_context def nf_core_cli(ctx, verbose, hide_progress, log_file): """ @@ -172,7 +192,9 @@ def nf_core_cli(ctx, verbose, hide_progress, log_file): log.addHandler( rich.logging.RichHandler( level=logging.DEBUG if verbose else logging.INFO, - console=rich.console.Console(stderr=True, force_terminal=rich_force_colors()), + console=rich.console.Console( + stderr=True, force_terminal=rich_force_colors() + ), show_time=False, show_path=verbose, # True if verbose, false otherwise markup=True, @@ -187,12 +209,17 @@ def nf_core_cli(ctx, verbose, hide_progress, log_file): if log_file: log_fh = logging.FileHandler(log_file, encoding="utf-8") log_fh.setLevel(logging.DEBUG) - log_fh.setFormatter(logging.Formatter("[%(asctime)s] %(name)-20s [%(levelname)-7s] %(message)s")) + log_fh.setFormatter( + logging.Formatter( + "[%(asctime)s] %(name)-20s [%(levelname)-7s] %(message)s" + ) + ) log.addHandler(log_fh) ctx.obj = { "verbose": verbose, - "hide_progress": hide_progress or verbose, # Always hide progress bar with verbose logging + "hide_progress": hide_progress + or verbose, # Always hide progress bar with verbose logging } @@ -217,19 +244,35 @@ def pipelines(ctx): type=str, help="The name of your new pipeline", ) -@click.option("-d", "--description", type=str, help="A short description of your pipeline") +@click.option( + "-d", "--description", type=str, help="A short description of your pipeline" +) @click.option("-a", "--author", type=str, help="Name of the main author(s)") -@click.option("--version", type=str, default="1.0.0dev", help="The initial version number to use") -@click.option("-f", "--force", is_flag=True, default=False, help="Overwrite output directory if it already exists") -@click.option("-o", "--outdir", help="Output directory for new pipeline (default: pipeline name)") -@click.option("-t", "--template-yaml", help="Pass a YAML file to customize the template") +@click.option( + "--version", type=str, default="1.0.0dev", help="The initial version number to use" +) +@click.option( + "-f", + "--force", + is_flag=True, + default=False, + help="Overwrite output directory if it already exists", +) +@click.option( + "-o", "--outdir", help="Output directory for new pipeline (default: pipeline name)" +) +@click.option( + "-t", "--template-yaml", help="Pass a YAML file to customize the template" +) @click.option( "--organisation", type=str, default="nf-core", help="The name of the GitHub organisation where the pipeline will be hosted (default: nf-core)", ) -def create_pipeline(ctx, name, description, author, version, force, outdir, template_yaml, organisation): +def create_pipeline( + ctx, name, description, author, version, force, outdir, template_yaml, organisation +): """ Create a new pipeline using the nf-core template. @@ -258,7 +301,15 @@ def create_pipeline(ctx, name, description, author, version, force, outdir, temp except UserWarning as e: log.error(e) sys.exit(1) - elif name or description or author or version != "1.0.0dev" or force or outdir or organisation != "nf-core": + elif ( + name + or description + or author + or version != "1.0.0dev" + or force + or outdir + or organisation != "nf-core" + ): log.error( "[red]Partial arguments supplied.[/] " "Run without [i]any[/] arguments for an interactive interface, " @@ -284,7 +335,10 @@ def create_pipeline(ctx, name, description, author, version, force, outdir, temp @click.option( "--release", is_flag=True, - default=os.path.basename(os.path.dirname(os.environ.get("GITHUB_REF", "").strip(" '\""))) == "master" + default=os.path.basename( + os.path.dirname(os.environ.get("GITHUB_REF", "").strip(" '\"")) + ) + == "master" and os.environ.get("GITHUB_REPOSITORY", "").startswith("nf-core/") and not os.environ.get("GITHUB_REPOSITORY", "") == "nf-core/tools", help="Execute additional checks for release-ready workflows.", @@ -305,9 +359,15 @@ def create_pipeline(ctx, name, description, author, version, force, outdir, temp multiple=True, help="Run only these lint tests", ) -@click.option("-p", "--show-passed", is_flag=True, help="Show passing tests on the command line") -@click.option("-i", "--fail-ignored", is_flag=True, help="Convert ignored tests to failures") -@click.option("-w", "--fail-warned", is_flag=True, help="Convert warn tests to failures") +@click.option( + "-p", "--show-passed", is_flag=True, help="Show passing tests on the command line" +) +@click.option( + "-i", "--fail-ignored", is_flag=True, help="Convert ignored tests to failures" +) +@click.option( + "-w", "--fail-warned", is_flag=True, help="Convert warn tests to failures" +) @click.option( "--markdown", type=str, @@ -405,7 +465,9 @@ def lint_pipeline( type=click.Choice(["tar.gz", "tar.bz2", "zip", "none"]), help="Archive compression type", ) -@click.option("-f", "--force", is_flag=True, default=False, help="Overwrite existing files") +@click.option( + "-f", "--force", is_flag=True, default=False, help="Overwrite existing files" +) # TODO: Remove this in a future release. Deprecated in March 2024. @click.option( "-t", @@ -493,7 +555,9 @@ def download_pipeline( from nf_core.pipelines.download import DownloadWorkflow if tower: - log.warning("[red]The `-t` / `--tower` flag is deprecated. Please use `--platform` instead.[/]") + log.warning( + "[red]The `-t` / `--tower` flag is deprecated. Please use `--platform` instead.[/]" + ) dl = DownloadWorkflow( pipeline, @@ -525,7 +589,9 @@ def download_pipeline( metavar="", help="Output filename. Defaults to `nf-params.yml`.", ) -@click.option("-f", "--force", is_flag=True, default=False, help="Overwrite existing files") +@click.option( + "-f", "--force", is_flag=True, default=False, help="Overwrite existing files" +) @click.option( "-x", "--show-hidden", @@ -555,7 +621,9 @@ def create_params_file_pipeline(ctx, pipeline, revision, output, force, show_hid # nf-core pipelines launch @pipelines.command("launch") @click.argument("pipeline", required=False, metavar="") -@click.option("-r", "--revision", help="Release/branch/SHA of the project to run (if remote)") +@click.option( + "-r", "--revision", help="Release/branch/SHA of the project to run (if remote)" +) @click.option("-i", "--id", help="ID for web-gui launch parameter set") @click.option( "-c", @@ -652,7 +720,9 @@ def launch_pipeline( help="How to sort listed pipelines", ) @click.option("--json", is_flag=True, default=False, help="Print full output as JSON") -@click.option("--show-archived", is_flag=True, default=False, help="Print archived workflows") +@click.option( + "--show-archived", is_flag=True, default=False, help="Print archived workflows" +) @click.pass_context def list_pipelines(ctx, keywords, sort, json, show_archived): """ @@ -695,10 +765,23 @@ def list_pipelines(ctx, keywords, sort, json, show_archived): default=False, help="Force the creation of a pull-request, even if there are no changes.", ) -@click.option("-g", "--github-repository", type=str, help="GitHub PR: target repository.") +@click.option( + "-g", "--github-repository", type=str, help="GitHub PR: target repository." +) @click.option("-u", "--username", type=str, help="GitHub PR: auth username.") -@click.option("-t", "--template-yaml", help="Pass a YAML file to customize the template") -def sync_pipeline(ctx, dir, from_branch, pull_request, github_repository, username, template_yaml, force_pr): +@click.option( + "-t", "--template-yaml", help="Pass a YAML file to customize the template" +) +def sync_pipeline( + ctx, + dir, + from_branch, + pull_request, + github_repository, + username, + template_yaml, + force_pr, +): """ Sync a pipeline [cyan i]TEMPLATE[/] branch with the nf-core template. @@ -711,14 +794,26 @@ def sync_pipeline(ctx, dir, from_branch, pull_request, github_repository, userna the pipeline. It is run automatically for all pipelines when ever a new release of [link=https://github.com/nf-core/tools]nf-core/tools[/link] (and the included template) is made. """ - from nf_core.pipelines.sync import PipelineSync, PullRequestExceptionError, SyncExceptionError + from nf_core.pipelines.sync import ( + PipelineSync, + PullRequestExceptionError, + SyncExceptionError, + ) from nf_core.utils import is_pipeline_directory # Check if pipeline directory contains necessary files is_pipeline_directory(dir) # Sync the given pipeline dir - sync_obj = PipelineSync(dir, from_branch, pull_request, github_repository, username, template_yaml, force_pr) + sync_obj = PipelineSync( + dir, + from_branch, + pull_request, + github_repository, + username, + template_yaml, + force_pr, + ) try: sync_obj.sync() except (SyncExceptionError, PullRequestExceptionError) as e: @@ -757,7 +852,10 @@ def bump_version_pipeline(ctx, new_version, dir, nextflow): As well as the pipeline version, you can also change the required version of Nextflow. """ - from nf_core.pipelines.bump_version import bump_nextflow_version, bump_pipeline_version + from nf_core.pipelines.bump_version import ( + bump_nextflow_version, + bump_pipeline_version, + ) from nf_core.utils import Pipeline, is_pipeline_directory try: @@ -781,7 +879,9 @@ def bump_version_pipeline(ctx, new_version, dir, nextflow): # nf-core pipelines create-logo @pipelines.command("create-logo") @click.argument("logo-text", metavar="") -@click.option("-d", "--dir", type=click.Path(), default=".", help="Directory to save the logo in.") +@click.option( + "-d", "--dir", type=click.Path(), default=".", help="Directory to save the logo in." +) @click.option( "-n", "--name", @@ -839,6 +939,35 @@ def logo_pipeline(logo_text, dir, name, theme, width, format, force): 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 licences @nf_core_cli.command() @click.argument("pipeline", required=True, metavar="") @@ -877,7 +1006,9 @@ def pipeline_schema(): # nf-core pipelines schema validate @pipeline_schema.command("validate") @click.argument("pipeline", required=True, metavar="") -@click.argument("params", type=click.Path(exists=True), required=True, metavar="") +@click.argument( + "params", type=click.Path(exists=True), required=True, metavar="" +) def validate_schema(pipeline, params): """ Validate a set of parameters against a pipeline schema. @@ -1011,7 +1142,9 @@ def lint_schema(schema_path): default="markdown", help="Format to output docs in.", ) -@click.option("-f", "--force", is_flag=True, default=False, help="Overwrite existing files") +@click.option( + "-f", "--force", is_flag=True, default=False, help="Overwrite existing files" +) @click.option( "-c", "--columns", @@ -1025,7 +1158,9 @@ def docs_schema(schema_path, output, format, force, columns): Outputs parameter documentation for a pipeline schema. """ if not os.path.exists(schema_path): - log.error("Could not find 'nextflow_schema.json' in current directory. Please specify a path.") + log.error( + "Could not find 'nextflow_schema.json' in current directory. Please specify a path." + ) sys.exit(1) from nf_core.pipelines.schema import PipelineSchema @@ -1145,7 +1280,13 @@ def modules_list_local(ctx, keywords, json, dir): # pylint: disable=redefined-b # nf-core modules install @modules.command("install") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1167,7 +1308,9 @@ def modules_list_local(ctx, keywords, json, dir): # pylint: disable=redefined-b default=False, help="Force reinstallation of module if it already exists", ) -@click.option("-s", "--sha", type=str, metavar="", help="Install module at commit SHA") +@click.option( + "-s", "--sha", type=str, metavar="", help="Install module at commit SHA" +) def modules_install(ctx, tool, dir, prompt, force, sha): """ Install DSL2 modules within a pipeline. @@ -1197,7 +1340,13 @@ def modules_install(ctx, tool, dir, prompt, force, sha): # nf-core modules update @modules.command("update") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1206,7 +1355,9 @@ def modules_install(ctx, tool, dir, prompt, force, sha): default=".", help=r"Pipeline directory. [dim]\[default: current working directory][/]", ) -@click.option("-f", "--force", is_flag=True, default=False, help="Force update of module") +@click.option( + "-f", "--force", is_flag=True, default=False, help="Force update of module" +) @click.option( "-p", "--prompt", @@ -1214,7 +1365,9 @@ def modules_install(ctx, tool, dir, prompt, force, sha): default=False, help="Prompt for the version of the module", ) -@click.option("-s", "--sha", type=str, metavar="", help="Install module at commit SHA") +@click.option( + "-s", "--sha", type=str, metavar="", help="Install module at commit SHA" +) @click.option( "-a", "--all", @@ -1289,7 +1442,13 @@ def modules_update( # nf-core modules patch @modules.command() @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1326,7 +1485,13 @@ def patch(ctx, tool, dir, remove): # nf-core modules remove @modules.command("remove") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1357,7 +1522,9 @@ def modules_remove(ctx, dir, tool): @modules.command("create") @click.pass_context @click.argument("tool", type=str, required=False, metavar=" or ") -@click.option("-d", "--dir", type=click.Path(exists=True), default=".", metavar="") +@click.option( + "-d", "--dir", type=click.Path(exists=True), default=".", metavar="" +) @click.option( "-a", "--author", @@ -1480,7 +1647,13 @@ def create_module( # nf-core modules test @modules.command("test") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1495,7 +1668,9 @@ def create_module( default=False, help="Use defaults without prompting", ) -@click.option("-u", "--update", is_flag=True, default=False, help="Update existing snapshots") +@click.option( + "-u", "--update", is_flag=True, default=False, help="Update existing snapshots" +) @click.option( "-o", "--once", @@ -1539,7 +1714,13 @@ def test_module(ctx, tool, dir, no_prompts, update, once, profile): # nf-core modules lint @modules.command("lint") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1564,8 +1745,12 @@ def test_module(ctx, tool, dir, no_prompts, update, once, profile): help="Run only these lint tests", ) @click.option("-a", "--all", is_flag=True, help="Run on all modules") -@click.option("-w", "--fail-warned", is_flag=True, help="Convert warn tests to failures") -@click.option("--local", is_flag=True, help="Run additional lint tests for local modules") +@click.option( + "-w", "--fail-warned", is_flag=True, help="Convert warn tests to failures" +) +@click.option( + "--local", is_flag=True, help="Run additional lint tests for local modules" +) @click.option("--passed", is_flag=True, help="Show passed tests") @click.option( "--sort-by", @@ -1579,7 +1764,9 @@ def test_module(ctx, tool, dir, no_prompts, update, once, profile): is_flag=True, help="Fix the module version if a newer version is available", ) -def modules_lint(ctx, tool, dir, registry, key, all, fail_warned, local, passed, sort_by, fix_version): +def modules_lint( + ctx, tool, dir, registry, key, all, fail_warned, local, passed, sort_by, fix_version +): """ Lint one or more modules in a directory. @@ -1626,7 +1813,13 @@ def modules_lint(ctx, tool, dir, registry, key, all, fail_warned, local, passed, # nf-core modules info @modules.command("info") @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1665,7 +1858,13 @@ def modules_info(ctx, tool, dir): # nf-core modules bump-versions @modules.command() @click.pass_context -@click.argument("tool", type=str, callback=normalize_case, required=False, metavar=" or ") +@click.argument( + "tool", + type=str, + callback=normalize_case, + required=False, + metavar=" or ", +) @click.option( "-d", "--dir", @@ -1674,7 +1873,9 @@ def modules_info(ctx, tool, dir): metavar="", ) @click.option("-a", "--all", is_flag=True, help="Run on all modules") -@click.option("-s", "--show-all", is_flag=True, help="Show up-to-date modules in results too") +@click.option( + "-s", "--show-all", is_flag=True, help="Show up-to-date modules in results too" +) def bump_versions(ctx, tool, dir, all, show_all): """ Bump versions for one or more modules in a clone of @@ -1690,7 +1891,9 @@ def bump_versions(ctx, tool, dir, all, show_all): ctx.obj["modules_repo_branch"], ctx.obj["modules_repo_no_pull"], ) - version_bumper.bump_versions(module=tool, all_modules=all, show_uptodate=show_all) + version_bumper.bump_versions( + module=tool, all_modules=all, show_uptodate=show_all + ) except ModuleExceptionError as e: log.error(e) sys.exit(1) @@ -1741,7 +1944,9 @@ def subworkflows(ctx, git_remote, branch, no_pull): @subworkflows.command("create") @click.pass_context @click.argument("subworkflow", type=str, required=False, metavar="subworkflow name") -@click.option("-d", "--dir", type=click.Path(exists=True), default=".", metavar="") +@click.option( + "-d", "--dir", type=click.Path(exists=True), default=".", metavar="" +) @click.option( "-a", "--author", @@ -1776,7 +1981,9 @@ def create_subworkflow(ctx, subworkflow, dir, author, force, migrate_pytest): # Run function try: - subworkflow_create = SubworkflowCreate(dir, subworkflow, author, force, migrate_pytest) + subworkflow_create = SubworkflowCreate( + dir, subworkflow, author, force, migrate_pytest + ) subworkflow_create.create() except UserWarning as e: log.critical(e) @@ -1789,7 +1996,13 @@ def create_subworkflow(ctx, subworkflow, dir, author, force, migrate_pytest): # nf-core subworkflows test @subworkflows.command("test") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -1804,7 +2017,9 @@ def create_subworkflow(ctx, subworkflow, dir, author, force, migrate_pytest): default=False, help="Use defaults without prompting", ) -@click.option("-u", "--update", is_flag=True, default=False, help="Update existing snapshots") +@click.option( + "-u", "--update", is_flag=True, default=False, help="Update existing snapshots" +) @click.option( "-o", "--once", @@ -1893,7 +2108,9 @@ def subworkflows_list_remote(ctx, keywords, json): default=".", help=r"Pipeline directory. [dim]\[default: Current working directory][/]", ) -def subworkflows_list_local(ctx, keywords, json, dir): # pylint: disable=redefined-builtin +def subworkflows_list_local( + ctx, keywords, json, dir +): # pylint: disable=redefined-builtin """ List subworkflows installed locally in a pipeline """ @@ -1916,7 +2133,13 @@ def subworkflows_list_local(ctx, keywords, json, dir): # pylint: disable=redefi # nf-core subworkflows lint @subworkflows.command("lint") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -1941,8 +2164,12 @@ def subworkflows_list_local(ctx, keywords, json, dir): # pylint: disable=redefi help="Run only these lint tests", ) @click.option("-a", "--all", is_flag=True, help="Run on all subworkflows") -@click.option("-w", "--fail-warned", is_flag=True, help="Convert warn tests to failures") -@click.option("--local", is_flag=True, help="Run additional lint tests for local subworkflows") +@click.option( + "-w", "--fail-warned", is_flag=True, help="Convert warn tests to failures" +) +@click.option( + "--local", is_flag=True, help="Run additional lint tests for local subworkflows" +) @click.option("--passed", is_flag=True, help="Show passed tests") @click.option( "--sort-by", @@ -1951,7 +2178,9 @@ def subworkflows_list_local(ctx, keywords, json, dir): # pylint: disable=redefi help="Sort lint output by subworkflow or test name.", show_default=True, ) -def subworkflows_lint(ctx, subworkflow, dir, registry, key, all, fail_warned, local, passed, sort_by): +def subworkflows_lint( + ctx, subworkflow, dir, registry, key, all, fail_warned, local, passed, sort_by +): """ Lint one or more subworkflows in a directory. @@ -1997,7 +2226,13 @@ def subworkflows_lint(ctx, subworkflow, dir, registry, key, all, fail_warned, lo # nf-core subworkflows info @subworkflows.command("info") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -2036,7 +2271,13 @@ def subworkflows_info(ctx, subworkflow, dir): # nf-core subworkflows install @subworkflows.command("install") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -2094,7 +2335,13 @@ def subworkflows_install(ctx, subworkflow, dir, prompt, force, sha): # nf-core subworkflows remove @subworkflows.command("remove") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -2124,7 +2371,13 @@ def subworkflows_remove(ctx, dir, subworkflow): # nf-core subworkflows update @subworkflows.command("update") @click.pass_context -@click.argument("subworkflow", type=str, callback=normalize_case, required=False, metavar="subworkflow name") +@click.argument( + "subworkflow", + type=str, + callback=normalize_case, + required=False, + metavar="subworkflow name", +) @click.option( "-d", "--dir", @@ -2132,7 +2385,9 @@ def subworkflows_remove(ctx, dir, subworkflow): default=".", help=r"Pipeline directory. [dim]\[default: current working directory][/]", ) -@click.option("-f", "--force", is_flag=True, default=False, help="Force update of subworkflow") +@click.option( + "-f", "--force", is_flag=True, default=False, help="Force update of subworkflow" +) @click.option( "-p", "--prompt", @@ -2236,7 +2491,9 @@ def schema(): # nf-core schema validate (deprecated) @schema.command("validate", deprecated=True) @click.argument("pipeline", required=True, metavar="") -@click.argument("params", type=click.Path(exists=True), required=True, metavar="") +@click.argument( + "params", type=click.Path(exists=True), required=True, metavar="" +) def validate(pipeline, params): """ DEPRECATED @@ -2382,7 +2639,9 @@ def schema_lint(schema_path): default="markdown", help="Format to output docs in.", ) -@click.option("-f", "--force", is_flag=True, default=False, help="Overwrite existing files") +@click.option( + "-f", "--force", is_flag=True, default=False, help="Overwrite existing files" +) @click.option( "-c", "--columns", @@ -2400,7 +2659,9 @@ def docs(schema_path, output, format, force, columns): "The `[magenta]nf-core schema docs[/]` command is deprecated. Use `[magenta]nf-core pipelines schema docs[/]` instead." ) if not os.path.exists(schema_path): - log.error("Could not find 'nextflow_schema.json' in current directory. Please specify a path.") + log.error( + "Could not find 'nextflow_schema.json' in current directory. Please specify a path." + ) sys.exit(1) from nf_core.pipelines.schema import PipelineSchema @@ -2415,7 +2676,9 @@ def docs(schema_path, output, format, force, columns): # nf-core create-logo (deprecated) @nf_core_cli.command("create-logo", deprecated=True, hidden=True) @click.argument("logo-text", metavar="") -@click.option("-d", "--dir", type=click.Path(), default=".", help="Directory to save the logo in.") +@click.option( + "-d", "--dir", type=click.Path(), default=".", help="Directory to save the logo in." +) @click.option( "-n", "--name", @@ -2505,10 +2768,16 @@ def logo(logo_text, dir, name, theme, width, format, force): default=False, help="Force the creation of a pull-request, even if there are no changes.", ) -@click.option("-g", "--github-repository", type=str, help="GitHub PR: target repository.") +@click.option( + "-g", "--github-repository", type=str, help="GitHub PR: target repository." +) @click.option("-u", "--username", type=str, help="GitHub PR: auth username.") -@click.option("-t", "--template-yaml", help="Pass a YAML file to customize the template") -def sync(dir, from_branch, pull_request, github_repository, username, template_yaml, force_pr): +@click.option( + "-t", "--template-yaml", help="Pass a YAML file to customize the template" +) +def sync( + dir, from_branch, pull_request, github_repository, username, template_yaml, force_pr +): """ DEPRECATED Sync a pipeline [cyan i]TEMPLATE[/] branch with the nf-core template. @@ -2525,14 +2794,26 @@ def sync(dir, from_branch, pull_request, github_repository, username, template_y log.warning( "The `[magenta]nf-core sync[/]` command is deprecated. Use `[magenta]nf-core pipelines sync[/]` instead." ) - from nf_core.pipelines.sync import PipelineSync, PullRequestExceptionError, SyncExceptionError + from nf_core.pipelines.sync import ( + PipelineSync, + PullRequestExceptionError, + SyncExceptionError, + ) from nf_core.utils import is_pipeline_directory # Check if pipeline directory contains necessary files is_pipeline_directory(dir) # Sync the given pipeline dir - sync_obj = PipelineSync(dir, from_branch, pull_request, github_repository, username, template_yaml, force_pr) + sync_obj = PipelineSync( + dir, + from_branch, + pull_request, + github_repository, + username, + template_yaml, + force_pr, + ) try: sync_obj.sync() except (SyncExceptionError, PullRequestExceptionError) as e: @@ -2574,7 +2855,10 @@ def bump_version(new_version, dir, nextflow): log.warning( "The `[magenta]nf-core bump-version[/]` command is deprecated. Use `[magenta]nf-core pipelines bump-version[/]` instead." ) - from nf_core.pipelines.bump_version import bump_nextflow_version, bump_pipeline_version + from nf_core.pipelines.bump_version import ( + bump_nextflow_version, + bump_pipeline_version, + ) from nf_core.utils import Pipeline, is_pipeline_directory try: @@ -2606,7 +2890,9 @@ def bump_version(new_version, dir, nextflow): help="How to sort listed pipelines", ) @click.option("--json", is_flag=True, default=False, help="Print full output as JSON") -@click.option("--show-archived", is_flag=True, default=False, help="Print archived workflows") +@click.option( + "--show-archived", is_flag=True, default=False, help="Print archived workflows" +) def list(keywords, sort, json, show_archived): """ DEPRECATED @@ -2626,7 +2912,9 @@ def list(keywords, sort, json, show_archived): # nf-core launch (deprecated) @nf_core_cli.command(deprecated=True, hidden=True) @click.argument("pipeline", required=False, metavar="") -@click.option("-r", "--revision", help="Release/branch/SHA of the project to run (if remote)") +@click.option( + "-r", "--revision", help="Release/branch/SHA of the project to run (if remote)" +) @click.option("-i", "--id", help="ID for web-gui launch parameter set") @click.option( "-c", @@ -2726,7 +3014,9 @@ def launch( metavar="", help="Output filename. Defaults to `nf-params.yml`.", ) -@click.option("-f", "--force", is_flag=True, default=False, help="Overwrite existing files") +@click.option( + "-f", "--force", is_flag=True, default=False, help="Overwrite existing files" +) @click.option( "-x", "--show-hidden", @@ -2772,7 +3062,9 @@ def create_params_file(pipeline, revision, output, force, show_hidden): type=click.Choice(["tar.gz", "tar.bz2", "zip", "none"]), help="Archive compression type", ) -@click.option("-f", "--force", is_flag=True, default=False, help="Overwrite existing files") +@click.option( + "-f", "--force", is_flag=True, default=False, help="Overwrite existing files" +) @click.option( "-t", "--tower", @@ -2859,7 +3151,9 @@ def download( from nf_core.pipelines.download import DownloadWorkflow if tower: - log.warning("[red]The `-t` / `--tower` flag is deprecated. Please use `--platform` instead.[/]") + log.warning( + "[red]The `-t` / `--tower` flag is deprecated. Please use `--platform` instead.[/]" + ) dl = DownloadWorkflow( pipeline, @@ -2891,7 +3185,10 @@ def download( @click.option( "--release", is_flag=True, - default=os.path.basename(os.path.dirname(os.environ.get("GITHUB_REF", "").strip(" '\""))) == "master" + default=os.path.basename( + os.path.dirname(os.environ.get("GITHUB_REF", "").strip(" '\"")) + ) + == "master" and os.environ.get("GITHUB_REPOSITORY", "").startswith("nf-core/") and not os.environ.get("GITHUB_REPOSITORY", "") == "nf-core/tools", help="Execute additional checks for release-ready workflows.", @@ -2912,9 +3209,15 @@ def download( multiple=True, help="Run only these lint tests", ) -@click.option("-p", "--show-passed", is_flag=True, help="Show passing tests on the command line") -@click.option("-i", "--fail-ignored", is_flag=True, help="Convert ignored tests to failures") -@click.option("-w", "--fail-warned", is_flag=True, help="Convert warn tests to failures") +@click.option( + "-p", "--show-passed", is_flag=True, help="Show passing tests on the command line" +) +@click.option( + "-i", "--fail-ignored", is_flag=True, help="Convert ignored tests to failures" +) +@click.option( + "-w", "--fail-warned", is_flag=True, help="Convert warn tests to failures" +) @click.option( "--markdown", type=str, @@ -3008,12 +3311,24 @@ def lint( type=str, help="The name of your new pipeline", ) -@click.option("-d", "--description", type=str, help="A short description of your pipeline") +@click.option( + "-d", "--description", type=str, help="A short description of your pipeline" +) @click.option("-a", "--author", type=str, help="Name of the main author(s)") @click.option("--version", type=str, help="The initial version number to use") -@click.option("-f", "--force", is_flag=True, default=False, help="Overwrite output directory if it already exists") -@click.option("-o", "--outdir", help="Output directory for new pipeline (default: pipeline name)") -@click.option("-t", "--template-yaml", help="Pass a YAML file to customize the template") +@click.option( + "-f", + "--force", + is_flag=True, + default=False, + help="Overwrite output directory if it already exists", +) +@click.option( + "-o", "--outdir", help="Output directory for new pipeline (default: pipeline name)" +) +@click.option( + "-t", "--template-yaml", help="Pass a YAML file to customize the template" +) @click.option("--plain", is_flag=True, help="Use the standard nf-core template") @click.option( "--organisation", @@ -3021,7 +3336,17 @@ def lint( default="nf-core", help="The name of the GitHub organisation where the pipeline will be hosted (default: nf-core)", ) -def create(name, description, author, version, force, outdir, template_yaml, plain, organisation): +def create( + name, + description, + author, + version, + force, + outdir, + template_yaml, + plain, + organisation, +): """ DEPRECATED Create a new pipeline using the nf-core template. @@ -3052,7 +3377,15 @@ def create(name, description, author, version, force, outdir, template_yaml, pla except UserWarning as e: log.error(e) sys.exit(1) - elif name or description or author or version != "1.0.0dev" or force or outdir or organisation != "nf-core": + elif ( + name + or description + or author + or version != "1.0.0dev" + or force + or outdir + or organisation != "nf-core" + ): log.error( "[red]Partial arguments supplied.[/] " "Run without [i]any[/] arguments for an interactive interface, " 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..f3ec38c7df --- /dev/null +++ b/nf_core/configs/create/__init__.py @@ -0,0 +1,94 @@ +"""A Textual app to create a config.""" + +import logging + +## Textual objects +from textual.app import App +from textual.widgets import Button + +## 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 + +## General utilities +from nf_core.utils import ( + CustomLogHandler, + LoggingConsole, +) + +## 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 = "../../textual.tcss" + TITLE = "nf-core configs create" + SUB_TITLE = "Create a new nextflow config with an interactive interface" + BINDINGS = [ + ("d", "toggle_dark", "Toggle dark mode"), + ("q", "quit", "Quit"), + ] + + ## New question screens (sections) loaded here + SCREENS = { + "welcome": WelcomeScreen(), + "choose_type": ChooseConfigType(), + "basic_details": BasicDetails(), + "final": FinalScreen(), + } + + # Initialise config as empty + TEMPLATE_CONFIG = CreateConfig() + + # Tracking variables + CONFIG_TYPE = None + + # 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") + + 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") + elif event.button.id == "next": + self.push_screen("final") + ## General options + if event.button.id == "close_app": + self.exit(return_code=0) + if event.button.id == "back": + self.pop_screen() + + ## 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/basicdetails.py b/nf_core/configs/create/basicdetails.py new file mode 100644 index 0000000000..3db1c4be28 --- /dev/null +++ b/nf_core/configs/create/basicdetails.py @@ -0,0 +1,98 @@ +"""Get basic contact information to set in params to help with debugging. By +displaying such info in the pipeline run header on run execution""" + +from textwrap import dedent + +from textual import on +from textual.app import ComposeResult +from textual.containers import Center, Horizontal +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Input, Markdown + +from nf_core.configs.create.utils import ( + CreateConfig, + TextInput, +) ## TODO Move somewhere common? + +config_exists_warn = """ +> ⚠️ **The config file you are trying to create already exists.** +> +> If you continue, you will **overwrite** the existing config. +> Please change the config name to create a different config!. +""" + + +class BasicDetails(Screen): + """Name, description, author, etc.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown( + dedent( + """ + # Basic details + """ + ) + ) + ## TODO Add validation, .conf already exists? + yield TextInput( + "general_config_name", + "custom", + "Config Name. Used for naming resulting file.", + "", + classes="column", + ) + with Horizontal(): + yield TextInput( + "config_profile_contact", + "Boaty McBoatFace", + "Author full name.", + classes="column", + ) + + 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/ + ) + yield Center( + Button("Back", id="back", variant="default"), + Button("Next", id="next", variant="success"), + classes="cta", + ) + + ## Updates the __init__ initialised TEMPLATE_CONFIG object (which is built from the 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 new file mode 100644 index 0000000000..c0adc1f458 --- /dev/null +++ b/nf_core/configs/create/configtype.py @@ -0,0 +1,84 @@ +"""Select which type of config to create to guide questions and order""" + +from textual.app import ComposeResult +from textual.containers import Center, Grid +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Markdown + +markdown_intro = """ +# Choose config type +""" + +markdown_type_nfcore = """ +## Choose _"Infrastructure config"_ if: + +* You want to only define the computational environment you will run all pipelines on + + +""" +markdown_type_custom = """ +## Choose _"Pipeline config"_ if: + +* You just want to tweak resources of a particular step of a specific pipeline. +""" + +markdown_details = """ +## What's the difference? + +_Infrastructure_ configs: + +- Describe the basic necessary information for any nf-core pipeline to +execute +- Define things such as which container engine to use, if there is a scheduler and +which queues to use etc. +- Are suitable for _all_ users on a given computing environment. +- Can be uploaded to [nf-core +configs](https://github.com/nf-core/tools/configs) to be directly accessible +in a nf-core pipeline with `-profile `. +- Are not used to tweak specific parts of a given pipeline (such as a process or +module) + +_Pipeline_ configs + +- Are config files that target specific component of a particular pipeline or pipeline run. + - Example: you have a particular step of the pipeline that often runs out +of memory using the pipeline's default settings. You would use this config to +increase the amount of memory Nextflow supplies that given task. +- Are normally only used by a _single or small group_ of users. +- _May_ also be shared amongst multiple users on the same +computing environment if running similar data with the same pipeline. +- Can _sometimes_ be uploaded to [nf-core +configs](https://github.com/nf-core/tools/configs) as a 'pipeline-specific' +config. + + +""" + + +class ChooseConfigType(Screen): + """Choose whether this will be an infrastructure or pipeline config.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Markdown(markdown_intro) + yield Grid( + Center( + Markdown(markdown_type_nfcore), + Center( + Button( + "Infrastructure config", + id="type_infrastructure", + variant="success", + ) + ), + ), + Center( + Markdown(markdown_type_custom), + Center( + Button("Pipeline config", id="type_pipeline", variant="primary") + ), + ), + classes="col-2 pipeline-type-grid", + ) + yield Markdown(markdown_details) diff --git a/nf_core/configs/create/create.py b/nf_core/configs/create/create.py new file mode 100644 index 0000000000..333e79e907 --- /dev/null +++ b/nf_core/configs/create/create.py @@ -0,0 +1,61 @@ +import json + +from nf_core.configs.create.utils import CreateConfig, generate_config_entry + + +class ConfigCreate: + def __init__(self, template_config: CreateConfig): + self.template_config = template_config + + def construct_params(self, contact, handle, description, url): + final_params = {} + + 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 != "": + final_params["config_profile_contact"] = handle + + if description != "": + final_params["config_profile_description"] = description + + if url != "": + 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" + + ## Collect all config entries per scope, for later checking scope needs to be written + validparams = self.construct_params( + self.template_config.config_profile_contact, + self.template_config.config_profile_handle, + self.template_config.config_profile_description, + self.template_config.config_profile_url, + ) + + 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 != "": + file.write(generate_config_entry(self, entry_key, entry_value)) + else: + continue + file.write("}\n") diff --git a/nf_core/configs/create/final.py b/nf_core/configs/create/final.py new file mode 100644 index 0000000000..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 new file mode 100644 index 0000000000..de961723ea --- /dev/null +++ b/nf_core/configs/create/utils.py @@ -0,0 +1,161 @@ +"""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 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.""" + + general_config_type: str = None + general_config_name: str = None + config_profile_contact: 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") + + def __init__(self, /, **data: Any) -> None: + """Custom init method to allow using a context on the model initialization.""" + self.__pydantic_validator__.validate_python( + data, + self_instance=self, + context=_init_context_var.get(), + ) + + @field_validator( + "general_config_name", + ) + @classmethod + def notempty(cls, v: str) -> str: + """Check that string values are not empty.""" + if v.strip() == "": + raise ValueError("Cannot be left empty.") + return v + + # @field_validator( + # "config_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): + """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()])) + + +def generate_config_entry(self, key, value): + parsed_entry = " " + key + ' = "' + value + '"\n' + return parsed_entry diff --git a/nf_core/configs/create/welcome.py b/nf_core/configs/create/welcome.py new file mode 100644 index 0000000000..7bca8100d0 --- /dev/null +++ b/nf_core/configs/create/welcome.py @@ -0,0 +1,43 @@ +"""Intro information to help inform user what we are about to do""" + +from textual.app import ComposeResult +from textual.containers import Center +from textual.screen import Screen +from textual.widgets import Button, Footer, Header, Markdown, Static + +from nf_core.utils import nfcore_logo + +markdown = """ +# Welcome to the nf-core config creation wizard + +This app will help you create **Nextflow configuration files** +for both: + +- **Infrastructure** configs for defining computing environment for all + pipelines, and +- **Pipeline** configs for defining pipeline-specific resource requirements + +## Using Configs + +The resulting config file can be used with a pipeline with adding `-c +.conf` to a `nextflow run` command. + +They can also be added to the centralised +[nf-core/configs](https://github.com/nf-core/configs) repository, where they +can be used directly by anyone running nf-core pipelines on your infrastructure +specifying `nextflow run -profile `. +""" + + +class WelcomeScreen(Screen): + """A welcome screen for the app.""" + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + yield Static( + "\n" + "\n".join(nfcore_logo) + "\n", + id="logo", + ) + yield Markdown(markdown) + yield Center(Button("Let's go!", id="lets_go", variant="success"), classes="cta") diff --git a/nf_core/pipelines/create/__init__.py b/nf_core/pipelines/create/__init__.py index 56e25bf1d5..c11a3ef674 100644 --- a/nf_core/pipelines/create/__init__.py +++ b/nf_core/pipelines/create/__init__.py @@ -15,10 +15,12 @@ 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 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, @@ -35,7 +37,7 @@ class PipelineCreateApp(App[utils.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 = [ @@ -56,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 diff --git a/nf_core/pipelines/create/basicdetails.py b/nf_core/pipelines/create/basicdetails.py index 09484fa2ea..665440b7f2 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 f1e0bae3ce..43a3795bdf 100644 --- a/nf_core/pipelines/create/utils.py +++ b/nf_core/pipelines/create/utils.py @@ -4,17 +4,15 @@ from logging import LogRecord from pathlib import Path from typing import Any, Dict, Iterator, Optional, Union - from pydantic import BaseModel, ConfigDict, ValidationError, ValidationInfo, 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 # Use ContextVar to define a context on the model initialization _init_context_var: ContextVar = ContextVar("_init_context_var", default={}) @@ -155,21 +153,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.""" @@ -205,44 +188,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/pipelines/create/create.tcss b/nf_core/textual.tcss similarity index 100% rename from nf_core/pipelines/create/create.tcss rename to nf_core/textual.tcss diff --git a/nf_core/utils.py b/nf_core/utils.py index 48d1c3ca3d..4124e47f10 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")