From ee55460162bf8ebc595a1d537b261cd49142f4af Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 11:26:50 +0100 Subject: [PATCH 01/22] Store the org URL in the nf-core config file --- nf_core/pipelines/create/basicdetails.py | 29 ++++++++++++++++++- nf_core/pipelines/create/create.py | 2 ++ nf_core/pipelines/create/utils.py | 12 +++++++- nf_core/utils.py | 13 +++++++++ .../test_basic_details_custom.svg | 20 ++++++------- .../test_basic_details_nfcore.svg | 20 ++++++------- .../test_type_nfcore_validation.svg | 20 ++++++------- tests/pipelines/test_create.py | 7 ++++- 8 files changed, 90 insertions(+), 33 deletions(-) diff --git a/nf_core/pipelines/create/basicdetails.py b/nf_core/pipelines/create/basicdetails.py index 2bd2ea1c79..00484796a5 100644 --- a/nf_core/pipelines/create/basicdetails.py +++ b/nf_core/pipelines/create/basicdetails.py @@ -10,6 +10,7 @@ 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.utils import get_org_url pipeline_exists_warn = """ > ⚠️ **The pipeline you are trying to create already exists.** @@ -22,6 +23,8 @@ class BasicDetails(Screen): """Name, description, author, etc.""" + _auto_org_url: str | None = None + def compose(self) -> ComposeResult: yield Header() yield Footer() @@ -58,6 +61,13 @@ def compose(self) -> ComposeResult: "Author(s)", "Name of the main author / authors", ) + yield TextInput( + "org_url", + "Organisation URL", + "Website URL for the organisation", + get_org_url("nf-core", self.parent.NFCORE_PIPELINE), + disabled=self.parent.NFCORE_PIPELINE, + ) yield Markdown(dedent(pipeline_exists_warn), id="exist_warn", classes="hide") yield Center( Button("Back", id="back", variant="default"), @@ -65,11 +75,27 @@ def compose(self) -> ComposeResult: classes="cta", ) + def _sync_org_url_input(self, force: bool = False) -> None: + """Keep the org URL in sync with the org name until the user overrides it.""" + # URL of nf-core pipelines is not modifiable + if self.parent.NFCORE_PIPELINE: + return + + org_input = self.query_one("#org", TextInput).query_one(Input) + org_url_input = self.query_one("#org_url", TextInput).query_one(Input) + suggested_url = get_org_url(org_input.value or "nf-core", self.parent.NFCORE_PIPELINE) + + if force or org_url_input.value in {"", self._auto_org_url}: + org_url_input.value = suggested_url + + self._auto_org_url = suggested_url + @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 overridden.""" + self._sync_org_url_input() config = {} for text_input in self.query("TextInput"): this_input = text_input.query_one(Input) @@ -84,8 +110,9 @@ def on_screen_resume(self): 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": + if text_input.field_id in {"org", "org_url"}: text_input.disabled = self.parent.NFCORE_PIPELINE + self._sync_org_url_input(force=True) @on(Button.Pressed) def on_button_pressed(self, event: Button.Pressed) -> None: diff --git a/nf_core/pipelines/create/create.py b/nf_core/pipelines/create/create.py index 055e5ae5bd..9c2d498912 100644 --- a/nf_core/pipelines/create/create.py +++ b/nf_core/pipelines/create/create.py @@ -194,6 +194,8 @@ def update_config(self, organisation, version, force, outdir): self.config.outdir = outdir if outdir else "." if self.config.is_nfcore is None or self.config.is_nfcore == "null": self.config.is_nfcore = self.config.org == "nf-core" + if self.config.org_url is None and self.config.org is not None: + self.config.org_url = nf_core.utils.get_org_url(self.config.org, self.config.is_nfcore) def obtain_jinja_params_dict(self, features_to_skip: list[str], pipeline_dir: str | Path) -> tuple[dict, list[str]]: """Creates a dictionary of parameters for the new pipeline. diff --git a/nf_core/pipelines/create/utils.py b/nf_core/pipelines/create/utils.py index 5751ddbd77..37fc93fb4f 100644 --- a/nf_core/pipelines/create/utils.py +++ b/nf_core/pipelines/create/utils.py @@ -4,6 +4,7 @@ from contextvars import ContextVar from pathlib import Path from typing import Any +from urllib.parse import urlparse import yaml from pydantic import ConfigDict, ValidationError, ValidationInfo, field_validator @@ -65,7 +66,7 @@ def name_nospecialchars(cls, v: str, info: ValidationInfo) -> str: raise ValueError("Must not contain special characters. Only '-' or '_' are allowed.") return v - @field_validator("org", "description", "author", "version", "outdir") + @field_validator("org", "org_url", "description", "author", "version", "outdir") @classmethod def notempty(cls, v: str) -> str: """Check that string values are not empty.""" @@ -73,6 +74,15 @@ def notempty(cls, v: str) -> str: raise ValueError("Cannot be left empty.") return v + @field_validator("org_url") + @classmethod + def valid_org_url(cls, v: str) -> str: + """Check that the organisation URL is a valid HTTP(S) URL.""" + parsed_url = urlparse(v) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + raise ValueError("Must be a valid http(s) URL.") + return v + @field_validator("version") @classmethod def version_nospecialchars(cls, v: str) -> str: diff --git a/nf_core/utils.py b/nf_core/utils.py index bac78de090..54375b3bd2 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -1235,11 +1235,20 @@ def get_repo_commit(pipeline, commit_id): DEPRECATED_CONFIG_PATHS = [".nf-core-lint.yml", ".nf-core-lint.yaml"] +def get_org_url(org_name: str, is_nfcore: bool | None = None) -> str: + """Return the canonical URL for a pipeline organisation.""" + if is_nfcore or org_name == "nf-core": + return "https://nf-co.re" + return f"https://github.com/{org_name}" + + class NFCoreTemplateConfig(BaseModel): """Template configuration schema""" org: str | None = None """ Organisation name """ + org_url: str | None = None + """ Organisation URL """ name: str | None = None """ Pipeline name """ description: str | None = None @@ -1427,6 +1436,10 @@ def model_dump(self, **kwargs) -> dict[str, Any]: for field in fields_to_exclude: config.pop(field, None) + template = config.get("template") + if isinstance(template, dict) and template.get("is_nfcore"): + template.pop("org_url", None) + return config diff --git a/tests/pipelines/__snapshots__/test_create_app/test_basic_details_custom.svg b/tests/pipelines/__snapshots__/test_create_app/test_basic_details_custom.svg index 7936809fd5..8803dbe400 100644 --- a/tests/pipelines/__snapshots__/test_create_app/test_basic_details_custom.svg +++ b/tests/pipelines/__snapshots__/test_create_app/test_basic_details_custom.svg @@ -212,7 +212,7 @@ - + nf-core pipelines create — Create a new pipeline with the nf-core pipeline templa… @@ -246,18 +246,18 @@ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Back  Next  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + +Website URL for the organisation - - - +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +https://nf-co.re                                                                             +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Back  Next  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/pipelines/__snapshots__/test_create_app/test_basic_details_nfcore.svg b/tests/pipelines/__snapshots__/test_create_app/test_basic_details_nfcore.svg index 7ef0eb9fcf..1db4258ca5 100644 --- a/tests/pipelines/__snapshots__/test_create_app/test_basic_details_nfcore.svg +++ b/tests/pipelines/__snapshots__/test_create_app/test_basic_details_nfcore.svg @@ -216,7 +216,7 @@ - + nf-core pipelines create — Create a new pipeline with the nf-core pipeline templa… @@ -250,18 +250,18 @@ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Back  Next  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + +Website URL for the organisation - - - +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +https://nf-co.re                                                                             +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Back  Next  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/pipelines/__snapshots__/test_create_app/test_type_nfcore_validation.svg b/tests/pipelines/__snapshots__/test_create_app/test_type_nfcore_validation.svg index c8cad16ff5..94a094e5a4 100644 --- a/tests/pipelines/__snapshots__/test_create_app/test_type_nfcore_validation.svg +++ b/tests/pipelines/__snapshots__/test_create_app/test_type_nfcore_validation.svg @@ -214,7 +214,7 @@ - + nf-core pipelines create — Create a new pipeline with the nf-core pipeline templa… @@ -249,18 +249,18 @@ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ Value error, Cannot be left empty. -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Back  Next  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + +Website URL for the organisation - - - +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +https://nf-co.re                                                                             +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Back  Next  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/pipelines/test_create.py b/tests/pipelines/test_create.py index ccd125d17e..63b23f2edb 100644 --- a/tests/pipelines/test_create.py +++ b/tests/pipelines/test_create.py @@ -43,6 +43,7 @@ def test_pipeline_creation(self, tmp_path): assert pipeline.config.description == self.pipeline_description assert pipeline.config.author == self.pipeline_author assert pipeline.config.version == self.pipeline_version + assert pipeline.config.org_url == "https://nf-co.re" @with_temporary_folder def test_pipeline_creation_initiation(self, tmp_path): @@ -61,7 +62,9 @@ def test_pipeline_creation_initiation(self, tmp_path): assert f" {self.default_branch}\n" in git.Repo.init(pipeline.outdir).git.branch() assert not Path(pipeline.outdir, "pipeline_template.yml").exists() with open(Path(pipeline.outdir, ".nf-core.yml")) as fh: - assert "template" in fh.read() + nfcore_yml = yaml.safe_load(fh) + assert "template" in nfcore_yml + assert "org_url" not in nfcore_yml["template"] @with_temporary_folder def test_pipeline_creation_initiation_with_yml(self, tmp_path): @@ -82,6 +85,7 @@ def test_pipeline_creation_initiation_with_yml(self, tmp_path): nfcore_yml = yaml.safe_load(fh) assert "template" in nfcore_yml assert yaml.safe_load(PIPELINE_TEMPLATE_YML.read_text()).items() <= nfcore_yml["template"].items() + assert nfcore_yml["template"]["org_url"] == "https://github.com/testprefix" @with_temporary_folder def test_pipeline_creation_initiation_customize_template(self, tmp_path): @@ -99,6 +103,7 @@ def test_pipeline_creation_initiation_customize_template(self, tmp_path): nfcore_yml = yaml.safe_load(fh) assert "template" in nfcore_yml assert yaml.safe_load(PIPELINE_TEMPLATE_YML.read_text()).items() <= nfcore_yml["template"].items() + assert nfcore_yml["template"]["org_url"] == "https://github.com/testprefix" @with_temporary_folder def test_pipeline_creation_with_yml_skip(self, tmp_path): From 2cc2e84e2c99b8c0c157d76efbcd0556cf0889ef Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 11:54:54 +0100 Subject: [PATCH 02/22] Also collect the org name --- nf_core/pipelines/create/basicdetails.py | 59 ++++++++++++++++-------- nf_core/pipelines/create/create.py | 2 + nf_core/pipelines/create/utils.py | 2 +- nf_core/utils.py | 3 ++ tests/pipelines/test_create.py | 4 ++ 5 files changed, 50 insertions(+), 20 deletions(-) diff --git a/nf_core/pipelines/create/basicdetails.py b/nf_core/pipelines/create/basicdetails.py index 00484796a5..de4df9807d 100644 --- a/nf_core/pipelines/create/basicdetails.py +++ b/nf_core/pipelines/create/basicdetails.py @@ -23,6 +23,7 @@ class BasicDetails(Screen): """Name, description, author, etc.""" + _auto_org_name: str | None = None _auto_org_url: str | None = None def compose(self) -> ComposeResult: @@ -61,13 +62,19 @@ def compose(self) -> ComposeResult: "Author(s)", "Name of the main author / authors", ) - yield TextInput( - "org_url", - "Organisation URL", - "Website URL for the organisation", - get_org_url("nf-core", self.parent.NFCORE_PIPELINE), - disabled=self.parent.NFCORE_PIPELINE, - ) + if not self.parent.NFCORE_PIPELINE: + yield TextInput( + "org_name", + "Organisation Name", + "Display name for the organisation", + "nf-core", + ) + yield TextInput( + "org_url", + "Organisation URL", + "Website URL for the organisation", + get_org_url("nf-core", self.parent.NFCORE_PIPELINE), + ) yield Markdown(dedent(pipeline_exists_warn), id="exist_warn", classes="hide") yield Center( Button("Back", id="back", variant="default"), @@ -75,31 +82,46 @@ def compose(self) -> ComposeResult: classes="cta", ) - def _sync_org_url_input(self, force: bool = False) -> None: - """Keep the org URL in sync with the org name until the user overrides it.""" - # URL of nf-core pipelines is not modifiable + def _sync_org_metadata_inputs(self, force: bool = False) -> None: + """Keep organisation metadata in sync with the org value until the user overrides it.""" if self.parent.NFCORE_PIPELINE: return org_input = self.query_one("#org", TextInput).query_one(Input) + org_name_input = self.query_one("#org_name", TextInput).query_one(Input) org_url_input = self.query_one("#org_url", TextInput).query_one(Input) + suggested_name = org_input.value or "nf-core" suggested_url = get_org_url(org_input.value or "nf-core", self.parent.NFCORE_PIPELINE) + if force or org_name_input.value in {"", self._auto_org_name}: + org_name_input.value = suggested_name + if force or org_url_input.value in {"", self._auto_org_url}: org_url_input.value = suggested_url + self._auto_org_name = suggested_name self._auto_org_url = suggested_url + def _get_config_values(self) -> dict[str, str]: + """Collect screen values and inject nf-core defaults for hidden fields.""" + config = {} + for text_input in self.query("TextInput"): + this_input = text_input.query_one(Input) + config[text_input.field_id] = this_input.value + + if self.parent.NFCORE_PIPELINE: + config["org_name"] = "nf-core" + config["org_url"] = get_org_url("nf-core", True) + + return config + @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 overridden.""" - self._sync_org_url_input() - config = {} - for text_input in self.query("TextInput"): - this_input = text_input.query_one(Input) - config[text_input.field_id] = this_input.value + self._sync_org_metadata_inputs() + config = self._get_config_values() if Path(config["org"] + "-" + config["name"]).is_dir(): remove_hide_class(self.parent, "exist_warn") else: @@ -110,22 +132,21 @@ def on_screen_resume(self): 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 in {"org", "org_url"}: + if text_input.field_id == "org": text_input.disabled = self.parent.NFCORE_PIPELINE - self._sync_org_url_input(force=True) + self._sync_org_metadata_inputs(force=True) @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("") + config = self._get_config_values() try: self.parent.TEMPLATE_CONFIG = CreateConfig(**config) if event.button.id == "next": diff --git a/nf_core/pipelines/create/create.py b/nf_core/pipelines/create/create.py index 9c2d498912..253121caef 100644 --- a/nf_core/pipelines/create/create.py +++ b/nf_core/pipelines/create/create.py @@ -194,6 +194,8 @@ def update_config(self, organisation, version, force, outdir): self.config.outdir = outdir if outdir else "." if self.config.is_nfcore is None or self.config.is_nfcore == "null": self.config.is_nfcore = self.config.org == "nf-core" + if self.config.org_name is None and self.config.org is not None: + self.config.org_name = "nf-core" if self.config.is_nfcore else self.config.org if self.config.org_url is None and self.config.org is not None: self.config.org_url = nf_core.utils.get_org_url(self.config.org, self.config.is_nfcore) diff --git a/nf_core/pipelines/create/utils.py b/nf_core/pipelines/create/utils.py index 37fc93fb4f..cd223f8af2 100644 --- a/nf_core/pipelines/create/utils.py +++ b/nf_core/pipelines/create/utils.py @@ -66,7 +66,7 @@ def name_nospecialchars(cls, v: str, info: ValidationInfo) -> str: raise ValueError("Must not contain special characters. Only '-' or '_' are allowed.") return v - @field_validator("org", "org_url", "description", "author", "version", "outdir") + @field_validator("org", "org_name", "org_url", "description", "author", "version", "outdir") @classmethod def notempty(cls, v: str) -> str: """Check that string values are not empty.""" diff --git a/nf_core/utils.py b/nf_core/utils.py index 54375b3bd2..ca9e1221df 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -1247,6 +1247,8 @@ class NFCoreTemplateConfig(BaseModel): org: str | None = None """ Organisation name """ + org_name: str | None = None + """ Full organisation name """ org_url: str | None = None """ Organisation URL """ name: str | None = None @@ -1438,6 +1440,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]: template = config.get("template") if isinstance(template, dict) and template.get("is_nfcore"): + template.pop("org_name", None) template.pop("org_url", None) return config diff --git a/tests/pipelines/test_create.py b/tests/pipelines/test_create.py index 63b23f2edb..96b01f2e95 100644 --- a/tests/pipelines/test_create.py +++ b/tests/pipelines/test_create.py @@ -43,6 +43,7 @@ def test_pipeline_creation(self, tmp_path): assert pipeline.config.description == self.pipeline_description assert pipeline.config.author == self.pipeline_author assert pipeline.config.version == self.pipeline_version + assert pipeline.config.org_name == "nf-core" assert pipeline.config.org_url == "https://nf-co.re" @with_temporary_folder @@ -64,6 +65,7 @@ def test_pipeline_creation_initiation(self, tmp_path): with open(Path(pipeline.outdir, ".nf-core.yml")) as fh: nfcore_yml = yaml.safe_load(fh) assert "template" in nfcore_yml + assert "org_name" not in nfcore_yml["template"] assert "org_url" not in nfcore_yml["template"] @with_temporary_folder @@ -85,6 +87,7 @@ def test_pipeline_creation_initiation_with_yml(self, tmp_path): nfcore_yml = yaml.safe_load(fh) assert "template" in nfcore_yml assert yaml.safe_load(PIPELINE_TEMPLATE_YML.read_text()).items() <= nfcore_yml["template"].items() + assert nfcore_yml["template"]["org_name"] == "testprefix" assert nfcore_yml["template"]["org_url"] == "https://github.com/testprefix" @with_temporary_folder @@ -103,6 +106,7 @@ def test_pipeline_creation_initiation_customize_template(self, tmp_path): nfcore_yml = yaml.safe_load(fh) assert "template" in nfcore_yml assert yaml.safe_load(PIPELINE_TEMPLATE_YML.read_text()).items() <= nfcore_yml["template"].items() + assert nfcore_yml["template"]["org_name"] == "testprefix" assert nfcore_yml["template"]["org_url"] == "https://github.com/testprefix" @with_temporary_folder From b82c2204df69d71bf37da6b3f4ab4fd13c7f41fa Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 13:07:12 +0100 Subject: [PATCH 03/22] Only sync the name and URL fields when editing the gihub org name --- nf_core/pipelines/create/basicdetails.py | 35 ++++++++++++++++--- tests/pipelines/test_create_app.py | 43 ++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/nf_core/pipelines/create/basicdetails.py b/nf_core/pipelines/create/basicdetails.py index de4df9807d..330a5d1e17 100644 --- a/nf_core/pipelines/create/basicdetails.py +++ b/nf_core/pipelines/create/basicdetails.py @@ -87,9 +87,9 @@ def _sync_org_metadata_inputs(self, force: bool = False) -> None: if self.parent.NFCORE_PIPELINE: return - org_input = self.query_one("#org", TextInput).query_one(Input) - org_name_input = self.query_one("#org_name", TextInput).query_one(Input) - org_url_input = self.query_one("#org_url", TextInput).query_one(Input) + org_input = self._get_input_widget("org") + org_name_input = self._get_input_widget("org_name") + org_url_input = self._get_input_widget("org_url") suggested_name = org_input.value or "nf-core" suggested_url = get_org_url(org_input.value or "nf-core", self.parent.NFCORE_PIPELINE) @@ -102,6 +102,10 @@ def _sync_org_metadata_inputs(self, force: bool = False) -> None: self._auto_org_name = suggested_name self._auto_org_url = suggested_url + def _get_input_widget(self, field_id: str) -> Input: + """Return the inner Textual input for a named TextInput wrapper.""" + return self.query_one(f"#{field_id}", TextInput).query_one(Input) + def _get_config_values(self) -> dict[str, str]: """Collect screen values and inject nf-core defaults for hidden fields.""" config = {} @@ -117,16 +121,37 @@ def _get_config_values(self) -> dict[str, str]: @on(Input.Changed) @on(Input.Submitted) - def show_exists_warn(self): + def show_exists_warn(self, event: Input.Changed | Input.Submitted) -> None: """Check if the pipeline exists on every input change or submitted. If the pipeline exists, show warning message saying that it will be overridden.""" - self._sync_org_metadata_inputs() + if event.input is self._get_input_widget("org"): + self._sync_org_metadata_inputs() config = self._get_config_values() if Path(config["org"] + "-" + config["name"]).is_dir(): remove_hide_class(self.parent, "exist_warn") else: add_hide_class(self.parent, "exist_warn") + @on(Input.Blurred) + def restore_empty_org_metadata_on_blur(self, event: Input.Blurred) -> None: + """Restore auto-managed org metadata only after the user leaves the field empty.""" + if self.parent.NFCORE_PIPELINE: + return + + org_input = self._get_input_widget("org") + for field_id in ("org_name", "org_url"): + if event.input is self._get_input_widget(field_id): + if event.input.value: + break + if field_id == "org_name": + restored_value = org_input.value or "nf-core" + self._auto_org_name = restored_value + else: + restored_value = get_org_url(org_input.value or "nf-core", self.parent.NFCORE_PIPELINE) + self._auto_org_url = restored_value + event.input.value = restored_value + break + def on_screen_resume(self): """Hide warn message on screen resume. Update displayed value on screen resume.""" diff --git a/tests/pipelines/test_create_app.py b/tests/pipelines/test_create_app.py index 144a51ac07..32e8b04d84 100644 --- a/tests/pipelines/test_create_app.py +++ b/tests/pipelines/test_create_app.py @@ -2,7 +2,11 @@ from unittest import mock +from textual.widgets import Input + from nf_core.pipelines.create import PipelineCreateApp +from nf_core.pipelines.create import utils as create_utils +from nf_core.pipelines.create.utils import TextInput INIT_FILE = "../../nf_core/pipelines/create/__init__.py" @@ -23,6 +27,45 @@ async def test_app_bindings(): assert app.return_code == 0 +async def test_custom_org_metadata_stays_empty_until_blur(): + """Empty org metadata fields should only be restored after the input loses focus.""" + app = PipelineCreateApp() + + async with app.run_test() as pilot: + pilot.app.NFCORE_PIPELINE = False + create_utils.NFCORE_PIPELINE_GLOBAL = False + pilot.app.push_screen("basic_details") + await pilot.pause() + + screen = pilot.app.screen + org_input = screen.query_one("#org", TextInput).query_one(Input) + org_name_input = screen.query_one("#org_name", TextInput).query_one(Input) + org_url_input = screen.query_one("#org_url", TextInput).query_one(Input) + + org_input.focus() + await pilot.pause() + await pilot.press("t", "e", "s", "t", "p", "r", "e", "f", "i", "x") + assert org_input.value == "testprefix" + assert org_name_input.value == "testprefix" + assert org_url_input.value == "https://github.com/testprefix" + + org_name_input.focus() + await pilot.pause() + await pilot.press("backspace") + assert org_name_input.value == "" + + org_url_input.focus() + await pilot.pause() + assert org_name_input.value == "testprefix" + + await pilot.press("backspace") + assert org_url_input.value == "" + + screen.query_one("#name", TextInput).query_one(Input).focus() + await pilot.pause() + assert org_url_input.value == "https://github.com/testprefix" + + def test_welcome(snap_compare): """Test snapshot for the first screen in the app. The welcome screen.""" assert snap_compare(INIT_FILE, terminal_size=(100, 50)) From c8a9c7b3193e9ca643592d3785cb6d133445c396 Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 17:03:11 +0100 Subject: [PATCH 04/22] Functioning usage URLs for GitHub sites --- nf_core/utils.py | 20 ++++++++++++++++++++ tests/test_utils.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/nf_core/utils.py b/nf_core/utils.py index ca9e1221df..2859b69882 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -23,6 +23,7 @@ from contextlib import contextmanager, suppress from pathlib import Path from typing import TYPE_CHECKING, Any, Literal +from urllib.parse import urlparse import git import prompt_toolkit.styles @@ -1242,6 +1243,25 @@ def get_org_url(org_name: str, is_nfcore: bool | None = None) -> str: return f"https://github.com/{org_name}" +def get_usage_docs_url(org_url: str, repo_name: str, short_name: str, default_branch: str) -> str: + """Return a forge-aware URL for the rendered usage documentation.""" + normalized_org_url = org_url.rstrip("/") + parsed_url = urlparse(normalized_org_url) + hostname = parsed_url.netloc.lower() + base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + + if hostname == "github.com" or hostname.startswith("github.") or ".github." in hostname: + return f"{base_url}/{repo_name}/blob/{default_branch}/docs/usage.md" + if hostname == "gitlab.com" or hostname.startswith("gitlab.") or ".gitlab." in hostname: + return f"{base_url}/{repo_name}/-/blob/{default_branch}/docs/usage.md" + if hostname == "bitbucket.org" or hostname.startswith("bitbucket.") or ".bitbucket." in hostname: + return f"{base_url}/{repo_name}/src/{default_branch}/docs/usage.md" + if hostname == "codeberg.org" or "forgejo" in hostname or "gitea" in hostname: + return f"{base_url}/{repo_name}/src/branch/{default_branch}/docs/usage.md" + + return f"{normalized_org_url}/{short_name}/usage" + + class NFCoreTemplateConfig(BaseModel): """Template configuration schema""" diff --git a/tests/test_utils.py b/tests/test_utils.py index fe5ff56d8e..ec84b05d7b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -56,6 +56,50 @@ def test_strip_ansi_codes(): assert stripped == "ls examplefile.zip" +@pytest.mark.parametrize( + ("org_url", "repo_name", "short_name", "default_branch", "expected"), + [ + ( + "https://github.com/my-org", + "my-org/testpipeline", + "testpipeline", + "main", + "https://github.com/my-org/testpipeline/blob/main/docs/usage.md", + ), + ( + "https://gitlab.com/my-org", + "my-org/testpipeline", + "testpipeline", + "main", + "https://gitlab.com/my-org/testpipeline/-/blob/main/docs/usage.md", + ), + ( + "https://bitbucket.org/my-org", + "my-org/testpipeline", + "testpipeline", + "main", + "https://bitbucket.org/my-org/testpipeline/src/main/docs/usage.md", + ), + ( + "https://codeberg.org/my-org", + "my-org/testpipeline", + "testpipeline", + "main", + "https://codeberg.org/my-org/testpipeline/src/branch/main/docs/usage.md", + ), + ( + "https://example.org/pipelines", + "my-org/testpipeline", + "testpipeline", + "main", + "https://example.org/pipelines/testpipeline/usage", + ), + ], +) +def test_get_usage_docs_url(org_url, repo_name, short_name, default_branch, expected): + assert nf_core.utils.get_usage_docs_url(org_url, repo_name, short_name, default_branch) == expected + + class TestUtils(TestPipelines): """Class for utils tests""" From 7ff42b4c430d2507c3ac708b3798780b19d6daa2 Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 17:44:21 +0100 Subject: [PATCH 05/22] Expanded to cover output docs --- nf_core/utils.py | 24 +++++++++++++++++------- tests/test_utils.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/nf_core/utils.py b/nf_core/utils.py index 2859b69882..1a366242aa 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -1243,23 +1243,33 @@ def get_org_url(org_name: str, is_nfcore: bool | None = None) -> str: return f"https://github.com/{org_name}" -def get_usage_docs_url(org_url: str, repo_name: str, short_name: str, default_branch: str) -> str: - """Return a forge-aware URL for the rendered usage documentation.""" +def _get_docs_url(org_url: str, repo_name: str, short_name: str, branch: str, doc_name: str) -> str: + """Return a forge-aware URL for a rendered documentation page.""" normalized_org_url = org_url.rstrip("/") parsed_url = urlparse(normalized_org_url) hostname = parsed_url.netloc.lower() base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" if hostname == "github.com" or hostname.startswith("github.") or ".github." in hostname: - return f"{base_url}/{repo_name}/blob/{default_branch}/docs/usage.md" + return f"{base_url}/{repo_name}/blob/{branch}/docs/{doc_name}.md" if hostname == "gitlab.com" or hostname.startswith("gitlab.") or ".gitlab." in hostname: - return f"{base_url}/{repo_name}/-/blob/{default_branch}/docs/usage.md" + return f"{base_url}/{repo_name}/-/blob/{branch}/docs/{doc_name}.md" if hostname == "bitbucket.org" or hostname.startswith("bitbucket.") or ".bitbucket." in hostname: - return f"{base_url}/{repo_name}/src/{default_branch}/docs/usage.md" + return f"{base_url}/{repo_name}/src/{branch}/docs/{doc_name}.md" if hostname == "codeberg.org" or "forgejo" in hostname or "gitea" in hostname: - return f"{base_url}/{repo_name}/src/branch/{default_branch}/docs/usage.md" + return f"{base_url}/{repo_name}/src/branch/{branch}/docs/{doc_name}.md" + + return f"{normalized_org_url}/{short_name}/{doc_name}" + + +def get_usage_docs_url(org_url: str, repo_name: str, short_name: str, branch: str) -> str: + """Return a forge-aware URL for the rendered usage documentation.""" + return _get_docs_url(org_url, repo_name, short_name, branch, "usage") + - return f"{normalized_org_url}/{short_name}/usage" +def get_output_docs_url(org_url: str, repo_name: str, short_name: str, branch: str) -> str: + """Return a forge-aware URL for the rendered output documentation.""" + return _get_docs_url(org_url, repo_name, short_name, branch, "output") class NFCoreTemplateConfig(BaseModel): diff --git a/tests/test_utils.py b/tests/test_utils.py index ec84b05d7b..46fd576605 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -100,6 +100,50 @@ def test_get_usage_docs_url(org_url, repo_name, short_name, default_branch, expe assert nf_core.utils.get_usage_docs_url(org_url, repo_name, short_name, default_branch) == expected +@pytest.mark.parametrize( + ("org_url", "repo_name", "short_name", "default_branch", "expected"), + [ + ( + "https://github.com/my-org", + "my-org/testpipeline", + "testpipeline", + "main", + "https://github.com/my-org/testpipeline/blob/main/docs/output.md", + ), + ( + "https://gitlab.com/my-org", + "my-org/testpipeline", + "testpipeline", + "main", + "https://gitlab.com/my-org/testpipeline/-/blob/main/docs/output.md", + ), + ( + "https://bitbucket.org/my-org", + "my-org/testpipeline", + "testpipeline", + "main", + "https://bitbucket.org/my-org/testpipeline/src/main/docs/output.md", + ), + ( + "https://codeberg.org/my-org", + "my-org/testpipeline", + "testpipeline", + "main", + "https://codeberg.org/my-org/testpipeline/src/branch/main/docs/output.md", + ), + ( + "https://example.org/pipelines", + "my-org/testpipeline", + "testpipeline", + "main", + "https://example.org/pipelines/testpipeline/output", + ), + ], +) +def test_get_output_docs_url(org_url, repo_name, short_name, default_branch, expected): + assert nf_core.utils.get_output_docs_url(org_url, repo_name, short_name, default_branch) == expected + + class TestUtils(TestPipelines): """Class for utils tests""" From d2ee60b31fe2f338094ab01b229cd0eb07c1269a Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 19:05:40 +0100 Subject: [PATCH 06/22] Revive the default values when loading config files --- nf_core/pipelines/create/basicdetails.py | 2 +- nf_core/pipelines/create/create.py | 2 +- nf_core/utils.py | 15 +++++++++++ tests/pipelines/test_sync.py | 32 ++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/nf_core/pipelines/create/basicdetails.py b/nf_core/pipelines/create/basicdetails.py index 330a5d1e17..1f11ca8bfb 100644 --- a/nf_core/pipelines/create/basicdetails.py +++ b/nf_core/pipelines/create/basicdetails.py @@ -114,7 +114,7 @@ def _get_config_values(self) -> dict[str, str]: config[text_input.field_id] = this_input.value if self.parent.NFCORE_PIPELINE: - config["org_name"] = "nf-core" + config["org_name"] = config["org"] config["org_url"] = get_org_url("nf-core", True) return config diff --git a/nf_core/pipelines/create/create.py b/nf_core/pipelines/create/create.py index 253121caef..094d6c1d65 100644 --- a/nf_core/pipelines/create/create.py +++ b/nf_core/pipelines/create/create.py @@ -195,7 +195,7 @@ def update_config(self, organisation, version, force, outdir): if self.config.is_nfcore is None or self.config.is_nfcore == "null": self.config.is_nfcore = self.config.org == "nf-core" if self.config.org_name is None and self.config.org is not None: - self.config.org_name = "nf-core" if self.config.is_nfcore else self.config.org + self.config.org_name = self.config.org if self.config.org_url is None and self.config.org is not None: self.config.org_url = nf_core.utils.get_org_url(self.config.org, self.config.is_nfcore) diff --git a/nf_core/utils.py b/nf_core/utils.py index 1a366242aa..d237fdaa8d 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -1476,6 +1476,19 @@ def model_dump(self, **kwargs) -> dict[str, Any]: return config +def _populate_template_org_metadata(template: NFCoreTemplateConfig | None) -> None: + """Backfill derived organisation fields needed to rebuild pipeline templates.""" + if template is None or template.org is None: + return + + if template.is_nfcore is None: + template.is_nfcore = template.org == "nf-core" + if template.org_name is None: + template.org_name = template.org + if template.org_url is None: + template.org_url = get_org_url(template.org, template.is_nfcore) + + def load_tools_config(directory: str | Path = ".") -> tuple[Path | None, NFCoreYamlConfig | None]: """ Parse the nf-core.yml configuration file @@ -1556,6 +1569,8 @@ def load_tools_config(directory: str | Path = ".") -> tuple[Path | None, NFCoreY is_nfcore=tools_config["template"].get("prefix", tools_config["template"].get("org")) == "nf-core", ) + _populate_template_org_metadata(nf_core_yaml_config.template) + log.debug("Using config file: %s", config_fn) return config_fn, nf_core_yaml_config diff --git a/tests/pipelines/test_sync.py b/tests/pipelines/test_sync.py index aa27170f26..13cb8a3c0f 100644 --- a/tests/pipelines/test_sync.py +++ b/tests/pipelines/test_sync.py @@ -10,6 +10,7 @@ import yaml import nf_core.pipelines.sync +import nf_core.utils from nf_core.utils import NFCoreYamlConfig from ..test_pipelines import TestPipelines @@ -307,6 +308,37 @@ def test_create_template_pipeline(self): assert (pipeline_path / "main.nf").exists() assert (pipeline_path / "nextflow.config").exists() + def test_create_template_pipeline_backfills_missing_org_url(self): + """Confirm that template rebuild still works when legacy config omits org metadata.""" + nf_core_yml_path = Path(self.pipeline_dir) / ".nf-core.yml" + with open(nf_core_yml_path) as fh: + nf_core_yml = yaml.safe_load(fh) + + nf_core_yml["template"].pop("org_name", None) + nf_core_yml["template"].pop("org_url", None) + + with open(nf_core_yml_path, "w") as fh: + yaml.safe_dump(nf_core_yml, fh) + + repo = git.Repo(self.pipeline_dir) + repo.git.add(str(nf_core_yml_path)) + repo.index.commit("Remove org metadata from template config") + + _, loaded_config = nf_core.utils.load_tools_config(self.pipeline_dir) + assert loaded_config is not None + assert loaded_config.template is not None + assert loaded_config.template.org_url == "https://nf-co.re" + + psync = nf_core.pipelines.sync.PipelineSync(self.pipeline_dir) + psync.inspect_sync_dir() + psync.get_wf_config() + psync.checkout_template_branch() + psync.delete_tracked_template_branch_files() + psync.make_template_pipeline() + + assert "main.nf" in os.listdir(self.pipeline_dir) + assert "nextflow.config" in os.listdir(self.pipeline_dir) + def test_commit_template_changes_nochanges(self): """Try to commit the TEMPLATE branch, but no changes were made""" # Check out the TEMPLATE branch but skip making the new template etc. From 5d095ad710a02495ebe5aa6a11b0b98b24b9c27f Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 19:11:31 +0100 Subject: [PATCH 07/22] Snapshot update --- .../test_basic_details_custom.svg | 68 +++++++++---------- .../test_basic_details_nfcore.svg | 20 +++--- .../test_customisation_help.svg | 54 +++++++-------- .../test_type_nfcore_validation.svg | 20 +++--- tests/pipelines/test_create_app.py | 16 +++-- 5 files changed, 90 insertions(+), 88 deletions(-) diff --git a/tests/pipelines/__snapshots__/test_create_app/test_basic_details_custom.svg b/tests/pipelines/__snapshots__/test_create_app/test_basic_details_custom.svg index 8803dbe400..4f2a1211f7 100644 --- a/tests/pipelines/__snapshots__/test_create_app/test_basic_details_custom.svg +++ b/tests/pipelines/__snapshots__/test_create_app/test_basic_details_custom.svg @@ -35,20 +35,16 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #e0e0e0 } .terminal-r3 { fill: #a0a3a6 } -.terminal-r4 { fill: #0178d4;font-weight: bold } -.terminal-r5 { fill: #a0a0a0;font-style: italic; } -.terminal-r6 { fill: #121212 } +.terminal-r4 { fill: #121212 } +.terminal-r5 { fill: #0178d4;font-weight: bold } +.terminal-r6 { fill: #a0a0a0;font-style: italic; } .terminal-r7 { fill: #008139 } .terminal-r8 { fill: #191919 } .terminal-r9 { fill: #737373 } .terminal-r10 { fill: #b93c5b } -.terminal-r11 { fill: #2d2d2d } -.terminal-r12 { fill: #7ae998 } -.terminal-r13 { fill: #e0e0e0;font-weight: bold } -.terminal-r14 { fill: #0a180e;font-weight: bold } -.terminal-r15 { fill: #0d0d0d } -.terminal-r16 { fill: #495259 } -.terminal-r17 { fill: #ffa62b;font-weight: bold } +.terminal-r11 { fill: #000000 } +.terminal-r12 { fill: #495259 } +.terminal-r13 { fill: #ffa62b;font-weight: bold } @@ -212,58 +208,58 @@ - + - nf-core pipelines create — Create a new pipeline with the nf-core pipeline templa… + nf-core pipelines create — Create a new pipeline with the nf-core pipeline temp… -Basic details +Basic details -GitHub organisationWorkflow name +GitHub organisationWorkflow name -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -nf-corePipeline Name -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +nf-corePipeline Name +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -A short description of your pipeline. +A short description of your pipeline. -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -Description -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Description +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -Name of the main author / authors +Name of the main author / authors -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -Author(s) -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Author(s) +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -Website URL for the organisation +Display name for the organisation -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -https://nf-co.re                                                                             -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +nf-core                                                                                    +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Back  Next  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + +Website URL for the organisation - - - +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▄▄ +https://nf-co.re                                                                           +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -^p palette +^p palette diff --git a/tests/pipelines/__snapshots__/test_create_app/test_basic_details_nfcore.svg b/tests/pipelines/__snapshots__/test_create_app/test_basic_details_nfcore.svg index 1db4258ca5..7ef0eb9fcf 100644 --- a/tests/pipelines/__snapshots__/test_create_app/test_basic_details_nfcore.svg +++ b/tests/pipelines/__snapshots__/test_create_app/test_basic_details_nfcore.svg @@ -216,7 +216,7 @@ - + nf-core pipelines create — Create a new pipeline with the nf-core pipeline templa… @@ -250,18 +250,18 @@ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - -Website URL for the organisation +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Back  Next  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -https://nf-co.re                                                                             -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Back  Next  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + diff --git a/tests/pipelines/__snapshots__/test_create_app/test_customisation_help.svg b/tests/pipelines/__snapshots__/test_create_app/test_customisation_help.svg index 90da8d5715..830a44d487 100644 --- a/tests/pipelines/__snapshots__/test_create_app/test_customisation_help.svg +++ b/tests/pipelines/__snapshots__/test_create_app/test_customisation_help.svg @@ -37,17 +37,17 @@ .terminal-r3 { fill: #a0a3a6 } .terminal-r4 { fill: #0178d4;font-weight: bold } .terminal-r5 { fill: #121212 } -.terminal-r6 { fill: #191919 } -.terminal-r7 { fill: #1e1e1e } +.terminal-r6 { fill: #0178d4 } +.terminal-r7 { fill: #272727 } .terminal-r8 { fill: #0178d4;text-decoration: underline; } -.terminal-r9 { fill: #6db2ff } -.terminal-r10 { fill: #808080 } -.terminal-r11 { fill: #ddedf9;font-weight: bold } -.terminal-r12 { fill: #004295 } -.terminal-r13 { fill: #000000 } -.terminal-r14 { fill: #0178d4 } -.terminal-r15 { fill: #2d2d2d } -.terminal-r16 { fill: #272727 } +.terminal-r9 { fill: #191919 } +.terminal-r10 { fill: #6db2ff } +.terminal-r11 { fill: #1e1e1e } +.terminal-r12 { fill: #808080 } +.terminal-r13 { fill: #ddedf9;font-weight: bold } +.terminal-r14 { fill: #004295 } +.terminal-r15 { fill: #000000 } +.terminal-r16 { fill: #2d2d2d } .terminal-r17 { fill: #e0e0e0;font-weight: bold } .terminal-r18 { fill: #0d0d0d } .terminal-r19 { fill: #f5bd6f } @@ -220,7 +220,7 @@ - + nf-core pipelines create — Create a new pipeline with the nf-core pipeline templa… @@ -234,15 +234,15 @@ Repository Setup -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -Use a GitHub repository.Create a GitHub Show help  -▁▁▁▁▁▁▁▁repository for the▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -pipeline. -▇▇ -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -Add Github badgesThe README.md file of Hide help  -▁▁▁▁▁▁▁▁the pipeline will▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -include GitHub badges +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Use a GitHub repository.Create a GitHub Show help  +▁▁▁▁▁▁▁▁repository for the▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +pipeline. +▇▇ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Add Github badgesThe README.md file of Hide help  +▁▁▁▁▁▁▁▁the pipeline will▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +include GitHub badges The pipeline README.md will include badges for: @@ -258,16 +258,16 @@ -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -Add a changelogAdd a CHANGELOG.md file. Show help  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -Add a license FileAdd the MIT license Show help  -▁▁▁▁▁▁▁▁file.▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Add a changelogAdd a CHANGELOG.md file. Show help  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +Add a license FileAdd the MIT license Show help  +▁▁▁▁▁▁▁▁file.▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔  Back  Continue  ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/pipelines/__snapshots__/test_create_app/test_type_nfcore_validation.svg b/tests/pipelines/__snapshots__/test_create_app/test_type_nfcore_validation.svg index 94a094e5a4..c8cad16ff5 100644 --- a/tests/pipelines/__snapshots__/test_create_app/test_type_nfcore_validation.svg +++ b/tests/pipelines/__snapshots__/test_create_app/test_type_nfcore_validation.svg @@ -214,7 +214,7 @@ - + nf-core pipelines create — Create a new pipeline with the nf-core pipeline templa… @@ -249,18 +249,18 @@ ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ Value error, Cannot be left empty. - - -Website URL for the organisation +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Back  Next  +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -https://nf-co.re                                                                             -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Back  Next  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + diff --git a/tests/pipelines/test_create_app.py b/tests/pipelines/test_create_app.py index 32e8b04d84..8bdb3ba100 100644 --- a/tests/pipelines/test_create_app.py +++ b/tests/pipelines/test_create_app.py @@ -2,7 +2,7 @@ from unittest import mock -from textual.widgets import Input +from textual.widgets import Button, Input from nf_core.pipelines.create import PipelineCreateApp from nf_core.pipelines.create import utils as create_utils @@ -11,6 +11,12 @@ INIT_FILE = "../../nf_core/pipelines/create/__init__.py" +async def press_screen_button(pilot, button_id: str) -> None: + """Press a button without relying on it being inside the visible viewport.""" + pilot.app.screen.query_one(button_id, Button).press() + await pilot.pause() + + async def test_app_bindings(): """Test that the app bindings work.""" app = PipelineCreateApp() @@ -132,7 +138,7 @@ async def run_before(pilot) -> None: await pilot.press("A", " ", "c", "o", "o", "l", " ", "d", "e", "s", "c", "r", "i", "p", "t", "i", "o", "n") await pilot.press("tab") await pilot.press("M", "e") - await pilot.click("#next") + await press_screen_button(pilot, "#next") assert snap_compare(INIT_FILE, terminal_size=(100, 50), run_before=run_before) @@ -175,7 +181,7 @@ async def run_before(pilot) -> None: await pilot.press("A", " ", "c", "o", "o", "l", " ", "d", "e", "s", "c", "r", "i", "p", "t", "i", "o", "n") await pilot.press("tab") await pilot.press("M", "e") - await pilot.click("#next") + await press_screen_button(pilot, "#next") assert snap_compare(INIT_FILE, terminal_size=(100, 50), run_before=run_before) @@ -224,9 +230,9 @@ async def run_before(pilot) -> None: await pilot.press("A", " ", "c", "o", "o", "l", " ", "d", "e", "s", "c", "r", "i", "p", "t", "i", "o", "n") await pilot.press("tab") await pilot.press("M", "e") - await pilot.click("#next") + await press_screen_button(pilot, "#next") await pilot.pause(delay=1) - await pilot.click("#show_help_github_badges") + await press_screen_button(pilot, "#show_help_github_badges") assert snap_compare(INIT_FILE, terminal_size=(100, 50), run_before=run_before) From a8b2a8facd3d8787c9d59765bc88e1ab59a1eb0a Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 11:24:34 +0000 Subject: [PATCH 08/22] Get the org name from the config file --- nf_core/pipelines/rocrate.py | 30 ++++++++++++++++++++++++------ tests/pipelines/test_rocrate.py | 20 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/nf_core/pipelines/rocrate.py b/nf_core/pipelines/rocrate.py index b4735c2127..f5bb50c283 100644 --- a/nf_core/pipelines/rocrate.py +++ b/nf_core/pipelines/rocrate.py @@ -17,7 +17,7 @@ from rocrate.model.person import Person from rocrate.rocrate import ROCrate as BaseROCrate -from nf_core.utils import Pipeline +from nf_core.utils import Pipeline, load_tools_config log = logging.getLogger(__name__) @@ -143,6 +143,23 @@ def create_rocrate(self, json_path: None | Path = None, zip_path: None | Path = return True + def _get_pipeline_org(self) -> str: + org_name = "nf-core" + try: + _, tools_config = load_tools_config(self.pipeline_dir) + except (AssertionError, FileNotFoundError, UserWarning) as error: + log.debug(f"Could not load `.nf-core.yml` for RO-Crate: {self.pipeline_dir}. {error}") + tools_config = None + if tools_config and getattr(tools_config, "template", None): + org_name = getattr(tools_config.template, "org", org_name) or org_name + return org_name + + def _get_pipeline_org_url(self) -> str: + org_name = self._get_pipeline_org() + if org_name == "nf-core": + return "https://nf-co.re/" + return f"https://github.com/{org_name}" + def make_workflow_rocrate(self) -> None: """ Create an RO Crate for a pipeline @@ -191,9 +208,9 @@ def make_workflow_rocrate(self) -> None: except FileNotFoundError: log.error(f"Could not find LICENSE file in {self.pipeline_dir}") - self.crate.add_jsonld( - {"@id": "https://nf-co.re/", "@type": "Organization", "name": "nf-core", "url": "https://nf-co.re/"} - ) + org_name = self._get_pipeline_org() + org_url = self._get_pipeline_org_url() + self.crate.add_jsonld({"@id": org_url, "@type": "Organization", "name": org_name, "url": org_url}) # Set metadata for main entity file self.set_main_entity("main.nf") @@ -213,10 +230,11 @@ def set_main_entity(self, main_entity_filename: str): self.crate.mainEntity.append_to( "dateModified", str(datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")), compact=True ) - self.crate.mainEntity.append_to("sdPublisher", {"@id": "https://nf-co.re/"}, compact=True) + self.crate.mainEntity.append_to("sdPublisher", {"@id": self._get_pipeline_org_url()}, compact=True) url = "dev" if self.version.endswith("dev") else self.version + crate_name = self.crate.name or "" self.crate.mainEntity.append_to( - "url", f"https://nf-co.re/{self.crate.name.replace('nf-core/', '')}/{url}/", compact=True + "url", f"https://nf-co.re/{crate_name.split('/', maxsplit=1)[-1]}/{url}/", compact=True ) self.crate.mainEntity.append_to("version", self.version, compact=True) diff --git a/tests/pipelines/test_rocrate.py b/tests/pipelines/test_rocrate.py index 8b04eb539b..1dd434cdfc 100644 --- a/tests/pipelines/test_rocrate.py +++ b/tests/pipelines/test_rocrate.py @@ -9,6 +9,7 @@ import git import rocrate.rocrate +import yaml from git import Repo import nf_core.pipelines.rocrate @@ -383,6 +384,25 @@ def test_parse_manifest_contributors_requires_names(self): with self.assertRaises(SystemExit): self.rocrate_obj.parse_manifest_contributors() + def test_rocrate_creation_uses_template_org(self): + """Use template.org from .nf-core.yml for the RO-Crate publisher metadata""" + config_path = Path(self.pipeline_dir, ".nf-core.yml") + with open(config_path) as fh: + config = yaml.safe_load(fh) + config["template"]["org"] = "my-org" + with open(config_path, "w") as fh: + yaml.safe_dump(config, fh, sort_keys=False) + + self.rocrate_obj = nf_core.pipelines.rocrate.ROCrate(self.pipeline_dir) + assert self.rocrate_obj.create_rocrate(self.pipeline_dir, self.pipeline_dir) + + with open(Path(self.pipeline_dir, "ro-crate-metadata.json")) as fh: + crate = json.load(fh) + entities = {entity["@id"]: entity for entity in crate["@graph"]} + + self.assertIn("https://github.com/my-org", entities) + self.assertEqual(entities["https://github.com/my-org"]["name"], "my-org") + def test_rocrate_creation_for_fetchngs(self): """Run the nf-core rocrate command with nf-core/fetchngs""" tmp_dir = Path(tempfile.mkdtemp()) From 069497cbe626c9237eb699ac509234f939fbd8f8 Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 11:26:50 +0100 Subject: [PATCH 09/22] Store the org URL in the nf-core config file --- nf_core/pipelines/rocrate.py | 17 +++++++++++++---- tests/pipelines/test_rocrate.py | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/nf_core/pipelines/rocrate.py b/nf_core/pipelines/rocrate.py index f5bb50c283..2c4125a7cf 100644 --- a/nf_core/pipelines/rocrate.py +++ b/nf_core/pipelines/rocrate.py @@ -17,7 +17,7 @@ from rocrate.model.person import Person from rocrate.rocrate import ROCrate as BaseROCrate -from nf_core.utils import Pipeline, load_tools_config +from nf_core.utils import Pipeline, get_org_url, load_tools_config log = logging.getLogger(__name__) @@ -156,9 +156,18 @@ def _get_pipeline_org(self) -> str: def _get_pipeline_org_url(self) -> str: org_name = self._get_pipeline_org() - if org_name == "nf-core": - return "https://nf-co.re/" - return f"https://github.com/{org_name}" + try: + _, tools_config = load_tools_config(self.pipeline_dir) + except (AssertionError, FileNotFoundError, UserWarning) as error: + log.debug(f"Could not load `.nf-core.yml` for RO-Crate org URL: {self.pipeline_dir}. {error}") + tools_config = None + + if tools_config and getattr(tools_config, "template", None): + template_org_url = getattr(tools_config.template, "org_url", None) + if template_org_url: + return template_org_url + + return get_org_url(org_name) def make_workflow_rocrate(self) -> None: """ diff --git a/tests/pipelines/test_rocrate.py b/tests/pipelines/test_rocrate.py index 1dd434cdfc..ec42d71363 100644 --- a/tests/pipelines/test_rocrate.py +++ b/tests/pipelines/test_rocrate.py @@ -390,6 +390,8 @@ def test_rocrate_creation_uses_template_org(self): with open(config_path) as fh: config = yaml.safe_load(fh) config["template"]["org"] = "my-org" + config["template"]["is_nfcore"] = False + config["template"].pop("org_url", None) with open(config_path, "w") as fh: yaml.safe_dump(config, fh, sort_keys=False) @@ -403,6 +405,27 @@ def test_rocrate_creation_uses_template_org(self): self.assertIn("https://github.com/my-org", entities) self.assertEqual(entities["https://github.com/my-org"]["name"], "my-org") + def test_rocrate_creation_uses_template_org_url(self): + """Use template.org_url from .nf-core.yml for the RO-Crate publisher metadata""" + config_path = Path(self.pipeline_dir, ".nf-core.yml") + with open(config_path) as fh: + config = yaml.safe_load(fh) + config["template"]["org"] = "my-org" + config["template"]["is_nfcore"] = False + config["template"]["org_url"] = "https://example.org/pipelines" + with open(config_path, "w") as fh: + yaml.safe_dump(config, fh, sort_keys=False) + + self.rocrate_obj = nf_core.pipelines.rocrate.ROCrate(self.pipeline_dir) + assert self.rocrate_obj.create_rocrate(self.pipeline_dir, self.pipeline_dir) + + with open(Path(self.pipeline_dir, "ro-crate-metadata.json")) as fh: + crate = json.load(fh) + entities = {entity["@id"]: entity for entity in crate["@graph"]} + + self.assertIn("https://example.org/pipelines", entities) + self.assertEqual(entities["https://example.org/pipelines"]["name"], "my-org") + def test_rocrate_creation_for_fetchngs(self): """Run the nf-core rocrate command with nf-core/fetchngs""" tmp_dir = Path(tempfile.mkdtemp()) From 4b0610cf7ee2283e693ad0ac26264c9416787810 Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 11:54:54 +0100 Subject: [PATCH 10/22] Also collect the org name --- nf_core/pipelines/rocrate.py | 27 +++++++++++++++++++++++---- tests/pipelines/test_rocrate.py | 13 +++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/nf_core/pipelines/rocrate.py b/nf_core/pipelines/rocrate.py index 2c4125a7cf..1452b7dddd 100644 --- a/nf_core/pipelines/rocrate.py +++ b/nf_core/pipelines/rocrate.py @@ -154,6 +154,23 @@ def _get_pipeline_org(self) -> str: org_name = getattr(tools_config.template, "org", org_name) or org_name return org_name + def _get_pipeline_org_name(self) -> str: + org_name = "nf-core" + try: + _, tools_config = load_tools_config(self.pipeline_dir) + except (AssertionError, FileNotFoundError, UserWarning) as error: + log.debug(f"Could not load `.nf-core.yml` for RO-Crate org name: {self.pipeline_dir}. {error}") + tools_config = None + + if tools_config and getattr(tools_config, "template", None): + template_org_name = getattr(tools_config.template, "org_name", None) + if template_org_name: + return template_org_name + + org_name = getattr(tools_config.template, "org", org_name) or org_name + + return org_name + def _get_pipeline_org_url(self) -> str: org_name = self._get_pipeline_org() try: @@ -217,7 +234,7 @@ def make_workflow_rocrate(self) -> None: except FileNotFoundError: log.error(f"Could not find LICENSE file in {self.pipeline_dir}") - org_name = self._get_pipeline_org() + org_name = self._get_pipeline_org_name() org_url = self._get_pipeline_org_url() self.crate.add_jsonld({"@id": org_url, "@type": "Organization", "name": org_name, "url": org_url}) @@ -250,13 +267,15 @@ def set_main_entity(self, main_entity_filename: str): # remove duplicate entries for version self.crate.mainEntity["version"] = list(set(self.crate.mainEntity["version"])) - # get keywords from nf-core website + # default topics + topics = ["nf-core", "nextflow"] + # get topics from nf-core website remote_workflows = requests.get("https://nf-co.re/pipelines.json").json()["remote_workflows"] # go through all remote workflows and find the one that matches the pipeline name - topics = ["nf-core", "nextflow"] + full_pipeline_name = f"{self._get_pipeline_org()}/{self.pipeline_obj.pipeline_name}" for remote_wf in remote_workflows: assert self.pipeline_obj.pipeline_name is not None # mypy - if remote_wf["name"] == self.pipeline_obj.pipeline_name.replace("nf-core/", ""): + if remote_wf["full_name"] == full_pipeline_name or remote_wf["name"] == self.pipeline_obj.pipeline_name: topics = topics + remote_wf["topics"] break diff --git a/tests/pipelines/test_rocrate.py b/tests/pipelines/test_rocrate.py index ec42d71363..16e08b0d6f 100644 --- a/tests/pipelines/test_rocrate.py +++ b/tests/pipelines/test_rocrate.py @@ -405,13 +405,14 @@ def test_rocrate_creation_uses_template_org(self): self.assertIn("https://github.com/my-org", entities) self.assertEqual(entities["https://github.com/my-org"]["name"], "my-org") - def test_rocrate_creation_uses_template_org_url(self): - """Use template.org_url from .nf-core.yml for the RO-Crate publisher metadata""" + def test_rocrate_creation_uses_template_org_params(self): + """Use template.org_name and template.org_url from .nf-core.yml for the RO-Crate publisher metadata""" config_path = Path(self.pipeline_dir, ".nf-core.yml") with open(config_path) as fh: config = yaml.safe_load(fh) - config["template"]["org"] = "my-org" + config["template"]["org"] = "nf-core" config["template"]["is_nfcore"] = False + config["template"]["org_name"] = "My Organisation" config["template"]["org_url"] = "https://example.org/pipelines" with open(config_path, "w") as fh: yaml.safe_dump(config, fh, sort_keys=False) @@ -422,9 +423,13 @@ def test_rocrate_creation_uses_template_org_url(self): with open(Path(self.pipeline_dir, "ro-crate-metadata.json")) as fh: crate = json.load(fh) entities = {entity["@id"]: entity for entity in crate["@graph"]} + main_entity = next(entity for entity in crate["@graph"] if entity.get("@id") in {"main.nf", "#main.nf"}) self.assertIn("https://example.org/pipelines", entities) - self.assertEqual(entities["https://example.org/pipelines"]["name"], "my-org") + self.assertEqual(entities["https://example.org/pipelines"]["name"], "My Organisation") + self.assertIn("https://example.org/pipelines/testpipeline/dev/", main_entity["url"]) + self.assertIn("nf-core", main_entity["keywords"]) + self.assertIn("custom", main_entity["keywords"]) def test_rocrate_creation_for_fetchngs(self): """Run the nf-core rocrate command with nf-core/fetchngs""" From 8ef8b72910ce1b5f89c5bf847bc170b9c4935a7d Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 12:22:34 +0100 Subject: [PATCH 11/22] Embed org_url in the rocrate and mock internet requests --- nf_core/pipelines/rocrate.py | 20 +++++++---- tests/pipelines/test_rocrate.py | 59 +++++++++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/nf_core/pipelines/rocrate.py b/nf_core/pipelines/rocrate.py index 1452b7dddd..1ec746d87b 100644 --- a/nf_core/pipelines/rocrate.py +++ b/nf_core/pipelines/rocrate.py @@ -186,6 +186,16 @@ def _get_pipeline_org_url(self) -> str: return get_org_url(org_name) + def _get_main_entity_url(self) -> str: + """Return a stable URL for the selected workflow revision.""" + return "/".join([ + self._get_pipeline_org_url(), + self.crate.name.replace(self._get_pipeline_org() + '/', ''), + "dev" if self.version.endswith("dev") else self.version, + "", # To have a trailing slash at the end of the URL + ]) + + def make_workflow_rocrate(self) -> None: """ Create an RO Crate for a pipeline @@ -257,11 +267,7 @@ def set_main_entity(self, main_entity_filename: str): "dateModified", str(datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")), compact=True ) self.crate.mainEntity.append_to("sdPublisher", {"@id": self._get_pipeline_org_url()}, compact=True) - url = "dev" if self.version.endswith("dev") else self.version - crate_name = self.crate.name or "" - self.crate.mainEntity.append_to( - "url", f"https://nf-co.re/{crate_name.split('/', maxsplit=1)[-1]}/{url}/", compact=True - ) + self.crate.mainEntity["url"] = [self._get_main_entity_url()] self.crate.mainEntity.append_to("version", self.version, compact=True) # remove duplicate entries for version @@ -269,8 +275,8 @@ def set_main_entity(self, main_entity_filename: str): # default topics topics = ["nf-core", "nextflow"] - # get topics from nf-core website - remote_workflows = requests.get("https://nf-co.re/pipelines.json").json()["remote_workflows"] + # get topics from org website + remote_workflows = requests.get(f"{self._get_pipeline_org_url()}/pipelines.json").json()["remote_workflows"] # go through all remote workflows and find the one that matches the pipeline name full_pipeline_name = f"{self._get_pipeline_org()}/{self.pipeline_obj.pipeline_name}" for remote_wf in remote_workflows: diff --git a/tests/pipelines/test_rocrate.py b/tests/pipelines/test_rocrate.py index 16e08b0d6f..208826acf4 100644 --- a/tests/pipelines/test_rocrate.py +++ b/tests/pipelines/test_rocrate.py @@ -6,6 +6,7 @@ import tempfile from pathlib import Path from unittest import mock +from unittest.mock import patch import git import rocrate.rocrate @@ -27,10 +28,59 @@ def __init__(self, payload, status_code=200, url=""): def json(self): return self.payload + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(f"HTTP {self.status_code} for {self.url}") + class TestROCrate(TestPipelines): """Class for lint tests""" + @staticmethod + def _mock_pipelines_response(url: str, *args, **kwargs): + if url == "https://nf-co.re/pipelines.json": + return MockResponse( + { + "remote_workflows": [ + { + "full_name": "nf-core/testpipeline", + "name": "testpipeline", + "topics": ["test", "pipeline"], + } + ] + }, + url=url, + ) + if url == "https://github.com/my-org/pipelines.json": + return MockResponse( + { + "remote_workflows": [ + { + "full_name": "my-org/testpipeline", + "name": "testpipeline", + "topics": ["custom", "org"], + } + ] + }, + url=url, + ) + if url == "https://example.org/pipelines/pipelines.json": + return MockResponse( + { + "remote_workflows": [ + { + "full_name": "my-org/testpipeline", + "name": "testpipeline", + "topics": ["custom", "org"], + } + ] + }, + url=url, + ) + if url.startswith("https://pub.orcid.org/v3.0/search/"): + return MockResponse({"num-found": 0, "result": []}, url=url) + raise AssertionError(f"Unexpected URL requested: {url}") + def setUp(self) -> None: super().setUp() # add fake metro map @@ -130,7 +180,8 @@ def test_rocrate_creation(self): """Run the nf-core rocrate command""" # Run the command - assert self.rocrate_obj.create_rocrate(self.pipeline_dir, self.pipeline_dir) + with patch("nf_core.pipelines.rocrate.requests.get", side_effect=self._mock_pipelines_response): + assert self.rocrate_obj.create_rocrate(self.pipeline_dir, self.pipeline_dir) # Check that the crate was created self.assertTrue(Path(self.pipeline_dir, "ro-crate-metadata.json").exists()) @@ -396,7 +447,8 @@ def test_rocrate_creation_uses_template_org(self): yaml.safe_dump(config, fh, sort_keys=False) self.rocrate_obj = nf_core.pipelines.rocrate.ROCrate(self.pipeline_dir) - assert self.rocrate_obj.create_rocrate(self.pipeline_dir, self.pipeline_dir) + with patch("nf_core.pipelines.rocrate.requests.get", side_effect=self._mock_pipelines_response): + assert self.rocrate_obj.create_rocrate(self.pipeline_dir, self.pipeline_dir) with open(Path(self.pipeline_dir, "ro-crate-metadata.json")) as fh: crate = json.load(fh) @@ -418,7 +470,8 @@ def test_rocrate_creation_uses_template_org_params(self): yaml.safe_dump(config, fh, sort_keys=False) self.rocrate_obj = nf_core.pipelines.rocrate.ROCrate(self.pipeline_dir) - assert self.rocrate_obj.create_rocrate(self.pipeline_dir, self.pipeline_dir) + with patch("nf_core.pipelines.rocrate.requests.get", side_effect=self._mock_pipelines_response): + assert self.rocrate_obj.create_rocrate(self.pipeline_dir, self.pipeline_dir) with open(Path(self.pipeline_dir, "ro-crate-metadata.json")) as fh: crate = json.load(fh) From 74ca2f7e61b91e7c14b9c68dc3da07a4ed78c3d1 Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 12:20:39 +0100 Subject: [PATCH 12/22] Don't use real author names in tests This allows testing the creator entity --- tests/pipelines/test_rocrate.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/pipelines/test_rocrate.py b/tests/pipelines/test_rocrate.py index 208826acf4..405de3c937 100644 --- a/tests/pipelines/test_rocrate.py +++ b/tests/pipelines/test_rocrate.py @@ -77,6 +77,8 @@ def _mock_pipelines_response(url: str, *args, **kwargs): }, url=url, ) + if url.startswith("https://api.github.com/users/"): + return MockResponse({"name": "Test McTestFace"}, url=url) if url.startswith("https://pub.orcid.org/v3.0/search/"): return MockResponse({"num-found": 0, "result": []}, url=url) raise AssertionError(f"Unexpected URL requested: {url}") @@ -85,10 +87,16 @@ def setUp(self) -> None: super().setUp() # add fake metro map Path(self.pipeline_dir, "docs", "images", "nf-core-testpipeline_metro_map.png").touch() - # commit the changes - repo = Repo(self.pipeline_dir) + # rebuild the git history with a deterministic test author + shutil.rmtree(self.pipeline_dir / ".git") + repo = Repo.init(self.pipeline_dir) + with repo.config_writer() as config_writer: + config_writer.set_value("user", "name", "Test McTestFace") + config_writer.set_value("user", "email", "test@example.com") + + author = git.Actor("Test McTestFace", "test@example.com") repo.git.add(A=True) - repo.index.commit("Initial commit") + repo.index.commit("Initial commit", author=author, committer=author) self.rocrate_obj = nf_core.pipelines.rocrate.ROCrate(self.pipeline_dir) def tearDown(self): @@ -207,9 +215,9 @@ def test_rocrate_creation(self): # assert that author is set as a person elif "name" in entity_json and entity_json["name"] == "Test McTestFace": self.assertEqual(entity_json["@type"], "Person") - # check that it is set as author of the main entity + # check that it is set as creator of the main entity if crate.mainEntity is not None: - self.assertEqual(crate.mainEntity["author"][0].id, entity_json["@id"]) + self.assertEqual(crate.mainEntity["creator"][0].id, entity_json["@id"]) def test_rocrate_creation_wrong_pipeline_dir(self): """Run the nf-core rocrate command with a wrong pipeline directory""" From 5a1eb77fc8bed84a5b8f832147352c65e4ab85ab Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 12:22:47 +0100 Subject: [PATCH 13/22] More optimal rocrate config loading --- nf_core/pipelines/rocrate.py | 35 +++++++++++++---------------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/nf_core/pipelines/rocrate.py b/nf_core/pipelines/rocrate.py index 1ec746d87b..fb1e23e099 100644 --- a/nf_core/pipelines/rocrate.py +++ b/nf_core/pipelines/rocrate.py @@ -88,6 +88,12 @@ def __init__(self, pipeline_dir: Path, version="") -> None: self.crate: rocrate.rocrate.ROCrate self.pipeline_obj = Pipeline(self.pipeline_dir) self.pipeline_obj._load() + self.tools_config = None + + try: + _, self.tools_config = load_tools_config(self.pipeline_dir) + except (AssertionError, FileNotFoundError, UserWarning) as error: + log.debug(f"Could not load `.nf-core.yml` for RO-Crate: {self.pipeline_dir}. {error}") setup_requests_cachedir() @@ -145,42 +151,27 @@ def create_rocrate(self, json_path: None | Path = None, zip_path: None | Path = def _get_pipeline_org(self) -> str: org_name = "nf-core" - try: - _, tools_config = load_tools_config(self.pipeline_dir) - except (AssertionError, FileNotFoundError, UserWarning) as error: - log.debug(f"Could not load `.nf-core.yml` for RO-Crate: {self.pipeline_dir}. {error}") - tools_config = None - if tools_config and getattr(tools_config, "template", None): - org_name = getattr(tools_config.template, "org", org_name) or org_name + if self.tools_config and getattr(self.tools_config, "template", None): + org_name = getattr(self.tools_config.template, "org", org_name) or org_name return org_name def _get_pipeline_org_name(self) -> str: org_name = "nf-core" - try: - _, tools_config = load_tools_config(self.pipeline_dir) - except (AssertionError, FileNotFoundError, UserWarning) as error: - log.debug(f"Could not load `.nf-core.yml` for RO-Crate org name: {self.pipeline_dir}. {error}") - tools_config = None - if tools_config and getattr(tools_config, "template", None): - template_org_name = getattr(tools_config.template, "org_name", None) + if self.tools_config and getattr(self.tools_config, "template", None): + template_org_name = getattr(self.tools_config.template, "org_name", None) if template_org_name: return template_org_name - org_name = getattr(tools_config.template, "org", org_name) or org_name + org_name = getattr(self.tools_config.template, "org", org_name) or org_name return org_name def _get_pipeline_org_url(self) -> str: org_name = self._get_pipeline_org() - try: - _, tools_config = load_tools_config(self.pipeline_dir) - except (AssertionError, FileNotFoundError, UserWarning) as error: - log.debug(f"Could not load `.nf-core.yml` for RO-Crate org URL: {self.pipeline_dir}. {error}") - tools_config = None - if tools_config and getattr(tools_config, "template", None): - template_org_url = getattr(tools_config.template, "org_url", None) + if self.tools_config and getattr(self.tools_config, "template", None): + template_org_url = getattr(self.tools_config.template, "org_url", None) if template_org_url: return template_org_url From 579d71ce7fe9e639abf03209c850540d8d890c20 Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 29 Mar 2026 13:19:03 +0100 Subject: [PATCH 14/22] Don't fail if the org doesn't provide a JSON --- nf_core/pipelines/rocrate.py | 43 ++++++++++++++++++++++-------- tests/pipelines/test_rocrate.py | 47 +++++++++++++++++++++++++++++++++ tests/pipelines/test_sync.py | 5 ++++ 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/nf_core/pipelines/rocrate.py b/nf_core/pipelines/rocrate.py index fb1e23e099..b2f681d4a6 100644 --- a/nf_core/pipelines/rocrate.py +++ b/nf_core/pipelines/rocrate.py @@ -21,6 +21,9 @@ log = logging.getLogger(__name__) +DEFAULT_TOPICS = ["nf-core", "nextflow"] +TOPICS_REQUEST_TIMEOUT_SECONDS = 10 + # To identify bots, we look for names that contain "[bot]" or end with "-bot" or "_bot", case-insensitive BOT_PATTERNS = re.compile(r"\[bot\]|(-bot|_bot)$", re.IGNORECASE) @@ -186,6 +189,34 @@ def _get_main_entity_url(self) -> str: "", # To have a trailing slash at the end of the URL ]) + def _get_remote_workflow_topics(self) -> list[str]: + """Fetch workflow topics from the organisation pipelines index when available.""" + assert self.pipeline_obj.pipeline_name is not None # mypy + topics = DEFAULT_TOPICS.copy() + + try: + response = requests.get( + f"{self._get_pipeline_org_url()}/pipelines.json", + timeout=TOPICS_REQUEST_TIMEOUT_SECONDS, + ) + response.raise_for_status() + except requests.exceptions.RequestException as error: + log.debug("Could not fetch workflow topics for RO-Crate from %s: %s", self._get_pipeline_org_url(), error) + return topics + + full_pipeline_name = f"{self._get_pipeline_org()}/{self.pipeline_obj.pipeline_name}" + try: + payload = response.json() + remote_workflows = payload["remote_workflows"] + for remote_wf in remote_workflows: + if remote_wf["full_name"] == full_pipeline_name or remote_wf["name"] == self.pipeline_obj.pipeline_name: + topics.extend(remote_wf["topics"]) + break + + except (ValueError, KeyError, TypeError) as error: + log.error("Could not parse pipelines index from %s: %s", response.url, error) + + return topics def make_workflow_rocrate(self) -> None: """ @@ -264,17 +295,7 @@ def set_main_entity(self, main_entity_filename: str): # remove duplicate entries for version self.crate.mainEntity["version"] = list(set(self.crate.mainEntity["version"])) - # default topics - topics = ["nf-core", "nextflow"] - # get topics from org website - remote_workflows = requests.get(f"{self._get_pipeline_org_url()}/pipelines.json").json()["remote_workflows"] - # go through all remote workflows and find the one that matches the pipeline name - full_pipeline_name = f"{self._get_pipeline_org()}/{self.pipeline_obj.pipeline_name}" - for remote_wf in remote_workflows: - assert self.pipeline_obj.pipeline_name is not None # mypy - if remote_wf["full_name"] == full_pipeline_name or remote_wf["name"] == self.pipeline_obj.pipeline_name: - topics = topics + remote_wf["topics"] - break + topics = self._get_remote_workflow_topics() log.debug(f"Adding topics: {topics}") self.crate.mainEntity.append_to("keywords", topics) diff --git a/tests/pipelines/test_rocrate.py b/tests/pipelines/test_rocrate.py index 405de3c937..79362922f5 100644 --- a/tests/pipelines/test_rocrate.py +++ b/tests/pipelines/test_rocrate.py @@ -9,6 +9,7 @@ from unittest.mock import patch import git +import requests import rocrate.rocrate import yaml from git import Repo @@ -492,6 +493,52 @@ def test_rocrate_creation_uses_template_org_params(self): self.assertIn("nf-core", main_entity["keywords"]) self.assertIn("custom", main_entity["keywords"]) + def test_rocrate_creation_falls_back_to_default_topics_on_request_error(self): + """Keep RO-Crate generation working when the pipelines index cannot be fetched.""" + + def mock_requests_get(url: str, *args, **kwargs): + if url.endswith("/pipelines.json"): + raise requests.exceptions.ConnectionError("offline") + return self._mock_pipelines_response(url, *args, **kwargs) + + with patch( + "nf_core.pipelines.rocrate.requests.get", + side_effect=mock_requests_get, + ): + assert self.rocrate_obj.create_rocrate(self.pipeline_dir, self.pipeline_dir) + + with open(Path(self.pipeline_dir, "ro-crate-metadata.json")) as fh: + crate = json.load(fh) + main_entity = next(entity for entity in crate["@graph"] if entity.get("@id") in {"main.nf", "#main.nf"}) + + self.assertEqual(main_entity["keywords"], ["nf-core", "nextflow"]) + + def test_rocrate_creation_falls_back_to_default_topics_on_invalid_json(self): + """Keep RO-Crate generation working when the pipelines index response cannot be parsed.""" + + class InvalidJsonResponse: + url = "https://nf-co.re/pipelines.json" + + def raise_for_status(self): + return None + + def json(self): + raise ValueError("invalid json") + + def mock_requests_get(url: str, *args, **kwargs): + if url.endswith("/pipelines.json"): + return InvalidJsonResponse() + return self._mock_pipelines_response(url, *args, **kwargs) + + with patch("nf_core.pipelines.rocrate.requests.get", side_effect=mock_requests_get): + assert self.rocrate_obj.create_rocrate(self.pipeline_dir, self.pipeline_dir) + + with open(Path(self.pipeline_dir, "ro-crate-metadata.json")) as fh: + crate = json.load(fh) + main_entity = next(entity for entity in crate["@graph"] if entity.get("@id") in {"main.nf", "#main.nf"}) + + self.assertEqual(main_entity["keywords"], ["nf-core", "nextflow"]) + def test_rocrate_creation_for_fetchngs(self): """Run the nf-core rocrate command with nf-core/fetchngs""" tmp_dir = Path(tempfile.mkdtemp()) diff --git a/tests/pipelines/test_sync.py b/tests/pipelines/test_sync.py index 13cb8a3c0f..96456e06ff 100644 --- a/tests/pipelines/test_sync.py +++ b/tests/pipelines/test_sync.py @@ -7,6 +7,7 @@ import git import pytest +import requests import yaml import nf_core.pipelines.sync @@ -30,6 +31,10 @@ def __init__(self, data: dict | list[dict], status_code: int, url: str): def json(self): return self.data + def raise_for_status(self): + if self.status_code >= 400: + raise requests.RequestException(f"Error {self.status_code}: {self.reason}") + def mocked_requests_get(url, params=None, **kwargs) -> MockResponse: """Helper function to emulate GET request responses from the web""" From e27245f2b1d8c376748ea602a569b5437bb4b8ba Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Wed, 1 Apr 2026 01:09:26 +0200 Subject: [PATCH 15/22] Renamed org_name to org_full_name to avoid confusion with the existing org name --- nf_core/pipelines/create/basicdetails.py | 21 +++++++++++---------- nf_core/pipelines/create/create.py | 4 ++-- nf_core/pipelines/create/utils.py | 2 +- nf_core/pipelines/rocrate.py | 18 +++++++++--------- nf_core/utils.py | 8 ++++---- tests/pipelines/test_create.py | 8 ++++---- tests/pipelines/test_create_app.py | 10 +++++----- tests/pipelines/test_rocrate.py | 4 ++-- tests/pipelines/test_sync.py | 2 +- 9 files changed, 39 insertions(+), 38 deletions(-) diff --git a/nf_core/pipelines/create/basicdetails.py b/nf_core/pipelines/create/basicdetails.py index 1f11ca8bfb..85cce20444 100644 --- a/nf_core/pipelines/create/basicdetails.py +++ b/nf_core/pipelines/create/basicdetails.py @@ -23,7 +23,7 @@ class BasicDetails(Screen): """Name, description, author, etc.""" - _auto_org_name: str | None = None + _auto_org_full_name: str | None = None _auto_org_url: str | None = None def compose(self) -> ComposeResult: @@ -64,7 +64,7 @@ def compose(self) -> ComposeResult: ) if not self.parent.NFCORE_PIPELINE: yield TextInput( - "org_name", + "org_full_name", "Organisation Name", "Display name for the organisation", "nf-core", @@ -88,18 +88,18 @@ def _sync_org_metadata_inputs(self, force: bool = False) -> None: return org_input = self._get_input_widget("org") - org_name_input = self._get_input_widget("org_name") + org_full_name_input = self._get_input_widget("org_full_name") org_url_input = self._get_input_widget("org_url") suggested_name = org_input.value or "nf-core" suggested_url = get_org_url(org_input.value or "nf-core", self.parent.NFCORE_PIPELINE) - if force or org_name_input.value in {"", self._auto_org_name}: - org_name_input.value = suggested_name + if force or org_full_name_input.value in {"", self._auto_org_full_name}: + org_full_name_input.value = suggested_name if force or org_url_input.value in {"", self._auto_org_url}: org_url_input.value = suggested_url - self._auto_org_name = suggested_name + self._auto_org_full_name = suggested_name self._auto_org_url = suggested_url def _get_input_widget(self, field_id: str) -> Input: @@ -114,7 +114,8 @@ def _get_config_values(self) -> dict[str, str]: config[text_input.field_id] = this_input.value if self.parent.NFCORE_PIPELINE: - config["org_name"] = config["org"] + # Add nf-core defaults for hidden fields, to avoid errors when creating a pipeline + config["org_full_name"] = config["org"] config["org_url"] = get_org_url("nf-core", True) return config @@ -139,13 +140,13 @@ def restore_empty_org_metadata_on_blur(self, event: Input.Blurred) -> None: return org_input = self._get_input_widget("org") - for field_id in ("org_name", "org_url"): + for field_id in ("org_full_name", "org_url"): if event.input is self._get_input_widget(field_id): if event.input.value: break - if field_id == "org_name": + if field_id == "org_full_name": restored_value = org_input.value or "nf-core" - self._auto_org_name = restored_value + self._auto_org_full_name = restored_value else: restored_value = get_org_url(org_input.value or "nf-core", self.parent.NFCORE_PIPELINE) self._auto_org_url = restored_value diff --git a/nf_core/pipelines/create/create.py b/nf_core/pipelines/create/create.py index 094d6c1d65..c7bf2faed3 100644 --- a/nf_core/pipelines/create/create.py +++ b/nf_core/pipelines/create/create.py @@ -194,8 +194,8 @@ def update_config(self, organisation, version, force, outdir): self.config.outdir = outdir if outdir else "." if self.config.is_nfcore is None or self.config.is_nfcore == "null": self.config.is_nfcore = self.config.org == "nf-core" - if self.config.org_name is None and self.config.org is not None: - self.config.org_name = self.config.org + if self.config.org_full_name is None and self.config.org is not None: + self.config.org_full_name = self.config.org if self.config.org_url is None and self.config.org is not None: self.config.org_url = nf_core.utils.get_org_url(self.config.org, self.config.is_nfcore) diff --git a/nf_core/pipelines/create/utils.py b/nf_core/pipelines/create/utils.py index cd223f8af2..fea8e8710c 100644 --- a/nf_core/pipelines/create/utils.py +++ b/nf_core/pipelines/create/utils.py @@ -66,7 +66,7 @@ def name_nospecialchars(cls, v: str, info: ValidationInfo) -> str: raise ValueError("Must not contain special characters. Only '-' or '_' are allowed.") return v - @field_validator("org", "org_name", "org_url", "description", "author", "version", "outdir") + @field_validator("org", "org_full_name", "org_url", "description", "author", "version", "outdir") @classmethod def notempty(cls, v: str) -> str: """Check that string values are not empty.""" diff --git a/nf_core/pipelines/rocrate.py b/nf_core/pipelines/rocrate.py index b2f681d4a6..8bc01d988d 100644 --- a/nf_core/pipelines/rocrate.py +++ b/nf_core/pipelines/rocrate.py @@ -158,17 +158,17 @@ def _get_pipeline_org(self) -> str: org_name = getattr(self.tools_config.template, "org", org_name) or org_name return org_name - def _get_pipeline_org_name(self) -> str: - org_name = "nf-core" + def _get_pipeline_org_full_name(self) -> str: + org_full_name = "nf-core" if self.tools_config and getattr(self.tools_config, "template", None): - template_org_name = getattr(self.tools_config.template, "org_name", None) - if template_org_name: - return template_org_name + template_org_full_name = getattr(self.tools_config.template, "org_full_name", None) + if template_org_full_name: + return template_org_full_name - org_name = getattr(self.tools_config.template, "org", org_name) or org_name + org_full_name = getattr(self.tools_config.template, "org", org_full_name) or org_full_name - return org_name + return org_full_name def _get_pipeline_org_url(self) -> str: org_name = self._get_pipeline_org() @@ -266,9 +266,9 @@ def make_workflow_rocrate(self) -> None: except FileNotFoundError: log.error(f"Could not find LICENSE file in {self.pipeline_dir}") - org_name = self._get_pipeline_org_name() + org_full_name = self._get_pipeline_org_full_name() org_url = self._get_pipeline_org_url() - self.crate.add_jsonld({"@id": org_url, "@type": "Organization", "name": org_name, "url": org_url}) + self.crate.add_jsonld({"@id": org_url, "@type": "Organization", "name": org_full_name, "url": org_url}) # Set metadata for main entity file self.set_main_entity("main.nf") diff --git a/nf_core/utils.py b/nf_core/utils.py index d237fdaa8d..14df66b41b 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -1277,7 +1277,7 @@ class NFCoreTemplateConfig(BaseModel): org: str | None = None """ Organisation name """ - org_name: str | None = None + org_full_name: str | None = None """ Full organisation name """ org_url: str | None = None """ Organisation URL """ @@ -1470,7 +1470,7 @@ def model_dump(self, **kwargs) -> dict[str, Any]: template = config.get("template") if isinstance(template, dict) and template.get("is_nfcore"): - template.pop("org_name", None) + template.pop("org_full_name", None) template.pop("org_url", None) return config @@ -1483,8 +1483,8 @@ def _populate_template_org_metadata(template: NFCoreTemplateConfig | None) -> No if template.is_nfcore is None: template.is_nfcore = template.org == "nf-core" - if template.org_name is None: - template.org_name = template.org + if template.org_full_name is None: + template.org_full_name = template.org if template.org_url is None: template.org_url = get_org_url(template.org, template.is_nfcore) diff --git a/tests/pipelines/test_create.py b/tests/pipelines/test_create.py index 96b01f2e95..56fc1bbe4f 100644 --- a/tests/pipelines/test_create.py +++ b/tests/pipelines/test_create.py @@ -43,7 +43,7 @@ def test_pipeline_creation(self, tmp_path): assert pipeline.config.description == self.pipeline_description assert pipeline.config.author == self.pipeline_author assert pipeline.config.version == self.pipeline_version - assert pipeline.config.org_name == "nf-core" + assert pipeline.config.org_full_name == "nf-core" assert pipeline.config.org_url == "https://nf-co.re" @with_temporary_folder @@ -65,7 +65,7 @@ def test_pipeline_creation_initiation(self, tmp_path): with open(Path(pipeline.outdir, ".nf-core.yml")) as fh: nfcore_yml = yaml.safe_load(fh) assert "template" in nfcore_yml - assert "org_name" not in nfcore_yml["template"] + assert "org_full_name" not in nfcore_yml["template"] assert "org_url" not in nfcore_yml["template"] @with_temporary_folder @@ -87,7 +87,7 @@ def test_pipeline_creation_initiation_with_yml(self, tmp_path): nfcore_yml = yaml.safe_load(fh) assert "template" in nfcore_yml assert yaml.safe_load(PIPELINE_TEMPLATE_YML.read_text()).items() <= nfcore_yml["template"].items() - assert nfcore_yml["template"]["org_name"] == "testprefix" + assert nfcore_yml["template"]["org_full_name"] == "testprefix" assert nfcore_yml["template"]["org_url"] == "https://github.com/testprefix" @with_temporary_folder @@ -106,7 +106,7 @@ def test_pipeline_creation_initiation_customize_template(self, tmp_path): nfcore_yml = yaml.safe_load(fh) assert "template" in nfcore_yml assert yaml.safe_load(PIPELINE_TEMPLATE_YML.read_text()).items() <= nfcore_yml["template"].items() - assert nfcore_yml["template"]["org_name"] == "testprefix" + assert nfcore_yml["template"]["org_full_name"] == "testprefix" assert nfcore_yml["template"]["org_url"] == "https://github.com/testprefix" @with_temporary_folder diff --git a/tests/pipelines/test_create_app.py b/tests/pipelines/test_create_app.py index 8bdb3ba100..5e3f091ed4 100644 --- a/tests/pipelines/test_create_app.py +++ b/tests/pipelines/test_create_app.py @@ -45,24 +45,24 @@ async def test_custom_org_metadata_stays_empty_until_blur(): screen = pilot.app.screen org_input = screen.query_one("#org", TextInput).query_one(Input) - org_name_input = screen.query_one("#org_name", TextInput).query_one(Input) + org_full_name_input = screen.query_one("#org_full_name", TextInput).query_one(Input) org_url_input = screen.query_one("#org_url", TextInput).query_one(Input) org_input.focus() await pilot.pause() await pilot.press("t", "e", "s", "t", "p", "r", "e", "f", "i", "x") assert org_input.value == "testprefix" - assert org_name_input.value == "testprefix" + assert org_full_name_input.value == "testprefix" assert org_url_input.value == "https://github.com/testprefix" - org_name_input.focus() + org_full_name_input.focus() await pilot.pause() await pilot.press("backspace") - assert org_name_input.value == "" + assert org_full_name_input.value == "" org_url_input.focus() await pilot.pause() - assert org_name_input.value == "testprefix" + assert org_full_name_input.value == "testprefix" await pilot.press("backspace") assert org_url_input.value == "" diff --git a/tests/pipelines/test_rocrate.py b/tests/pipelines/test_rocrate.py index 79362922f5..cec5c7f1a3 100644 --- a/tests/pipelines/test_rocrate.py +++ b/tests/pipelines/test_rocrate.py @@ -467,13 +467,13 @@ def test_rocrate_creation_uses_template_org(self): self.assertEqual(entities["https://github.com/my-org"]["name"], "my-org") def test_rocrate_creation_uses_template_org_params(self): - """Use template.org_name and template.org_url from .nf-core.yml for the RO-Crate publisher metadata""" + """Use template.org_full_name and template.org_url from .nf-core.yml for the RO-Crate publisher metadata""" config_path = Path(self.pipeline_dir, ".nf-core.yml") with open(config_path) as fh: config = yaml.safe_load(fh) config["template"]["org"] = "nf-core" config["template"]["is_nfcore"] = False - config["template"]["org_name"] = "My Organisation" + config["template"]["org_full_name"] = "My Organisation" config["template"]["org_url"] = "https://example.org/pipelines" with open(config_path, "w") as fh: yaml.safe_dump(config, fh, sort_keys=False) diff --git a/tests/pipelines/test_sync.py b/tests/pipelines/test_sync.py index 96456e06ff..b16bccae5f 100644 --- a/tests/pipelines/test_sync.py +++ b/tests/pipelines/test_sync.py @@ -319,7 +319,7 @@ def test_create_template_pipeline_backfills_missing_org_url(self): with open(nf_core_yml_path) as fh: nf_core_yml = yaml.safe_load(fh) - nf_core_yml["template"].pop("org_name", None) + nf_core_yml["template"].pop("org_full_name", None) nf_core_yml["template"].pop("org_url", None) with open(nf_core_yml_path, "w") as fh: From 993f00a626b809d3880192054bc2bae038107f1e Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Wed, 1 Apr 2026 01:26:25 +0200 Subject: [PATCH 16/22] Combined the function to generate docs URLs --- nf_core/utils.py | 12 +----------- tests/test_utils.py | 25 +++++++++++++------------ 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/nf_core/utils.py b/nf_core/utils.py index 14df66b41b..680a37edb3 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -1243,7 +1243,7 @@ def get_org_url(org_name: str, is_nfcore: bool | None = None) -> str: return f"https://github.com/{org_name}" -def _get_docs_url(org_url: str, repo_name: str, short_name: str, branch: str, doc_name: str) -> str: +def get_docs_url(org_url: str, repo_name: str, short_name: str, branch: str, doc_name: str) -> str: """Return a forge-aware URL for a rendered documentation page.""" normalized_org_url = org_url.rstrip("/") parsed_url = urlparse(normalized_org_url) @@ -1262,16 +1262,6 @@ def _get_docs_url(org_url: str, repo_name: str, short_name: str, branch: str, do return f"{normalized_org_url}/{short_name}/{doc_name}" -def get_usage_docs_url(org_url: str, repo_name: str, short_name: str, branch: str) -> str: - """Return a forge-aware URL for the rendered usage documentation.""" - return _get_docs_url(org_url, repo_name, short_name, branch, "usage") - - -def get_output_docs_url(org_url: str, repo_name: str, short_name: str, branch: str) -> str: - """Return a forge-aware URL for the rendered output documentation.""" - return _get_docs_url(org_url, repo_name, short_name, branch, "output") - - class NFCoreTemplateConfig(BaseModel): """Template configuration schema""" diff --git a/tests/test_utils.py b/tests/test_utils.py index 46fd576605..a8fa93d412 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -57,13 +57,14 @@ def test_strip_ansi_codes(): @pytest.mark.parametrize( - ("org_url", "repo_name", "short_name", "default_branch", "expected"), + ("org_url", "repo_name", "short_name", "default_branch", "doc_name", "expected"), [ ( "https://github.com/my-org", "my-org/testpipeline", "testpipeline", "main", + "usage", "https://github.com/my-org/testpipeline/blob/main/docs/usage.md", ), ( @@ -71,6 +72,7 @@ def test_strip_ansi_codes(): "my-org/testpipeline", "testpipeline", "main", + "usage", "https://gitlab.com/my-org/testpipeline/-/blob/main/docs/usage.md", ), ( @@ -78,6 +80,7 @@ def test_strip_ansi_codes(): "my-org/testpipeline", "testpipeline", "main", + "usage", "https://bitbucket.org/my-org/testpipeline/src/main/docs/usage.md", ), ( @@ -85,6 +88,7 @@ def test_strip_ansi_codes(): "my-org/testpipeline", "testpipeline", "main", + "usage", "https://codeberg.org/my-org/testpipeline/src/branch/main/docs/usage.md", ), ( @@ -92,22 +96,15 @@ def test_strip_ansi_codes(): "my-org/testpipeline", "testpipeline", "main", + "usage", "https://example.org/pipelines/testpipeline/usage", ), - ], -) -def test_get_usage_docs_url(org_url, repo_name, short_name, default_branch, expected): - assert nf_core.utils.get_usage_docs_url(org_url, repo_name, short_name, default_branch) == expected - - -@pytest.mark.parametrize( - ("org_url", "repo_name", "short_name", "default_branch", "expected"), - [ ( "https://github.com/my-org", "my-org/testpipeline", "testpipeline", "main", + "output", "https://github.com/my-org/testpipeline/blob/main/docs/output.md", ), ( @@ -115,6 +112,7 @@ def test_get_usage_docs_url(org_url, repo_name, short_name, default_branch, expe "my-org/testpipeline", "testpipeline", "main", + "output", "https://gitlab.com/my-org/testpipeline/-/blob/main/docs/output.md", ), ( @@ -122,6 +120,7 @@ def test_get_usage_docs_url(org_url, repo_name, short_name, default_branch, expe "my-org/testpipeline", "testpipeline", "main", + "output", "https://bitbucket.org/my-org/testpipeline/src/main/docs/output.md", ), ( @@ -129,6 +128,7 @@ def test_get_usage_docs_url(org_url, repo_name, short_name, default_branch, expe "my-org/testpipeline", "testpipeline", "main", + "output", "https://codeberg.org/my-org/testpipeline/src/branch/main/docs/output.md", ), ( @@ -136,12 +136,13 @@ def test_get_usage_docs_url(org_url, repo_name, short_name, default_branch, expe "my-org/testpipeline", "testpipeline", "main", + "output", "https://example.org/pipelines/testpipeline/output", ), ], ) -def test_get_output_docs_url(org_url, repo_name, short_name, default_branch, expected): - assert nf_core.utils.get_output_docs_url(org_url, repo_name, short_name, default_branch) == expected +def test_get_docs_url(org_url, repo_name, short_name, default_branch, doc_name, expected): + assert nf_core.utils.get_docs_url(org_url, repo_name, short_name, default_branch, doc_name) == expected class TestUtils(TestPipelines): From 3c4d062d16fa4c1b51950ac6e7761ae41f5f72cf Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Thu, 2 Apr 2026 02:41:13 +0200 Subject: [PATCH 17/22] Factored out some code to access template parameters --- nf_core/pipelines/rocrate.py | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/nf_core/pipelines/rocrate.py b/nf_core/pipelines/rocrate.py index 8bc01d988d..f694111efa 100644 --- a/nf_core/pipelines/rocrate.py +++ b/nf_core/pipelines/rocrate.py @@ -152,33 +152,21 @@ def create_rocrate(self, json_path: None | Path = None, zip_path: None | Path = return True - def _get_pipeline_org(self) -> str: - org_name = "nf-core" - if self.tools_config and getattr(self.tools_config, "template", None): - org_name = getattr(self.tools_config.template, "org", org_name) or org_name - return org_name - - def _get_pipeline_org_full_name(self) -> str: - org_full_name = "nf-core" - + def _get_template_parameter(self, param_name: str) -> str | None: if self.tools_config and getattr(self.tools_config, "template", None): - template_org_full_name = getattr(self.tools_config.template, "org_full_name", None) - if template_org_full_name: - return template_org_full_name + org_name = getattr(self.tools_config.template, param_name, None) + if org_name: + return org_name + return None - org_full_name = getattr(self.tools_config.template, "org", org_full_name) or org_full_name + def _get_pipeline_org(self) -> str: + return self._get_template_parameter("org") or "nf-core" - return org_full_name + def _get_pipeline_org_full_name(self) -> str: + return self._get_template_parameter("org_full_name") or self._get_pipeline_org() def _get_pipeline_org_url(self) -> str: - org_name = self._get_pipeline_org() - - if self.tools_config and getattr(self.tools_config, "template", None): - template_org_url = getattr(self.tools_config.template, "org_url", None) - if template_org_url: - return template_org_url - - return get_org_url(org_name) + return self._get_template_parameter("org_url") or get_org_url(self._get_pipeline_org()) def _get_main_entity_url(self) -> str: """Return a stable URL for the selected workflow revision.""" From 6e401ef76b46707a15c6884d2cebb56d85e0244f Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Thu, 2 Apr 2026 03:58:58 +0200 Subject: [PATCH 18/22] New function to get link to the pipeline page --- nf_core/pipelines/rocrate.py | 13 ++--- nf_core/utils.py | 29 ++++++++--- tests/pipelines/test_rocrate.py | 19 ++++++- tests/test_utils.py | 89 +++++++++++++-------------------- 4 files changed, 84 insertions(+), 66 deletions(-) diff --git a/nf_core/pipelines/rocrate.py b/nf_core/pipelines/rocrate.py index f694111efa..ab5bbf52e1 100644 --- a/nf_core/pipelines/rocrate.py +++ b/nf_core/pipelines/rocrate.py @@ -17,7 +17,7 @@ from rocrate.model.person import Person from rocrate.rocrate import ROCrate as BaseROCrate -from nf_core.utils import Pipeline, get_org_url, load_tools_config +from nf_core.utils import Pipeline, get_docs_url, get_org_url, load_tools_config log = logging.getLogger(__name__) @@ -170,12 +170,13 @@ def _get_pipeline_org_url(self) -> str: def _get_main_entity_url(self) -> str: """Return a stable URL for the selected workflow revision.""" - return "/".join([ + return get_docs_url( self._get_pipeline_org_url(), - self.crate.name.replace(self._get_pipeline_org() + '/', ''), - "dev" if self.version.endswith("dev") else self.version, - "", # To have a trailing slash at the end of the URL - ]) + self.crate.name, + self.crate.name.replace(self._get_pipeline_org() + "/", ""), + self.version, + None, + ) def _get_remote_workflow_topics(self) -> list[str]: """Fetch workflow topics from the organisation pipelines index when available.""" diff --git a/nf_core/utils.py b/nf_core/utils.py index 680a37edb3..604ccb0b11 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -1243,23 +1243,40 @@ def get_org_url(org_name: str, is_nfcore: bool | None = None) -> str: return f"https://github.com/{org_name}" -def get_docs_url(org_url: str, repo_name: str, short_name: str, branch: str, doc_name: str) -> str: +def get_docs_url(org_url: str, repo_name: str, short_name: str, branch: str, doc_name: str | None) -> str: """Return a forge-aware URL for a rendered documentation page.""" normalized_org_url = org_url.rstrip("/") parsed_url = urlparse(normalized_org_url) hostname = parsed_url.netloc.lower() base_url = f"{parsed_url.scheme}://{parsed_url.netloc}" + revision = "dev" if branch.endswith("dev") else branch if hostname == "github.com" or hostname.startswith("github.") or ".github." in hostname: - return f"{base_url}/{repo_name}/blob/{branch}/docs/{doc_name}.md" + if doc_name: + return f"{base_url}/{repo_name}/blob/{revision}/docs/{doc_name}.md" + else: + return f"{base_url}/{repo_name}/tree/{revision}" if hostname == "gitlab.com" or hostname.startswith("gitlab.") or ".gitlab." in hostname: - return f"{base_url}/{repo_name}/-/blob/{branch}/docs/{doc_name}.md" + if doc_name: + return f"{base_url}/{repo_name}/-/blob/{revision}/docs/{doc_name}.md" + else: + return f"{base_url}/{repo_name}/-/tree/{revision}" if hostname == "bitbucket.org" or hostname.startswith("bitbucket.") or ".bitbucket." in hostname: - return f"{base_url}/{repo_name}/src/{branch}/docs/{doc_name}.md" + if doc_name: + return f"{base_url}/{repo_name}/src/{revision}/docs/{doc_name}.md" + else: + return f"{base_url}/{repo_name}/src/{revision}" if hostname == "codeberg.org" or "forgejo" in hostname or "gitea" in hostname: - return f"{base_url}/{repo_name}/src/branch/{branch}/docs/{doc_name}.md" + if doc_name: + return f"{base_url}/{repo_name}/src/branch/{revision}/docs/{doc_name}.md" + else: + return f"{base_url}/{repo_name}/src/branch/{revision}" - return f"{normalized_org_url}/{short_name}/{doc_name}" + revision = "" if revision in ["main", "master"] else f"/{revision}" + if doc_name: + return f"{normalized_org_url}/{short_name}{revision}/{doc_name}" + else: + return f"{normalized_org_url}/{short_name}{revision}" class NFCoreTemplateConfig(BaseModel): diff --git a/tests/pipelines/test_rocrate.py b/tests/pipelines/test_rocrate.py index cec5c7f1a3..390028047f 100644 --- a/tests/pipelines/test_rocrate.py +++ b/tests/pipelines/test_rocrate.py @@ -489,10 +489,27 @@ def test_rocrate_creation_uses_template_org_params(self): self.assertIn("https://example.org/pipelines", entities) self.assertEqual(entities["https://example.org/pipelines"]["name"], "My Organisation") - self.assertIn("https://example.org/pipelines/testpipeline/dev/", main_entity["url"]) + self.assertIn("https://example.org/pipelines/testpipeline/dev", main_entity["url"]) self.assertIn("nf-core", main_entity["keywords"]) self.assertIn("custom", main_entity["keywords"]) + def test_rocrate_creation_uses_manifest_homepage_for_release_url(self): + """Use manifest.homePage to build the RO-Crate main entity URL for release revisions.""" + bump_pipeline_version(self.pipeline_obj, "1.1.0") + self.rocrate_obj = nf_core.pipelines.rocrate.ROCrate(self.pipeline_dir) + + with patch("nf_core.pipelines.rocrate.requests.get", side_effect=self._mock_pipelines_response): + assert self.rocrate_obj.create_rocrate(self.pipeline_dir, self.pipeline_dir) + + with open(Path(self.pipeline_dir, "ro-crate-metadata.json")) as fh: + crate = json.load(fh) + main_entity = next(entity for entity in crate["@graph"] if entity.get("@id") in {"main.nf", "#main.nf"}) + + self.assertEqual( + main_entity["url"], + ["https://nf-co.re/testpipeline/1.1.0"], + ) + def test_rocrate_creation_falls_back_to_default_topics_on_request_error(self): """Keep RO-Crate generation working when the pipelines index cannot be fetched.""" diff --git a/tests/test_utils.py b/tests/test_utils.py index a8fa93d412..e50f1b7150 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -57,92 +57,75 @@ def test_strip_ansi_codes(): @pytest.mark.parametrize( - ("org_url", "repo_name", "short_name", "default_branch", "doc_name", "expected"), + ("doc_name", "generic_expected"), + [ + ("usage", "https://example.org/pipelines/testpipeline/usage"), + ("output", "https://example.org/pipelines/testpipeline/output"), + (None, "https://example.org/pipelines/testpipeline"), + ], +) +@pytest.mark.parametrize( + ("org_url", "repo_name", "short_name", "default_branch", "expected"), [ ( "https://github.com/my-org", "my-org/testpipeline", "testpipeline", "main", - "usage", - "https://github.com/my-org/testpipeline/blob/main/docs/usage.md", - ), - ( - "https://gitlab.com/my-org", - "my-org/testpipeline", - "testpipeline", - "main", - "usage", - "https://gitlab.com/my-org/testpipeline/-/blob/main/docs/usage.md", - ), - ( - "https://bitbucket.org/my-org", - "my-org/testpipeline", - "testpipeline", - "main", - "usage", - "https://bitbucket.org/my-org/testpipeline/src/main/docs/usage.md", - ), - ( - "https://codeberg.org/my-org", - "my-org/testpipeline", - "testpipeline", - "main", - "usage", - "https://codeberg.org/my-org/testpipeline/src/branch/main/docs/usage.md", - ), - ( - "https://example.org/pipelines", - "my-org/testpipeline", - "testpipeline", - "main", - "usage", - "https://example.org/pipelines/testpipeline/usage", - ), - ( - "https://github.com/my-org", - "my-org/testpipeline", - "testpipeline", - "main", - "output", - "https://github.com/my-org/testpipeline/blob/main/docs/output.md", + { + "usage": "https://github.com/my-org/testpipeline/blob/main/docs/usage.md", + "output": "https://github.com/my-org/testpipeline/blob/main/docs/output.md", + None: "https://github.com/my-org/testpipeline/tree/main", + }, ), ( "https://gitlab.com/my-org", "my-org/testpipeline", "testpipeline", "main", - "output", - "https://gitlab.com/my-org/testpipeline/-/blob/main/docs/output.md", + { + "usage": "https://gitlab.com/my-org/testpipeline/-/blob/main/docs/usage.md", + "output": "https://gitlab.com/my-org/testpipeline/-/blob/main/docs/output.md", + None: "https://gitlab.com/my-org/testpipeline/-/tree/main", + }, ), ( "https://bitbucket.org/my-org", "my-org/testpipeline", "testpipeline", "main", - "output", - "https://bitbucket.org/my-org/testpipeline/src/main/docs/output.md", + { + "usage": "https://bitbucket.org/my-org/testpipeline/src/main/docs/usage.md", + "output": "https://bitbucket.org/my-org/testpipeline/src/main/docs/output.md", + None: "https://bitbucket.org/my-org/testpipeline/src/main", + }, ), ( "https://codeberg.org/my-org", "my-org/testpipeline", "testpipeline", "main", - "output", - "https://codeberg.org/my-org/testpipeline/src/branch/main/docs/output.md", + { + "usage": "https://codeberg.org/my-org/testpipeline/src/branch/main/docs/usage.md", + "output": "https://codeberg.org/my-org/testpipeline/src/branch/main/docs/output.md", + None: "https://codeberg.org/my-org/testpipeline/src/branch/main", + }, ), ( "https://example.org/pipelines", "my-org/testpipeline", "testpipeline", "main", - "output", - "https://example.org/pipelines/testpipeline/output", + { + "usage": "https://example.org/pipelines/testpipeline/usage", + "output": "https://example.org/pipelines/testpipeline/output", + None: "https://example.org/pipelines/testpipeline", + }, ), ], ) -def test_get_docs_url(org_url, repo_name, short_name, default_branch, doc_name, expected): - assert nf_core.utils.get_docs_url(org_url, repo_name, short_name, default_branch, doc_name) == expected +def test_get_docs_url(org_url, repo_name, short_name, default_branch, doc_name, generic_expected, expected): + assert nf_core.utils.get_docs_url(org_url, repo_name, short_name, default_branch, doc_name) == expected[doc_name] class TestUtils(TestPipelines): From 435a36b2916bd4b96ea6920da564e8c587786f21 Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sun, 5 Apr 2026 11:09:45 +0200 Subject: [PATCH 19/22] Better URLs for tagged releases --- nf_core/utils.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/nf_core/utils.py b/nf_core/utils.py index 604ccb0b11..fc008dae16 100644 --- a/nf_core/utils.py +++ b/nf_core/utils.py @@ -1255,22 +1255,34 @@ def get_docs_url(org_url: str, repo_name: str, short_name: str, branch: str, doc if doc_name: return f"{base_url}/{repo_name}/blob/{revision}/docs/{doc_name}.md" else: - return f"{base_url}/{repo_name}/tree/{revision}" + if revision == "dev": + return f"{base_url}/{repo_name}/tree/{revision}" + else: + return f"{base_url}/{repo_name}/releases/tag/{revision}" if hostname == "gitlab.com" or hostname.startswith("gitlab.") or ".gitlab." in hostname: if doc_name: return f"{base_url}/{repo_name}/-/blob/{revision}/docs/{doc_name}.md" else: - return f"{base_url}/{repo_name}/-/tree/{revision}" + if revision == "dev": + return f"{base_url}/{repo_name}/-/tree/{revision}" + else: + return f"{base_url}/{repo_name}/-/releases/{revision}" if hostname == "bitbucket.org" or hostname.startswith("bitbucket.") or ".bitbucket." in hostname: if doc_name: return f"{base_url}/{repo_name}/src/{revision}/docs/{doc_name}.md" else: - return f"{base_url}/{repo_name}/src/{revision}" + if revision == "dev": + return f"{base_url}/{repo_name}/src/{revision}" + else: + return f"{base_url}/{repo_name}/commits/tag/{revision}" if hostname == "codeberg.org" or "forgejo" in hostname or "gitea" in hostname: if doc_name: return f"{base_url}/{repo_name}/src/branch/{revision}/docs/{doc_name}.md" else: - return f"{base_url}/{repo_name}/src/branch/{revision}" + if revision == "dev": + return f"{base_url}/{repo_name}/src/branch/{revision}" + else: + return f"{base_url}/{repo_name}/releases/tag/{revision}" revision = "" if revision in ["main", "master"] else f"/{revision}" if doc_name: From 5a98c8ddf0445a8f098005d6767e762faaabfcbd Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Mon, 6 Apr 2026 03:32:29 +0200 Subject: [PATCH 20/22] Fixedand expanded the tests --- tests/test_utils.py | 259 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 218 insertions(+), 41 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index e50f1b7150..9d9569c258 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -57,75 +57,252 @@ def test_strip_ansi_codes(): @pytest.mark.parametrize( - ("doc_name", "generic_expected"), - [ - ("usage", "https://example.org/pipelines/testpipeline/usage"), - ("output", "https://example.org/pipelines/testpipeline/output"), - (None, "https://example.org/pipelines/testpipeline"), - ], -) -@pytest.mark.parametrize( - ("org_url", "repo_name", "short_name", "default_branch", "expected"), + ("org_url", "repo_name", "short_name", "default_branch", "doc_name", "expected"), [ ( "https://github.com/my-org", "my-org/testpipeline", "testpipeline", - "main", - { - "usage": "https://github.com/my-org/testpipeline/blob/main/docs/usage.md", - "output": "https://github.com/my-org/testpipeline/blob/main/docs/output.md", - None: "https://github.com/my-org/testpipeline/tree/main", - }, + "1.2.3", + "usage", + "https://github.com/my-org/testpipeline/blob/1.2.3/docs/usage.md", + ), + ( + "https://github.com/my-org", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + "output", + "https://github.com/my-org/testpipeline/blob/1.2.3/docs/output.md", + ), + ( + "https://github.com/my-org", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + None, + "https://github.com/my-org/testpipeline/releases/tag/1.2.3", + ), + ( + "https://github.com/my-org", + "my-org/testpipeline", + "testpipeline", + "dev", + "usage", + "https://github.com/my-org/testpipeline/blob/dev/docs/usage.md", + ), + ( + "https://github.com/my-org", + "my-org/testpipeline", + "testpipeline", + "dev", + "output", + "https://github.com/my-org/testpipeline/blob/dev/docs/output.md", + ), + ( + "https://github.com/my-org", + "my-org/testpipeline", + "testpipeline", + "dev", + None, + "https://github.com/my-org/testpipeline/tree/dev", ), ( "https://gitlab.com/my-org", "my-org/testpipeline", "testpipeline", - "main", - { - "usage": "https://gitlab.com/my-org/testpipeline/-/blob/main/docs/usage.md", - "output": "https://gitlab.com/my-org/testpipeline/-/blob/main/docs/output.md", - None: "https://gitlab.com/my-org/testpipeline/-/tree/main", - }, + "1.2.3", + "usage", + "https://gitlab.com/my-org/testpipeline/-/blob/1.2.3/docs/usage.md", + ), + ( + "https://gitlab.com/my-org", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + "output", + "https://gitlab.com/my-org/testpipeline/-/blob/1.2.3/docs/output.md", + ), + ( + "https://gitlab.com/my-org", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + None, + "https://gitlab.com/my-org/testpipeline/-/releases/1.2.3", + ), + ( + "https://gitlab.com/my-org", + "my-org/testpipeline", + "testpipeline", + "dev", + "usage", + "https://gitlab.com/my-org/testpipeline/-/blob/dev/docs/usage.md", + ), + ( + "https://gitlab.com/my-org", + "my-org/testpipeline", + "testpipeline", + "dev", + "output", + "https://gitlab.com/my-org/testpipeline/-/blob/dev/docs/output.md", + ), + ( + "https://gitlab.com/my-org", + "my-org/testpipeline", + "testpipeline", + "dev", + None, + "https://gitlab.com/my-org/testpipeline/-/tree/dev", + ), + ( + "https://bitbucket.org/my-org", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + "usage", + "https://bitbucket.org/my-org/testpipeline/src/1.2.3/docs/usage.md", + ), + ( + "https://bitbucket.org/my-org", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + "output", + "https://bitbucket.org/my-org/testpipeline/src/1.2.3/docs/output.md", ), ( "https://bitbucket.org/my-org", "my-org/testpipeline", "testpipeline", - "main", - { - "usage": "https://bitbucket.org/my-org/testpipeline/src/main/docs/usage.md", - "output": "https://bitbucket.org/my-org/testpipeline/src/main/docs/output.md", - None: "https://bitbucket.org/my-org/testpipeline/src/main", - }, + "1.2.3", + None, + "https://bitbucket.org/my-org/testpipeline/commits/tag/1.2.3", + ), + ( + "https://bitbucket.org/my-org", + "my-org/testpipeline", + "testpipeline", + "dev", + "usage", + "https://bitbucket.org/my-org/testpipeline/src/dev/docs/usage.md", + ), + ( + "https://bitbucket.org/my-org", + "my-org/testpipeline", + "testpipeline", + "dev", + "output", + "https://bitbucket.org/my-org/testpipeline/src/dev/docs/output.md", + ), + ( + "https://bitbucket.org/my-org", + "my-org/testpipeline", + "testpipeline", + "dev", + None, + "https://bitbucket.org/my-org/testpipeline/src/dev", + ), + ( + "https://codeberg.org/my-org", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + "usage", + "https://codeberg.org/my-org/testpipeline/src/branch/1.2.3/docs/usage.md", + ), + ( + "https://codeberg.org/my-org", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + "output", + "https://codeberg.org/my-org/testpipeline/src/branch/1.2.3/docs/output.md", + ), + ( + "https://codeberg.org/my-org", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + None, + "https://codeberg.org/my-org/testpipeline/releases/tag/1.2.3", + ), + ( + "https://codeberg.org/my-org", + "my-org/testpipeline", + "testpipeline", + "dev", + "usage", + "https://codeberg.org/my-org/testpipeline/src/branch/dev/docs/usage.md", + ), + ( + "https://codeberg.org/my-org", + "my-org/testpipeline", + "testpipeline", + "dev", + "output", + "https://codeberg.org/my-org/testpipeline/src/branch/dev/docs/output.md", ), ( "https://codeberg.org/my-org", "my-org/testpipeline", "testpipeline", - "main", - { - "usage": "https://codeberg.org/my-org/testpipeline/src/branch/main/docs/usage.md", - "output": "https://codeberg.org/my-org/testpipeline/src/branch/main/docs/output.md", - None: "https://codeberg.org/my-org/testpipeline/src/branch/main", - }, + "dev", + None, + "https://codeberg.org/my-org/testpipeline/src/branch/dev", + ), + ( + "https://example.org/pipelines", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + "usage", + "https://example.org/pipelines/testpipeline/1.2.3/usage", + ), + ( + "https://example.org/pipelines", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + "output", + "https://example.org/pipelines/testpipeline/1.2.3/output", + ), + ( + "https://example.org/pipelines", + "my-org/testpipeline", + "testpipeline", + "1.2.3", + None, + "https://example.org/pipelines/testpipeline/1.2.3", + ), + ( + "https://example.org/pipelines", + "my-org/testpipeline", + "testpipeline", + "dev", + "usage", + "https://example.org/pipelines/testpipeline/dev/usage", + ), + ( + "https://example.org/pipelines", + "my-org/testpipeline", + "testpipeline", + "dev", + "output", + "https://example.org/pipelines/testpipeline/dev/output", ), ( "https://example.org/pipelines", "my-org/testpipeline", "testpipeline", - "main", - { - "usage": "https://example.org/pipelines/testpipeline/usage", - "output": "https://example.org/pipelines/testpipeline/output", - None: "https://example.org/pipelines/testpipeline", - }, + "dev", + None, + "https://example.org/pipelines/testpipeline/dev", ), ], ) -def test_get_docs_url(org_url, repo_name, short_name, default_branch, doc_name, generic_expected, expected): - assert nf_core.utils.get_docs_url(org_url, repo_name, short_name, default_branch, doc_name) == expected[doc_name] +def test_get_docs_url(org_url, repo_name, short_name, default_branch, doc_name, expected): + assert nf_core.utils.get_docs_url(org_url, repo_name, short_name, default_branch, doc_name) == expected class TestUtils(TestPipelines): From 9d41f788877fd3d6fc50c4835529fa6c00f2528a Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Mon, 6 Apr 2026 12:00:51 +0200 Subject: [PATCH 21/22] bug: the URL was meant to be added the list --- nf_core/pipelines/rocrate.py | 2 +- tests/pipelines/test_rocrate.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nf_core/pipelines/rocrate.py b/nf_core/pipelines/rocrate.py index ab5bbf52e1..a9a1663ea6 100644 --- a/nf_core/pipelines/rocrate.py +++ b/nf_core/pipelines/rocrate.py @@ -278,7 +278,7 @@ def set_main_entity(self, main_entity_filename: str): "dateModified", str(datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")), compact=True ) self.crate.mainEntity.append_to("sdPublisher", {"@id": self._get_pipeline_org_url()}, compact=True) - self.crate.mainEntity["url"] = [self._get_main_entity_url()] + self.crate.mainEntity.append_to("url", self._get_main_entity_url(), compact=True) self.crate.mainEntity.append_to("version", self.version, compact=True) # remove duplicate entries for version diff --git a/tests/pipelines/test_rocrate.py b/tests/pipelines/test_rocrate.py index 390028047f..23b0e67ea9 100644 --- a/tests/pipelines/test_rocrate.py +++ b/tests/pipelines/test_rocrate.py @@ -507,7 +507,7 @@ def test_rocrate_creation_uses_manifest_homepage_for_release_url(self): self.assertEqual( main_entity["url"], - ["https://nf-co.re/testpipeline/1.1.0"], + ["https://github.com/nf-core/testpipeline", "https://nf-co.re/testpipeline/1.1.0"], ) def test_rocrate_creation_falls_back_to_default_topics_on_request_error(self): From 05c9fa9a10fb71f8c00db24fc1c22a3ce2d786ef Mon Sep 17 00:00:00 2001 From: Matthieu Muffato Date: Sat, 18 Apr 2026 00:56:29 +0100 Subject: [PATCH 22/22] [lint] os.listdir is to be avoided --- tests/pipelines/test_sync.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/pipelines/test_sync.py b/tests/pipelines/test_sync.py index b16bccae5f..6b5c653840 100644 --- a/tests/pipelines/test_sync.py +++ b/tests/pipelines/test_sync.py @@ -340,9 +340,9 @@ def test_create_template_pipeline_backfills_missing_org_url(self): psync.checkout_template_branch() psync.delete_tracked_template_branch_files() psync.make_template_pipeline() - - assert "main.nf" in os.listdir(self.pipeline_dir) - assert "nextflow.config" in os.listdir(self.pipeline_dir) + pipeline_path = Path(self.pipeline_dir) + assert (pipeline_path / "main.nf").exists() + assert (pipeline_path / "nextflow.config").exists() def test_commit_template_changes_nochanges(self): """Try to commit the TEMPLATE branch, but no changes were made"""