diff --git a/nf_core/pipelines/create/basicdetails.py b/nf_core/pipelines/create/basicdetails.py index 2bd2ea1c79..85cce20444 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,9 @@ class BasicDetails(Screen): """Name, description, author, etc.""" + _auto_org_full_name: str | None = None + _auto_org_url: str | None = None + def compose(self) -> ComposeResult: yield Header() yield Footer() @@ -58,6 +62,19 @@ def compose(self) -> ComposeResult: "Author(s)", "Name of the main author / authors", ) + if not self.parent.NFCORE_PIPELINE: + yield TextInput( + "org_full_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"), @@ -65,20 +82,77 @@ def compose(self) -> ComposeResult: classes="cta", ) - @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.""" + 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._get_input_widget("org") + 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_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_full_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 = {} 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: + # 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 + + @on(Input.Changed) + @on(Input.Submitted) + 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.""" + 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_full_name", "org_url"): + if event.input is self._get_input_widget(field_id): + if event.input.value: + break + if field_id == "org_full_name": + restored_value = org_input.value or "nf-core" + 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 + event.input.value = restored_value + break + def on_screen_resume(self): """Hide warn message on screen resume. Update displayed value on screen resume.""" @@ -86,19 +160,19 @@ def on_screen_resume(self): for text_input in self.query("TextInput"): if text_input.field_id == "org": text_input.disabled = self.parent.NFCORE_PIPELINE + 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 055e5ae5bd..c7bf2faed3 100644 --- a/nf_core/pipelines/create/create.py +++ b/nf_core/pipelines/create/create.py @@ -194,6 +194,10 @@ 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_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) 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..fea8e8710c 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_full_name", "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/pipelines/rocrate.py b/nf_core/pipelines/rocrate.py index b4735c2127..a9a1663ea6 100644 --- a/nf_core/pipelines/rocrate.py +++ b/nf_core/pipelines/rocrate.py @@ -17,10 +17,13 @@ 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, get_docs_url, get_org_url, load_tools_config 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) @@ -88,6 +91,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() @@ -143,6 +152,61 @@ def create_rocrate(self, json_path: None | Path = None, zip_path: None | Path = return True + def _get_template_parameter(self, param_name: str) -> str | None: + if self.tools_config and getattr(self.tools_config, "template", None): + org_name = getattr(self.tools_config.template, param_name, None) + if org_name: + return org_name + return None + + def _get_pipeline_org(self) -> str: + return self._get_template_parameter("org") or "nf-core" + + 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: + 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.""" + return get_docs_url( + self._get_pipeline_org_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.""" + 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: """ Create an RO Crate for a pipeline @@ -191,9 +255,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_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_full_name, "url": org_url}) # Set metadata for main entity file self.set_main_entity("main.nf") @@ -213,25 +277,14 @@ 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) - url = "dev" if self.version.endswith("dev") else self.version - self.crate.mainEntity.append_to( - "url", f"https://nf-co.re/{self.crate.name.replace('nf-core/', '')}/{url}/", compact=True - ) + self.crate.mainEntity.append_to("sdPublisher", {"@id": self._get_pipeline_org_url()}, compact=True) + 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 self.crate.mainEntity["version"] = list(set(self.crate.mainEntity["version"])) - # get keywords 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"] - 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/", ""): - 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/nf_core/utils.py b/nf_core/utils.py index bac78de090..fc008dae16 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 @@ -1235,11 +1236,70 @@ 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}" + + +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: + if doc_name: + return f"{base_url}/{repo_name}/blob/{revision}/docs/{doc_name}.md" + else: + 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: + 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: + 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: + 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: + return f"{normalized_org_url}/{short_name}{revision}/{doc_name}" + else: + return f"{normalized_org_url}/{short_name}{revision}" + + class NFCoreTemplateConfig(BaseModel): """Template configuration schema""" org: str | None = None """ Organisation name """ + org_full_name: str | None = None + """ Full organisation name """ + org_url: str | None = None + """ Organisation URL """ name: str | None = None """ Pipeline name """ description: str | None = None @@ -1427,9 +1487,27 @@ 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_full_name", None) + template.pop("org_url", None) + 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_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) + + def load_tools_config(directory: str | Path = ".") -> tuple[Path | None, NFCoreYamlConfig | None]: """ Parse the nf-core.yml configuration file @@ -1510,6 +1588,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/__snapshots__/test_create_app/test_basic_details_custom.svg b/tests/pipelines/__snapshots__/test_create_app/test_basic_details_custom.svg index 7936809fd5..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) +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Back  Next  -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + +Display name for the organisation - - - +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +nf-core                                                                                    +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - +Website URL for the organisation - - - +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▄▄ +https://nf-co.re                                                                           +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -^p palette +^p palette 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/test_create.py b/tests/pipelines/test_create.py index ccd125d17e..56fc1bbe4f 100644 --- a/tests/pipelines/test_create.py +++ b/tests/pipelines/test_create.py @@ -43,6 +43,8 @@ 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_full_name == "nf-core" + assert pipeline.config.org_url == "https://nf-co.re" @with_temporary_folder def test_pipeline_creation_initiation(self, tmp_path): @@ -61,7 +63,10 @@ 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_full_name" not in nfcore_yml["template"] + assert "org_url" not in nfcore_yml["template"] @with_temporary_folder def test_pipeline_creation_initiation_with_yml(self, tmp_path): @@ -82,6 +87,8 @@ 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_full_name"] == "testprefix" + 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 +106,8 @@ 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_full_name"] == "testprefix" + assert nfcore_yml["template"]["org_url"] == "https://github.com/testprefix" @with_temporary_folder def test_pipeline_creation_with_yml_skip(self, tmp_path): diff --git a/tests/pipelines/test_create_app.py b/tests/pipelines/test_create_app.py index 144a51ac07..5e3f091ed4 100644 --- a/tests/pipelines/test_create_app.py +++ b/tests/pipelines/test_create_app.py @@ -2,11 +2,21 @@ from unittest import mock +from textual.widgets import Button, 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" +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() @@ -23,6 +33,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_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_full_name_input.value == "testprefix" + assert org_url_input.value == "https://github.com/testprefix" + + org_full_name_input.focus() + await pilot.pause() + await pilot.press("backspace") + assert org_full_name_input.value == "" + + org_url_input.focus() + await pilot.pause() + assert org_full_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)) @@ -89,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) @@ -132,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) @@ -181,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) diff --git a/tests/pipelines/test_rocrate.py b/tests/pipelines/test_rocrate.py index 8b04eb539b..23b0e67ea9 100644 --- a/tests/pipelines/test_rocrate.py +++ b/tests/pipelines/test_rocrate.py @@ -6,9 +6,12 @@ import tempfile from pathlib import Path from unittest import mock +from unittest.mock import patch import git +import requests import rocrate.rocrate +import yaml from git import Repo import nf_core.pipelines.rocrate @@ -26,18 +29,75 @@ 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://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}") + 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): @@ -129,7 +189,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()) @@ -155,9 +216,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""" @@ -383,6 +444,118 @@ 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" + 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) + + 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) + 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_uses_template_org_params(self): + """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_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) + + 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) + 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 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_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://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): + """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 aa27170f26..6b5c653840 100644 --- a/tests/pipelines/test_sync.py +++ b/tests/pipelines/test_sync.py @@ -7,9 +7,11 @@ import git import pytest +import requests import yaml import nf_core.pipelines.sync +import nf_core.utils from nf_core.utils import NFCoreYamlConfig from ..test_pipelines import TestPipelines @@ -29,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""" @@ -307,6 +313,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_full_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() + 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""" # Check out the TEMPLATE branch but skip making the new template etc. diff --git a/tests/test_utils.py b/tests/test_utils.py index fe5ff56d8e..9d9569c258 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -56,6 +56,255 @@ def test_strip_ansi_codes(): assert stripped == "ls examplefile.zip" +@pytest.mark.parametrize( + ("org_url", "repo_name", "short_name", "default_branch", "doc_name", "expected"), + [ + ( + "https://github.com/my-org", + "my-org/testpipeline", + "testpipeline", + "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", + "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", + "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", + "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", + "dev", + None, + "https://example.org/pipelines/testpipeline/dev", + ), + ], +) +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): """Class for utils tests"""