From b7bf5763f0fd588ede53a1b06ea1160b77098c86 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:24:21 +0200 Subject: [PATCH 1/6] Update dependency textual to v8.2.8 (#4409) --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b576e68066..6f603aae16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ dependencies = [ "repo2rocrate", "setuptools<81", "tabulate", - "textual==8.2.7", + "textual==8.2.8", "trogon", "pdiff", "ruamel.yaml", diff --git a/uv.lock b/uv.lock index 1af61632ae..1a41d54693 100644 --- a/uv.lock +++ b/uv.lock @@ -1825,7 +1825,7 @@ requires-dist = [ { name = "ruamel-yaml" }, { name = "setuptools", specifier = "<81" }, { name = "tabulate" }, - { name = "textual", specifier = "==8.2.7" }, + { name = "textual", specifier = "==8.2.8" }, { name = "trogon" }, ] @@ -3742,7 +3742,7 @@ wheels = [ [[package]] name = "textual" -version = "8.2.7" +version = "8.2.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, extra = ["linkify"], marker = "python_full_version < '3.11'" }, @@ -3753,9 +3753,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9b/7a/c519db0aba5024f86e71e9631810bfdd6866ed2c8695bd7fa34b90e7ef59/textual-8.2.7.tar.gz", hash = "sha256:658f568ff81e30ed43890c3e07520390e5cf1b4763822006e060656b0a88f105", size = 1859249, upload-time = "2026-05-19T10:52:49.531Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/f5/c1e18bc0707300a0e90204343abbf7d7acd6fb7ebe03a6d4893b99a234b8/textual-8.2.7-py3-none-any.whl", hash = "sha256:4caaa13a90bc4cf9c6c862c067ccd34fe84e9c161710a2a907a8026313b6bd73", size = 731129, upload-time = "2026-05-19T10:52:51.773Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, ] [[package]] From 2f6ab4ef374d41bd27046ab167fb63aec78d0289 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:29:33 +0200 Subject: [PATCH 2/6] Update pre-commit hook astral-sh/uv-pre-commit to v0.11.31 (#4410) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 63fff74030..0fb30da8c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,7 +49,7 @@ repos: - types-setuptools - pydantic - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.30 + rev: 0.11.31 hooks: - id: uv-lock priority: 20 From 674138a81b4fa61e7d2c4f860e796e771add6153 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:30:06 +0200 Subject: [PATCH 3/6] Update mcr.microsoft.com/devcontainers/base Docker tag to v2.1.11 (#4363) --- .devcontainer/build-devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/build-devcontainer/Dockerfile b/.devcontainer/build-devcontainer/Dockerfile index e1a66c1181..355041eb35 100644 --- a/.devcontainer/build-devcontainer/Dockerfile +++ b/.devcontainer/build-devcontainer/Dockerfile @@ -27,7 +27,7 @@ RUN rm -f /etc/apt/sources.list.d/yarn.list && \ rm -rf /var/lib/apt/lists/* # Final stage to copy only the required files after installation -FROM mcr.microsoft.com/devcontainers/base:2.1.8-debian12@sha256:17e6cc517b483d1108b333d4c34352b0a21617f0117052e9b259d47113a9dc37 AS final +FROM mcr.microsoft.com/devcontainers/base:2.1.11-debian12@sha256:bb7b81b6e5be17b5267f92f4ffda534fea37dab1df97b5e86c1f9b91da5c0b5d AS final # Copy only the conda environment and site-packages from build stage COPY --from=build /opt/conda /opt/conda From 209d786e247122622ea83387521fa0c667101a04 Mon Sep 17 00:00:00 2001 From: mashehu Date: Thu, 16 Jul 2026 20:07:19 +0200 Subject: [PATCH 4/6] use components.json instead of github repo as modules version cache (cherry picked from commit 47b36b0d7661a2172a2b82407189f12548f77300) # Conflicts: # nf_core/modules/lint/main_nf.py --- nf_core/components/components_command.py | 17 +- nf_core/components/components_utils.py | 7 +- nf_core/components/install.py | 10 +- nf_core/components/list.py | 4 +- nf_core/components/update.py | 8 +- nf_core/modules/lint/environment_yml.py | 2 + nf_core/modules/lint/meta_yml.py | 2 + nf_core/modules/lint/module_changes.py | 2 +- nf_core/modules/lint/module_version.py | 2 +- nf_core/modules/modules_json.py | 25 ++- nf_core/modules/modules_repo.py | 17 ++ nf_core/modules/registry_client.py | 204 ++++++++++++++++++ .../subworkflows/lint/subworkflow_changes.py | 4 +- .../subworkflows/lint/subworkflow_version.py | 2 +- 14 files changed, 270 insertions(+), 36 deletions(-) create mode 100644 nf_core/modules/registry_client.py diff --git a/nf_core/components/components_command.py b/nf_core/components/components_command.py index 7fffbf8e44..8d35b47d39 100644 --- a/nf_core/components/components_command.py +++ b/nf_core/components/components_command.py @@ -6,7 +6,7 @@ import nf_core.utils from nf_core.modules.modules_json import ModulesJson -from nf_core.modules.modules_repo import ModulesRepo +from nf_core.modules.modules_repo import ModulesRepoType, get_modules_repo from nf_core.modules.modules_utils import scan_modules_dir from .components_utils import get_repo_info @@ -34,7 +34,7 @@ def __init__( """ self.component_type: str = component_type self.directory: Path = Path(directory) - self.modules_repo = ModulesRepo(remote_url, branch, no_pull, hide_progress) + self.modules_repo = get_modules_repo(remote_url, branch, no_pull, hide_progress) self.hide_progress: bool = hide_progress self.no_prompts: bool = no_prompts or not nf_core.utils.is_interactive() self.repo_type: str | None = None @@ -156,7 +156,7 @@ def components_from_repo(self, install_dir: str) -> list[str]: return scan_modules_dir(repo_dir) def install_component_files( - self, component_name: str, component_version: str, modules_repo: ModulesRepo, install_dir: str | Path + self, component_name: str, component_version: str, modules_repo: ModulesRepoType, install_dir: str | Path ) -> bool: """ Installs a module/subworkflow into the given directory @@ -206,11 +206,12 @@ def check_modules_structure(self) -> None: # If there are modules installed in the wrong location if len(wrong_location_modules) > 0: log.info("The modules folder structure is outdated. Reinstalling modules.") - # Remove the local copy of the modules repository - log.info(f"Updating '{self.modules_repo.local_repo_dir}'") - self.modules_repo.setup_local_repo( - self.modules_repo.remote_url, self.modules_repo.branch, self.hide_progress - ) + # Remove the local copy of the modules repository (git backend only) + if self.modules_repo.local_repo_dir is not None: + log.info(f"Updating '{self.modules_repo.local_repo_dir}'") + self.modules_repo.setup_local_repo( # type: ignore[union-attr] + self.modules_repo.remote_url, self.modules_repo.branch, self.hide_progress + ) # Move wrong modules to the right directory for module in wrong_location_modules: modules_dir = Path("modules").resolve() diff --git a/nf_core/components/components_utils.py b/nf_core/components/components_utils.py index b4cc7bea18..2fb4b91511 100644 --- a/nf_core/components/components_utils.py +++ b/nf_core/components/components_utils.py @@ -8,7 +8,7 @@ import ruamel.yaml import nf_core.utils -from nf_core.modules.modules_repo import ModulesRepo +from nf_core.modules.modules_repo import ModulesRepoType, get_modules_repo log = logging.getLogger(__name__) @@ -36,6 +36,7 @@ def get_repo_info(directory: Path, use_prompt: bool | None = True) -> tuple[Path # Try to find the root directory base_dir: Path = nf_core.utils.determine_base_dir(directory) + print(f"base_dir: {base_dir}") # Figure out the repository type from the .nf-core.yml config file if we can config_fn, tools_config = nf_core.utils.load_tools_config(base_dir) @@ -96,7 +97,7 @@ def get_repo_info(directory: Path, use_prompt: bool | None = True) -> tuple[Path def prompt_component_version_sha( component_name: str, component_type: str, - modules_repo: "ModulesRepo", + modules_repo: "ModulesRepoType", installed_sha: str | None = None, ) -> str: """ @@ -193,7 +194,7 @@ def get_components_to_install( component_name = list(component.keys())[0].lower() branch = component[component_name].get("branch") git_remote = component[component_name]["git_remote"] - modules_repo = ModulesRepo(git_remote, branch=branch) + modules_repo = get_modules_repo(git_remote, branch=branch) current_comp_dict = subworkflows if component_name in subworkflows else modules component_dict = { diff --git a/nf_core/components/install.py b/nf_core/components/install.py index fc6c790dd3..1080b4920a 100644 --- a/nf_core/components/install.py +++ b/nf_core/components/install.py @@ -19,7 +19,7 @@ NF_CORE_MODULES_NAME, ) from nf_core.modules.modules_json import ModulesJson -from nf_core.modules.modules_repo import ModulesRepo +from nf_core.modules.modules_repo import get_modules_repo from nf_core.pipelines.containers_utils import try_generate_container_configs log = logging.getLogger(__name__) @@ -40,7 +40,7 @@ def __init__( skip_deps: bool = False, ): super().__init__(component_type, pipeline_dir, remote_url, branch, no_pull) - self.current_remote = ModulesRepo(remote_url, branch) + self.current_remote = get_modules_repo(remote_url, branch) self.branch = branch self.force = force self.prompt = prompt @@ -57,7 +57,7 @@ def install(self, component: str | dict[str, str], silent: bool = False) -> bool # Override modules_repo when the component to install is a dependency from a subworkflow. remote_url = component.get("git_remote", self.current_remote.remote_url) branch = component.get("branch", self.branch) - self.modules_repo = ModulesRepo(remote_url, branch) + self.modules_repo = get_modules_repo(remote_url, branch) component = component["name"] if self.current_remote is None: @@ -232,7 +232,9 @@ def install_included_components(self, subworkflow_dir): self.modules_repo = ini_modules_repo def collect_and_verify_name( - self, component: str | None, modules_repo: "nf_core.modules.modules_repo.ModulesRepo" + self, + component: str | None, + modules_repo: "nf_core.modules.modules_repo.ModulesRepo | nf_core.modules.registry_client.RegistryClient", ) -> str: """ Collect component name. diff --git a/nf_core/components/list.py b/nf_core/components/list.py index 7411cfec90..d00c50070b 100644 --- a/nf_core/components/list.py +++ b/nf_core/components/list.py @@ -7,7 +7,7 @@ from nf_core.components.components_command import ComponentCommand from nf_core.modules.modules_json import ModulesJson, ModulesJsonModuleEntry -from nf_core.modules.modules_repo import ModulesRepo +from nf_core.modules.modules_repo import get_modules_repo log = logging.getLogger(__name__) @@ -135,7 +135,7 @@ def pattern_msg(keywords: list[str]) -> str: version_sha = component_entry["git_sha"] try: # pass repo_name to get info on modules even outside nf-core/modules - module = ModulesRepo( + module = get_modules_repo( remote_url=repo_url, branch=component_entry["branch"], ) diff --git a/nf_core/components/update.py b/nf_core/components/update.py index bf2b9a64b8..fbdd9941e9 100644 --- a/nf_core/components/update.py +++ b/nf_core/components/update.py @@ -15,7 +15,7 @@ from nf_core.components.install import ComponentInstall from nf_core.components.remove import ComponentRemove from nf_core.modules.modules_json import ModulesJson -from nf_core.modules.modules_repo import ModulesRepo +from nf_core.modules.modules_repo import get_modules_repo from nf_core.pipelines.containers_utils import try_generate_container_configs from nf_core.utils import plural_es, plural_s, plural_y @@ -41,7 +41,7 @@ def __init__( skip_deps=False, ): super().__init__(component_type, pipeline_dir, remote_url, branch, no_pull) - self.current_remote = ModulesRepo(remote_url, branch) + self.current_remote = get_modules_repo(remote_url, branch) self.branch = branch self.force = force self.prompt = prompt @@ -105,7 +105,7 @@ def update(self, component=None, silent=False, updated=None, check_diff_exist=Tr # Override modules_repo when the component to install is a dependency from a subworkflow. remote_url = component.get("git_remote", self.current_remote.remote_url) branch = component.get("branch", self.branch) - self.modules_repo = ModulesRepo(remote_url, branch) + self.modules_repo = get_modules_repo(remote_url, branch) component = component["name"] self.component = component @@ -708,7 +708,7 @@ def get_all_components_info(self, branch=None): repo_objs_comps = [] for (repo_url, branch), comps_shas in repos_and_branches.items(): try: - modules_repo = ModulesRepo(remote_url=repo_url, branch=branch) + modules_repo = get_modules_repo(remote_url=repo_url, branch=branch) except LookupError as e: log.warning(e) log.info(f"Skipping {self.component_type} in '{repo_url}'") diff --git a/nf_core/modules/lint/environment_yml.py b/nf_core/modules/lint/environment_yml.py index cd5ede8943..8adfa38434 100644 --- a/nf_core/modules/lint/environment_yml.py +++ b/nf_core/modules/lint/environment_yml.py @@ -199,6 +199,8 @@ def lint_environment_yml( if env_yml: valid_env_yml = False try: + if module_lint_object.modules_repo.local_repo_dir is None: + raise FileNotFoundError("No local repo clone available (HTTP registry mode)") with open(Path(module_lint_object.modules_repo.local_repo_dir, "modules/environment-schema.json")) as fh: schema = json.load(fh) validators.validate(instance=env_yml, schema=schema) diff --git a/nf_core/modules/lint/meta_yml.py b/nf_core/modules/lint/meta_yml.py index 6c5a0c36f4..2f757b6451 100644 --- a/nf_core/modules/lint/meta_yml.py +++ b/nf_core/modules/lint/meta_yml.py @@ -182,6 +182,8 @@ def meta_yml(module_lint_object: ModuleLint, module: NFCoreComponent, allow_miss # Confirm that the meta.yml file is valid according to the JSON schema valid_meta_yml = False try: + if module_lint_object.modules_repo.local_repo_dir is None: + raise FileNotFoundError("No local repo clone available (HTTP registry mode)") schema = module_lint_object.load_meta_schema() validators.validate(instance=meta_yaml, schema=schema) module.passed.append(("meta_yml", "meta_yml_valid", "Module `meta.yml` is valid", module.meta_yml)) diff --git a/nf_core/modules/lint/module_changes.py b/nf_core/modules/lint/module_changes.py index 07ccdd56f6..bcd571feeb 100644 --- a/nf_core/modules/lint/module_changes.py +++ b/nf_core/modules/lint/module_changes.py @@ -57,7 +57,7 @@ def module_changes(module_lint_object, module): module.branch = module_lint_object.modules_json.get_component_branch( "modules", module.component_name, module.repo_url, module.org ) - modules_repo = nf_core.modules.modules_repo.ModulesRepo(remote_url=module.repo_url, branch=module.branch) + modules_repo = nf_core.modules.modules_repo.get_modules_repo(remote_url=module.repo_url, branch=module.branch) for f, same in modules_repo.component_files_identical( module.component_name, tempdir, module.git_sha, "modules" diff --git a/nf_core/modules/lint/module_version.py b/nf_core/modules/lint/module_version.py index a5a7828d6e..f4c897227e 100644 --- a/nf_core/modules/lint/module_version.py +++ b/nf_core/modules/lint/module_version.py @@ -50,7 +50,7 @@ def module_version(module_lint_object: "nf_core.modules.lint.ModuleLint", module module.branch = module_lint_object.modules_json.get_component_branch( "modules", module.component_name, module.repo_url, module.org ) - modules_repo = nf_core.modules.modules_repo.ModulesRepo(remote_url=module.repo_url, branch=module.branch) + modules_repo = nf_core.modules.modules_repo.get_modules_repo(remote_url=module.repo_url, branch=module.branch) module_git_log = list(modules_repo.get_component_git_log(module.component_name, "modules")) if version == module_git_log[0]["git_sha"]: diff --git a/nf_core/modules/modules_json.py b/nf_core/modules/modules_json.py index c47b4378cf..402c70ffed 100644 --- a/nf_core/modules/modules_json.py +++ b/nf_core/modules/modules_json.py @@ -16,7 +16,8 @@ import nf_core.utils from nf_core.components.components_utils import get_components_to_install from nf_core.components.constants import NF_CORE_MODULES_NAME, NF_CORE_MODULES_REMOTE -from nf_core.modules.modules_repo import ModulesRepo +from nf_core.modules.modules_repo import ModulesRepo, get_modules_repo +from nf_core.modules.registry_client import RegistryClient from nf_core.pipelines.lint_utils import dump_json_with_prettier from ..components.components_differ import ComponentsDiffer @@ -138,7 +139,7 @@ def get_component_names_from_repo( """ names = [] for repo_url in repos: - modules_repo = ModulesRepo(repo_url) + modules_repo = get_modules_repo(repo_url) if modules_repo is None: raise UserWarning(f"Could not find module repository for '{repo_url}' in '{directory}'") if modules_repo.repo_path is None: @@ -180,7 +181,7 @@ def get_pipeline_module_repositories( renamed_dirs = {} # Check if there are any untracked repositories - dirs_not_covered = self.dir_tree_uncovered(directory, [Path(ModulesRepo(url).repo_path) for url in repos]) + dirs_not_covered = self.dir_tree_uncovered(directory, [Path(get_modules_repo(url).repo_path) for url in repos]) if len(dirs_not_covered) > 0: log.info(f"Found custom {component_type[:-1]} repositories when creating 'modules.json'") # Loop until all directories in the base directory are covered by a remote @@ -212,7 +213,7 @@ def get_pipeline_module_repositories( ).unsafe_ask() # Verify that there is a directory corresponding the remote - nrepo_name = ModulesRepo(nrepo_remote).repo_path + nrepo_name = get_modules_repo(nrepo_remote).repo_path if nrepo_name is None: raise UserWarning(f"Could not find the repository name for '{nrepo_remote}'") if not (directory / nrepo_name).exists(): @@ -300,7 +301,7 @@ def determine_branches_and_shas( (dict[str, dict[str, str]]): The module.json entries for the modules/subworkflows from the repository """ - default_modules_repo = ModulesRepo(remote_url=remote_url) + default_modules_repo = get_modules_repo(remote_url=remote_url) if component_type == "modules": repo_path = self.modules_dir / install_dir elif component_type == "subworkflows": @@ -370,7 +371,9 @@ def determine_branches_and_shas( dead_components.append(component) break # Create a new modules repo with the selected branch, and retry find the sha - modules_repo = ModulesRepo(remote_url=remote_url, branch=branch, no_pull=True, hide_progress=True) + modules_repo = get_modules_repo( + remote_url=remote_url, branch=branch, no_pull=True, hide_progress=True + ) else: found_sha = True break @@ -397,7 +400,7 @@ def find_correct_commit_sha( component_type: str, component_name: str | Path, component_path: str | Path, - modules_repo: ModulesRepo, + modules_repo: "ModulesRepo | RegistryClient", ) -> str | None: """ Returns the SHA for the latest commit where the local files are identical to the remote files @@ -598,7 +601,7 @@ def reinstall_repo(self, install_dir, remote_url, module_entries): for branch, modules in branches_and_mods.items(): try: - modules_repo = ModulesRepo(remote_url=remote_url, branch=branch) + modules_repo = get_modules_repo(remote_url=remote_url, branch=branch) except LookupError as e: log.error(e) failed_to_install.extend(modules) @@ -722,7 +725,7 @@ def load(self) -> None: def update( self, component_type: str, - modules_repo: ModulesRepo, + modules_repo: "ModulesRepo | RegistryClient", component_name: str, component_version: str, installed_by: list[str] | None, @@ -1079,7 +1082,7 @@ def get_dependent_components( # Find all components that have an entry of install by of a given component, recursively call this function for subworkflows for comp_type in component_types: for repo_url in self.modules_json["repos"]: - modules_repo = ModulesRepo(repo_url) + modules_repo = get_modules_repo(repo_url) install_dir = modules_repo.repo_path try: for comp in self.modules_json["repos"][repo_url][comp_type][install_dir]: @@ -1216,7 +1219,7 @@ def resolve_missing_from_modules_json(self, missing_from_modules_json, component def components_with_repos(): for directory in missing_from_modules_json: for repo_url in repos: - modules_repo = ModulesRepo(repo_url) + modules_repo = get_modules_repo(repo_url) paths_in_directory = [] repo_url_path = Path( self.modules_dir, diff --git a/nf_core/modules/modules_repo.py b/nf_core/modules/modules_repo.py index b9ac2c815b..5819dac13c 100644 --- a/nf_core/modules/modules_repo.py +++ b/nf_core/modules/modules_repo.py @@ -10,12 +10,25 @@ import nf_core.modules.modules_utils from nf_core.components.constants import NF_CORE_MODULES_NAME, NF_CORE_MODULES_REMOTE +from nf_core.modules.registry_client import RegistryClient from nf_core.synced_repo import RemoteProgressbar, SyncedRepo from nf_core.utils import NFCORE_CACHE_DIR, NFCORE_DIR, load_tools_config log = logging.getLogger(__name__) +def get_modules_repo( + remote_url: str | None = None, + branch: str | None = None, + no_pull: bool = False, + hide_progress: bool = False, +) -> "ModulesRepoType": + """Return a RegistryClient for the default nf-core/modules remote, or a ModulesRepo for custom remotes.""" + if (remote_url is None or remote_url == NF_CORE_MODULES_REMOTE) and branch is None: + return RegistryClient() + return ModulesRepo(remote_url=remote_url, branch=branch, no_pull=no_pull, hide_progress=hide_progress) + + class ModulesRepo(SyncedRepo): """ An object to store details about the repository being used for modules. @@ -150,3 +163,7 @@ def setup_local_repo(self, remote, branch, hide_progress=True, in_cache=False): self.setup_local_repo(remote, branch, hide_progress) else: raise LookupError("Exiting due to error with local modules git repo") from e + + +# Canonical type alias for the two possible backends — import this instead of defining locally. +ModulesRepoType = RegistryClient | ModulesRepo diff --git a/nf_core/modules/registry_client.py b/nf_core/modules/registry_client.py new file mode 100644 index 0000000000..ae95e17b54 --- /dev/null +++ b/nf_core/modules/registry_client.py @@ -0,0 +1,204 @@ +"""HTTP-based registry client for nf-core/modules. + +Fetches and caches components.json from the nf-core website, providing +the same interface as ModulesRepo without requiring a full git clone. +""" + +import json +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +import requests +import yaml + +from nf_core.components.constants import NF_CORE_MODULES_REMOTE +from nf_core.utils import NFCORE_CACHE_DIR, gh_api + +log = logging.getLogger(__name__) + +COMPONENTS_JSON_URL = "https://nf-co.re/components.json" +GITHUB_RAW_BASE = "https://raw.githubusercontent.com/nf-core/modules/{sha}/{path}" +GITHUB_API_BASE = "https://api.github.com/repos/nf-core/modules" + + +class RegistryClient: + """ + Drop-in replacement for ModulesRepo for the default nf-core/modules remote. + Downloads components.json once per session (re-downloading only when ETag changes) + and installs module files via raw.githubusercontent.com rather than a git clone. + """ + + def __init__(self) -> None: + self.remote_url = NF_CORE_MODULES_REMOTE + self.branch = "master" + self.repo_path = "nf-core" + self.modules_dir = None + self.subworkflows_dir = None + self._components: dict | None = None + self._by_name: dict[str, dict] = {} + + @property + def _cache_path(self) -> Path: + return Path(NFCORE_CACHE_DIR, "components.json") + + @property + def _etag_path(self) -> Path: + return self._cache_path.with_suffix(".etag") + + def _fetch(self) -> bytes | None: + """Download components.json; returns fresh bytes on 200, None on 304 (cache still valid).""" + headers: dict[str, str] = {} + if self._cache_path.exists() and self._etag_path.exists(): + headers["If-None-Match"] = self._etag_path.read_text().strip() + + try: + resp = requests.get(COMPONENTS_JSON_URL, headers=headers, timeout=30) + except requests.RequestException as e: + if self._cache_path.exists(): + log.warning(f"Could not reach {COMPONENTS_JSON_URL} ({e}), using cached components.json") + return None + raise LookupError(f"Could not fetch components.json and no local cache exists: {e}") from e + + if resp.status_code == 304: + return None + + resp.raise_for_status() + self._cache_path.parent.mkdir(parents=True, exist_ok=True) + self._cache_path.write_bytes(resp.content) + if etag := resp.headers.get("ETag"): + self._etag_path.write_text(etag) + return resp.content + + def _load(self) -> dict: + """Load components.json into memory, fetching first if needed.""" + if self._components is not None: + return self._components + fresh = self._fetch() + content = fresh if fresh is not None else self._cache_path.read_bytes() + self._components = json.loads(content) + for component_type in ("modules", "subworkflows"): + for entry in self._components.get(component_type, []): + name = self._path_to_name(entry["path"], component_type) + entry["_name"] = name + self._by_name[f"{component_type}/{name}"] = entry + return self._components + + @staticmethod + def _path_to_name(path: str, component_type: str) -> str: + """Convert components.json path to nf-core/tools slash-separated name. + + e.g. "modules/nf-core/samtools/index/meta.yml" -> "samtools/index" + """ + return path.removeprefix(f"{component_type}/nf-core/").removesuffix("/meta.yml") + + def get_avail_components(self, component_type: str, **_kwargs) -> list[str]: + data = self._load() + return [self._path_to_name(e["path"], component_type) for e in data.get(component_type, [])] + + def component_exists(self, component_name: str, component_type: str, **_kwargs) -> bool: + self._load() + return f"{component_type}/{component_name}" in self._by_name + + def get_latest_component_version(self, component_name: str, component_type: str) -> str | None: + self._load() + entry = self._by_name.get(f"{component_type}/{component_name}") + return entry.get("git_sha") if entry else None + + def install_component(self, component_name: str, install_dir: str | Path, commit: str, component_type: str) -> bool: + """Fetch component files from raw.githubusercontent.com at the given commit SHA.""" + self._load() + entry = self._by_name.get(f"{component_type}/{component_name}") + if not entry: + log.error(f"Could not find {component_type[:-1]} '{component_name}' in registry") + return False + + files: list[str] = entry.get("files", []) + if not files: + log.error(f"No file list available for '{component_name}' in registry") + return False + + install_path = Path(install_dir, component_name) + install_path.mkdir(parents=True, exist_ok=True) + dir_prefix = f"{component_type}/nf-core/{component_name}/" + + def _download(file_path: str) -> None: + dest = install_path / file_path.removeprefix(dir_prefix) + dest.parent.mkdir(parents=True, exist_ok=True) + resp = requests.get(GITHUB_RAW_BASE.format(sha=commit, path=file_path), timeout=30) + resp.raise_for_status() + dest.write_bytes(resp.content) + + try: + with ThreadPoolExecutor(max_workers=4) as pool: + futures = {pool.submit(_download, f): f for f in files} + for future in as_completed(futures): + future.result() + except requests.RequestException as e: + log.error(f"Failed to download files for '{component_name}' at {commit}: {e}") + return False + + return True + + def sha_exists_on_branch(self, sha: str) -> bool: + """Check whether a commit SHA exists in nf-core/modules via the GitHub API.""" + try: + resp = gh_api.get(f"{GITHUB_API_BASE}/commits/{sha}") + return resp.status_code == 200 + except requests.RequestException: + return False + + def get_commit_info(self, sha: str) -> dict[str, str]: + resp = gh_api.get(f"{GITHUB_API_BASE}/commits/{sha}") + resp.raise_for_status() + data = resp.json() + return { + "git_sha": sha, + "trunc_message": data["commit"]["message"], + "date": data["commit"]["committer"]["date"], + } + + def component_files_identical( + self, component_name: str | Path, base_path: str | Path, commit: str, component_type: str + ) -> dict[str, bool]: + """Not supported for HTTP registry — no local clone to compare against.""" + return {} + + def get_component_git_log( + self, component_name: str | Path, component_type: str, depth: int | None = None + ) -> list[dict[str, str]]: + """Fetch commit history via the GitHub API (used for interactive --sha selection).""" + dir_path = f"{component_type}/nf-core/{component_name}" + try: + resp = gh_api.get(f"{GITHUB_API_BASE}/commits", params={"path": dir_path, "per_page": depth or 30}) + resp.raise_for_status() + return [{"git_sha": c["sha"], "trunc_message": c["commit"]["message"]} for c in resp.json()] + except requests.RequestException as e: + log.error(f"Could not fetch git log for '{component_name}': {e}") + return [] + + def verify_sha(self, prompt: bool, sha: str | None) -> bool: + """Validate --sha / --prompt combination and check SHA exists.""" + if prompt and sha is not None: + log.error("Cannot use '--sha' and '--prompt' at the same time!") + return False + if sha and not self.sha_exists_on_branch(sha): + log.error(f"Commit SHA '{sha}' doesn't exist in '{self.remote_url}'") + return False + return True + + def get_meta_yml(self, component_type: str, component_name: str) -> str | None: + """Return the meta.yml content for a component as a YAML string.""" + self._load() + entry = self._by_name.get(f"{component_type}/{component_name}") + if entry is None or "meta" not in entry: + return None + return yaml.dump(entry["meta"], default_flow_style=False) + + @property + def local_repo_dir(self) -> None: + """No local clone — callers must guard against None.""" + return None + + def gitless_repo(self) -> str: + return self.remote_url.removesuffix(".git") diff --git a/nf_core/subworkflows/lint/subworkflow_changes.py b/nf_core/subworkflows/lint/subworkflow_changes.py index 22e992043e..1277ebfb46 100644 --- a/nf_core/subworkflows/lint/subworkflow_changes.py +++ b/nf_core/subworkflows/lint/subworkflow_changes.py @@ -78,7 +78,9 @@ def subworkflow_changes(subworkflow_lint_object, subworkflow): subworkflow.branch = subworkflow_lint_object.modules_json.get_component_branch( "subworkflows", subworkflow.component_name, subworkflow.repo_url, subworkflow.org ) - modules_repo = nf_core.modules.modules_repo.ModulesRepo(remote_url=subworkflow.repo_url, branch=subworkflow.branch) + modules_repo = nf_core.modules.modules_repo.get_modules_repo( + remote_url=subworkflow.repo_url, branch=subworkflow.branch + ) for f, same in modules_repo.component_files_identical( subworkflow.component_name, tempdir, subworkflow.git_sha, "subworkflows" diff --git a/nf_core/subworkflows/lint/subworkflow_version.py b/nf_core/subworkflows/lint/subworkflow_version.py index d364cd1637..8046456ebe 100644 --- a/nf_core/subworkflows/lint/subworkflow_version.py +++ b/nf_core/subworkflows/lint/subworkflow_version.py @@ -53,7 +53,7 @@ def subworkflow_version(subworkflow_lint_object, subworkflow): subworkflow.branch = subworkflow_lint_object.modules_json.get_component_branch( "subworkflows", subworkflow.component_name, subworkflow.repo_url, subworkflow.org ) - modules_repo = nf_core.modules.modules_repo.ModulesRepo( + modules_repo = nf_core.modules.modules_repo.get_modules_repo( remote_url=subworkflow.repo_url, branch=subworkflow.branch ) From d6151b7096787f3f0d159077942718f73d63d48c Mon Sep 17 00:00:00 2001 From: mashehu Date: Fri, 17 Jul 2026 12:42:51 +0200 Subject: [PATCH 5/6] simplify logic --- nf_core/components/components_utils.py | 27 +++++- nf_core/modules/lint/__init__.py | 12 +-- nf_core/modules/lint/environment_yml.py | 7 +- nf_core/modules/lint/meta_yml.py | 2 - nf_core/modules/modules_repo.py | 5 +- nf_core/modules/registry_client.py | 120 +++++++++++++++++++----- nf_core/subworkflows/lint/meta_yml.py | 6 +- 7 files changed, 135 insertions(+), 44 deletions(-) diff --git a/nf_core/components/components_utils.py b/nf_core/components/components_utils.py index 2fb4b91511..aaf714e7eb 100644 --- a/nf_core/components/components_utils.py +++ b/nf_core/components/components_utils.py @@ -1,3 +1,4 @@ +import json import logging import re from pathlib import Path @@ -9,6 +10,7 @@ import nf_core.utils from nf_core.modules.modules_repo import ModulesRepoType, get_modules_repo +from nf_core.modules.registry_client import RegistryClient log = logging.getLogger(__name__) @@ -24,6 +26,30 @@ yaml.width = 4096 +def load_schema_from_repo(modules_repo: "ModulesRepoType", relative_path: str) -> dict: + """ + Load a JSON schema shipped in the modules repository. + + Reads from the local git clone when available, otherwise fetches the file + over HTTP (registry mode). + + Raises: + LookupError: If the schema cannot be read or fetched. + """ + if isinstance(modules_repo, RegistryClient): + try: + return json.loads(modules_repo.get_file_content(relative_path)) + except requests.RequestException as e: + raise LookupError(f"Could not fetch '{relative_path}' from '{modules_repo.remote_url}': {e}") from e + if modules_repo.local_repo_dir is None: + raise LookupError(f"Local clone of the modules repo not found, cannot load '{relative_path}'") + try: + with open(Path(modules_repo.local_repo_dir, relative_path)) as fh: + return json.load(fh) + except FileNotFoundError as e: + raise LookupError(f"Could not find '{relative_path}' in '{modules_repo.local_repo_dir}'") from e + + def get_repo_info(directory: Path, use_prompt: bool | None = True) -> tuple[Path, str | None, str]: """ Determine whether this is a pipeline repository or a clone of @@ -36,7 +62,6 @@ def get_repo_info(directory: Path, use_prompt: bool | None = True) -> tuple[Path # Try to find the root directory base_dir: Path = nf_core.utils.determine_base_dir(directory) - print(f"base_dir: {base_dir}") # Figure out the repository type from the .nf-core.yml config file if we can config_fn, tools_config = nf_core.utils.load_tools_config(base_dir) diff --git a/nf_core/modules/lint/__init__.py b/nf_core/modules/lint/__init__.py index f21c28b2d5..9f57ced099 100644 --- a/nf_core/modules/lint/__init__.py +++ b/nf_core/modules/lint/__init__.py @@ -20,7 +20,7 @@ import nf_core.modules.modules_utils import nf_core.utils -from nf_core.components.components_utils import get_biotools_id, get_biotools_response, yaml +from nf_core.components.components_utils import get_biotools_id, get_biotools_response, load_schema_from_repo, yaml from nf_core.components.lint import ComponentLint, LintExceptionError, LintResult from nf_core.components.nfcore_component import NFCoreComponent from nf_core.pipelines.lint_utils import console, run_prettier_on_file @@ -285,24 +285,20 @@ def lint_module( def load_meta_schema(self) -> Mapping[str, Any]: """ - Load the meta.yml JSON schema from the local modules repository cache. + Load the meta.yml JSON schema from the modules repository (local clone or HTTP registry). The schema is cached in self.meta_schema to avoid reloading. Returns: dict: The meta.yml JSON schema Raises: - LookupError: If the local module cache is not found + LookupError: If the schema cannot be read or fetched """ # Return cached schema if already loaded if self.meta_schema is not None: return self.meta_schema - if self.modules_repo.local_repo_dir is None: - raise LookupError("Local module cache not found") - - with open(Path(self.modules_repo.local_repo_dir, "modules/meta-schema.json")) as fh: - self.meta_schema = json.load(fh) + self.meta_schema = load_schema_from_repo(self.modules_repo, "modules/meta-schema.json") return self.meta_schema def sort_meta_yml(self, meta_yml: dict) -> dict: diff --git a/nf_core/modules/lint/environment_yml.py b/nf_core/modules/lint/environment_yml.py index 8adfa38434..da162a2345 100644 --- a/nf_core/modules/lint/environment_yml.py +++ b/nf_core/modules/lint/environment_yml.py @@ -1,4 +1,3 @@ -import json import logging from pathlib import Path @@ -7,6 +6,7 @@ from rich.progress import Progress import nf_core.utils +from nf_core.components.components_utils import load_schema_from_repo from nf_core.components.lint import ComponentLint from nf_core.components.nfcore_component import NFCoreComponent from nf_core.modules.modules_utils import module_uses_dockerfile @@ -199,10 +199,7 @@ def lint_environment_yml( if env_yml: valid_env_yml = False try: - if module_lint_object.modules_repo.local_repo_dir is None: - raise FileNotFoundError("No local repo clone available (HTTP registry mode)") - with open(Path(module_lint_object.modules_repo.local_repo_dir, "modules/environment-schema.json")) as fh: - schema = json.load(fh) + schema = load_schema_from_repo(module_lint_object.modules_repo, "modules/environment-schema.json") validators.validate(instance=env_yml, schema=schema) module.passed.append( ( diff --git a/nf_core/modules/lint/meta_yml.py b/nf_core/modules/lint/meta_yml.py index 2f757b6451..6c5a0c36f4 100644 --- a/nf_core/modules/lint/meta_yml.py +++ b/nf_core/modules/lint/meta_yml.py @@ -182,8 +182,6 @@ def meta_yml(module_lint_object: ModuleLint, module: NFCoreComponent, allow_miss # Confirm that the meta.yml file is valid according to the JSON schema valid_meta_yml = False try: - if module_lint_object.modules_repo.local_repo_dir is None: - raise FileNotFoundError("No local repo clone available (HTTP registry mode)") schema = module_lint_object.load_meta_schema() validators.validate(instance=meta_yaml, schema=schema) module.passed.append(("meta_yml", "meta_yml_valid", "Module `meta.yml` is valid", module.meta_yml)) diff --git a/nf_core/modules/modules_repo.py b/nf_core/modules/modules_repo.py index 5819dac13c..3c369f0648 100644 --- a/nf_core/modules/modules_repo.py +++ b/nf_core/modules/modules_repo.py @@ -23,8 +23,9 @@ def get_modules_repo( no_pull: bool = False, hide_progress: bool = False, ) -> "ModulesRepoType": - """Return a RegistryClient for the default nf-core/modules remote, or a ModulesRepo for custom remotes.""" - if (remote_url is None or remote_url == NF_CORE_MODULES_REMOTE) and branch is None: + """Return a RegistryClient for the default nf-core/modules remote on its default branch, + or a git-backed ModulesRepo for custom remotes/branches.""" + if (remote_url is None or remote_url == NF_CORE_MODULES_REMOTE) and branch in (None, "master"): return RegistryClient() return ModulesRepo(remote_url=remote_url, branch=branch, no_pull=no_pull, hide_progress=hide_progress) diff --git a/nf_core/modules/registry_client.py b/nf_core/modules/registry_client.py index ae95e17b54..0f376e4d09 100644 --- a/nf_core/modules/registry_client.py +++ b/nf_core/modules/registry_client.py @@ -6,6 +6,7 @@ import json import logging +import shutil from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path @@ -37,6 +38,7 @@ def __init__(self) -> None: self.subworkflows_dir = None self._components: dict | None = None self._by_name: dict[str, dict] = {} + self._file_cache: dict[str, str] = {} @property def _cache_path(self) -> Path: @@ -54,16 +56,15 @@ def _fetch(self) -> bytes | None: try: resp = requests.get(COMPONENTS_JSON_URL, headers=headers, timeout=30) + if resp.status_code == 304: + return None + resp.raise_for_status() except requests.RequestException as e: if self._cache_path.exists(): log.warning(f"Could not reach {COMPONENTS_JSON_URL} ({e}), using cached components.json") return None raise LookupError(f"Could not fetch components.json and no local cache exists: {e}") from e - if resp.status_code == 304: - return None - - resp.raise_for_status() self._cache_path.parent.mkdir(parents=True, exist_ok=True) self._cache_path.write_bytes(resp.content) if etag := resp.headers.get("ETag"): @@ -105,17 +106,40 @@ def get_latest_component_version(self, component_name: str, component_type: str) entry = self._by_name.get(f"{component_type}/{component_name}") return entry.get("git_sha") if entry else None + def _list_component_files(self, component_name: str, component_type: str, commit: str) -> list[str]: + """List a component's files at a specific commit. + + Uses the file list from components.json when the commit is the registry's latest + for the component; otherwise queries the GitHub contents API at that ref, since + the file set may differ between commits. + """ + entry = self._by_name.get(f"{component_type}/{component_name}") + if entry and entry.get("git_sha") == commit and entry.get("files"): + return entry["files"] + + def _list_dir(dir_path: str) -> list[str]: + resp = gh_api.get(f"{GITHUB_API_BASE}/contents/{dir_path}", params={"ref": commit}) + resp.raise_for_status() + files: list[str] = [] + for item in resp.json(): + if item["type"] == "file": + files.append(item["path"]) + elif item["type"] == "dir": + files.extend(_list_dir(item["path"])) + return files + + return _list_dir(f"{component_type}/nf-core/{component_name}") + def install_component(self, component_name: str, install_dir: str | Path, commit: str, component_type: str) -> bool: """Fetch component files from raw.githubusercontent.com at the given commit SHA.""" self._load() - entry = self._by_name.get(f"{component_type}/{component_name}") - if not entry: - log.error(f"Could not find {component_type[:-1]} '{component_name}' in registry") + try: + files = self._list_component_files(component_name, component_type, commit) + except requests.RequestException as e: + log.error(f"Could not list files for '{component_name}' at {commit}: {e}") return False - - files: list[str] = entry.get("files", []) if not files: - log.error(f"No file list available for '{component_name}' in registry") + log.error(f"No files found for {component_type[:-1]} '{component_name}' at {commit}") return False install_path = Path(install_dir, component_name) @@ -131,20 +155,24 @@ def _download(file_path: str) -> None: try: with ThreadPoolExecutor(max_workers=4) as pool: - futures = {pool.submit(_download, f): f for f in files} - for future in as_completed(futures): + for future in as_completed([pool.submit(_download, f) for f in files]): future.result() except requests.RequestException as e: log.error(f"Failed to download files for '{component_name}' at {commit}: {e}") + # Don't leave a partially-downloaded component behind + shutil.rmtree(install_path, ignore_errors=True) return False return True def sha_exists_on_branch(self, sha: str) -> bool: - """Check whether a commit SHA exists in nf-core/modules via the GitHub API.""" + """Check whether a commit SHA is on the default branch of nf-core/modules via the GitHub API.""" try: - resp = gh_api.get(f"{GITHUB_API_BASE}/commits/{sha}") - return resp.status_code == 200 + resp = gh_api.get(f"{GITHUB_API_BASE}/compare/{self.branch}...{sha}", params={"per_page": 1}) + if resp.status_code != 200: + return False + # "identical"/"behind" means the SHA is an ancestor of (i.e. on) the branch + return resp.json().get("status") in ("identical", "behind") except requests.RequestException: return False @@ -158,24 +186,70 @@ def get_commit_info(self, sha: str) -> dict[str, str]: "date": data["commit"]["committer"]["date"], } + def get_file_content(self, path: str, commit: str | None = None) -> str: + """Fetch a single file from nf-core/modules via raw.githubusercontent.com, memoized per instance.""" + ref = commit or self.branch + key = f"{ref}:{path}" + if key not in self._file_cache: + resp = requests.get(GITHUB_RAW_BASE.format(sha=ref, path=path), timeout=30) + resp.raise_for_status() + self._file_cache[key] = resp.text + return self._file_cache[key] + def component_files_identical( - self, component_name: str | Path, base_path: str | Path, commit: str, component_type: str + self, component_name: str | Path, base_path: str | Path, commit: str | None, component_type: str ) -> dict[str, bool]: - """Not supported for HTTP registry — no local clone to compare against.""" - return {} + """ + Check whether local component files are identical to the remote ones at the given commit. + + Mirrors ``SyncedRepo.component_files_identical``: compares ``main.nf`` and ``meta.yml``, + and a file missing locally or remotely is skipped (left as identical). + """ + ref = commit or self.branch + component_files = ["main.nf", "meta.yml"] + files_identical = dict.fromkeys(component_files, True) + remote_dir = f"{component_type}/nf-core/{component_name}" + for file in component_files: + local_file = Path(base_path, file) + if not local_file.exists(): + log.debug(f"Could not open file: {local_file}") + continue + try: + resp = requests.get(GITHUB_RAW_BASE.format(sha=ref, path=f"{remote_dir}/{file}"), timeout=30) + if resp.status_code == 404: + log.debug(f"File not found on remote at {ref}: {remote_dir}/{file}") + continue + resp.raise_for_status() + except requests.RequestException as e: + # Report "not identical" rather than silently matching on network failure + log.warning(f"Could not fetch '{remote_dir}/{file}' at {ref} to compare: {e}") + files_identical[file] = False + continue + files_identical[file] = resp.content == local_file.read_bytes() + return files_identical def get_component_git_log( self, component_name: str | Path, component_type: str, depth: int | None = None ) -> list[dict[str, str]]: - """Fetch commit history via the GitHub API (used for interactive --sha selection).""" + """Fetch commit history via the GitHub API, paginated until exhausted or ``depth`` commits.""" dir_path = f"{component_type}/nf-core/{component_name}" + commits: list[dict[str, str]] = [] + page = 1 try: - resp = gh_api.get(f"{GITHUB_API_BASE}/commits", params={"path": dir_path, "per_page": depth or 30}) - resp.raise_for_status() - return [{"git_sha": c["sha"], "trunc_message": c["commit"]["message"]} for c in resp.json()] + while depth is None or len(commits) < depth: + resp = gh_api.get( + f"{GITHUB_API_BASE}/commits", + params={"path": dir_path, "sha": self.branch, "per_page": 100, "page": page}, + ) + resp.raise_for_status() + batch = resp.json() + commits.extend({"git_sha": c["sha"], "trunc_message": c["commit"]["message"]} for c in batch) + if len(batch) < 100: + break + page += 1 except requests.RequestException as e: log.error(f"Could not fetch git log for '{component_name}': {e}") - return [] + return commits[:depth] if depth is not None else commits def verify_sha(self, prompt: bool, sha: str | None) -> bool: """Validate --sha / --prompt combination and check SHA exists.""" diff --git a/nf_core/subworkflows/lint/meta_yml.py b/nf_core/subworkflows/lint/meta_yml.py index 480c2d672a..b068a90cd2 100644 --- a/nf_core/subworkflows/lint/meta_yml.py +++ b/nf_core/subworkflows/lint/meta_yml.py @@ -1,4 +1,3 @@ -import json import logging from pathlib import Path @@ -99,8 +98,9 @@ def meta_yml(subworkflow_lint_object, subworkflow, allow_missing: bool = False): # Confirm that the meta.yml file is valid according to the JSON schema valid_meta_yml = True try: - with open(Path(subworkflow_lint_object.modules_repo.local_repo_dir, "subworkflows/yaml-schema.json")) as fh: - schema = json.load(fh) + schema = nf_core.components.components_utils.load_schema_from_repo( + subworkflow_lint_object.modules_repo, "subworkflows/yaml-schema.json" + ) jsonschema.validators.validate(instance=meta_yaml, schema=schema) subworkflow.passed.append( ("meta_yml", "meta_yml_valid", "Subworkflow `meta.yml` is valid", subworkflow.meta_yml) From 493f7f396f879536995277e0e4c554e832081f94 Mon Sep 17 00:00:00 2001 From: mashehu Date: Fri, 17 Jul 2026 12:46:18 +0200 Subject: [PATCH 6/6] add unit test --- tests/modules/test_registry_client.py | 361 ++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 tests/modules/test_registry_client.py diff --git a/tests/modules/test_registry_client.py b/tests/modules/test_registry_client.py new file mode 100644 index 0000000000..f7fcea8379 --- /dev/null +++ b/tests/modules/test_registry_client.py @@ -0,0 +1,361 @@ +"""Tests for the HTTP-based registry client and the get_modules_repo backend dispatch.""" + +import json +from unittest import mock + +import pytest +import requests +import responses +import yaml + +import nf_core.modules.registry_client +from nf_core.components.constants import NF_CORE_MODULES_REMOTE +from nf_core.modules.modules_repo import get_modules_repo +from nf_core.modules.registry_client import COMPONENTS_JSON_URL, GITHUB_RAW_BASE, RegistryClient + +LATEST_SHA = "1234567890abcdef1234567890abcdef12345678" +OLD_SHA = "fedcba0987654321fedcba0987654321fedcba09" + +COMPONENTS = { + "modules": [ + { + "path": "modules/nf-core/samtools/index/meta.yml", + "git_sha": LATEST_SHA, + "files": [ + "modules/nf-core/samtools/index/main.nf", + "modules/nf-core/samtools/index/meta.yml", + "modules/nf-core/samtools/index/tests/main.nf.test", + ], + "meta": {"name": "samtools_index"}, + }, + { + "path": "modules/nf-core/fastqc/meta.yml", + "git_sha": LATEST_SHA, + "files": ["modules/nf-core/fastqc/main.nf", "modules/nf-core/fastqc/meta.yml"], + "meta": {"name": "fastqc"}, + }, + ], + "subworkflows": [ + { + "path": "subworkflows/nf-core/bam_sort_stats_samtools/meta.yml", + "git_sha": LATEST_SHA, + "files": ["subworkflows/nf-core/bam_sort_stats_samtools/main.nf"], + "meta": {"name": "bam_sort_stats_samtools"}, + } + ], +} + + +@pytest.fixture +def registry(monkeypatch, tmp_path): + """A RegistryClient with its on-disk cache redirected to a temporary directory.""" + monkeypatch.setattr(nf_core.modules.registry_client, "NFCORE_CACHE_DIR", tmp_path) + return RegistryClient() + + +def add_components_json(rsps, payload=COMPONENTS, **kwargs): + rsps.add(responses.GET, COMPONENTS_JSON_URL, json=payload, **kwargs) + + +def raw_url(sha, path): + return GITHUB_RAW_BASE.format(sha=sha, path=path) + + +def mock_gh_response(json_data, status_code=200): + resp = mock.Mock() + resp.status_code = status_code + resp.json.return_value = json_data + if status_code >= 400: + resp.raise_for_status.side_effect = requests.HTTPError(f"{status_code} error") + else: + resp.raise_for_status.return_value = None + return resp + + +# get_modules_repo backend dispatch + + +@pytest.mark.parametrize( + "remote_url,branch", + [ + (None, None), + (NF_CORE_MODULES_REMOTE, None), + (None, "master"), + (NF_CORE_MODULES_REMOTE, "master"), + ], +) +def test_get_modules_repo_registry_for_default_remote_and_branch(remote_url, branch): + """The default nf-core/modules remote on its default branch uses the HTTP registry.""" + assert isinstance(get_modules_repo(remote_url, branch), RegistryClient) + + +@pytest.mark.parametrize( + "remote_url,branch", + [ + (NF_CORE_MODULES_REMOTE, "dev"), + ("https://github.com/other/modules.git", None), + ("https://github.com/other/modules.git", "master"), + ], +) +def test_get_modules_repo_git_backend_for_custom_remote_or_branch(remote_url, branch): + """Custom remotes or branches fall back to the git-backed ModulesRepo.""" + with mock.patch("nf_core.modules.modules_repo.ModulesRepo") as mock_repo: + get_modules_repo(remote_url, branch) + mock_repo.assert_called_once_with(remote_url=remote_url, branch=branch, no_pull=False, hide_progress=False) + + +# components.json fetching and caching + + +def test_path_to_name(): + assert RegistryClient._path_to_name("modules/nf-core/samtools/index/meta.yml", "modules") == "samtools/index" + assert RegistryClient._path_to_name("modules/nf-core/fastqc/meta.yml", "modules") == "fastqc" + assert ( + RegistryClient._path_to_name("subworkflows/nf-core/bam_sort_stats_samtools/meta.yml", "subworkflows") + == "bam_sort_stats_samtools" + ) + + +def test_load_fetches_and_writes_cache(registry): + with responses.RequestsMock() as rsps: + add_components_json(rsps, headers={"ETag": 'W/"etag123"'}) + assert registry.get_avail_components("modules") == ["samtools/index", "fastqc"] + assert registry.get_avail_components("subworkflows") == ["bam_sort_stats_samtools"] + # No conditional header on a cold cache + assert "If-None-Match" not in rsps.calls[0].request.headers + assert json.loads(registry._cache_path.read_text()) == COMPONENTS + assert registry._etag_path.read_text() == 'W/"etag123"' + + +def test_load_uses_cache_on_304(registry): + registry._cache_path.parent.mkdir(parents=True, exist_ok=True) + registry._cache_path.write_text(json.dumps(COMPONENTS)) + registry._etag_path.write_text('W/"etag123"') + with responses.RequestsMock() as rsps: + add_components_json(rsps, payload=None, status=304) + assert registry.component_exists("samtools/index", "modules") + assert rsps.calls[0].request.headers["If-None-Match"] == 'W/"etag123"' + + +@pytest.mark.parametrize("failure", ["connection_error", "server_error"]) +def test_load_falls_back_to_cache_on_fetch_failure(registry, caplog, failure): + """Both connection errors and HTTP errors fall back to a valid cached components.json.""" + registry._cache_path.parent.mkdir(parents=True, exist_ok=True) + registry._cache_path.write_text(json.dumps(COMPONENTS)) + with responses.RequestsMock() as rsps: + if failure == "connection_error": + rsps.add(responses.GET, COMPONENTS_JSON_URL, body=requests.exceptions.ConnectionError("boom")) + else: + add_components_json(rsps, payload=None, status=500) + assert registry.component_exists("fastqc", "modules") + assert "using cached components.json" in caplog.text + + +@pytest.mark.parametrize("failure", ["connection_error", "server_error"]) +def test_load_raises_without_cache(registry, failure): + with responses.RequestsMock() as rsps: + if failure == "connection_error": + rsps.add(responses.GET, COMPONENTS_JSON_URL, body=requests.exceptions.ConnectionError("boom")) + else: + add_components_json(rsps, payload=None, status=500) + with pytest.raises(LookupError, match="no local cache"): + registry.get_avail_components("modules") + + +# registry lookups + + +def test_component_exists_and_latest_version(registry): + with responses.RequestsMock() as rsps: + add_components_json(rsps) + assert registry.component_exists("samtools/index", "modules") + assert not registry.component_exists("samtools/index", "subworkflows") + assert not registry.component_exists("nonexistent", "modules") + assert registry.get_latest_component_version("fastqc", "modules") == LATEST_SHA + assert registry.get_latest_component_version("nonexistent", "modules") is None + + +def test_get_meta_yml(registry): + with responses.RequestsMock() as rsps: + add_components_json(rsps) + meta = registry.get_meta_yml("modules", "fastqc") + assert yaml.safe_load(meta) == {"name": "fastqc"} + assert registry.get_meta_yml("modules", "nonexistent") is None + + +# component installation + + +def test_install_component_at_latest_sha(registry, tmp_path): + """At the registry's latest SHA the file list from components.json is used directly.""" + install_dir = tmp_path / "install" + with responses.RequestsMock() as rsps: + add_components_json(rsps) + for file_path in COMPONENTS["modules"][0]["files"]: + rsps.add(responses.GET, raw_url(LATEST_SHA, file_path), body=f"content of {file_path}") + assert registry.install_component("samtools/index", install_dir, LATEST_SHA, "modules") + module_dir = install_dir / "samtools/index" + assert (module_dir / "main.nf").read_text() == "content of modules/nf-core/samtools/index/main.nf" + assert (module_dir / "meta.yml").is_file() + assert (module_dir / "tests/main.nf.test").is_file() + + +def test_install_component_at_old_sha_lists_files_at_ref(registry, tmp_path): + """At an older SHA the file list must come from the GitHub contents API at that ref, + since the file set may differ from the registry's latest.""" + install_dir = tmp_path / "install" + contents = [{"type": "file", "path": "modules/nf-core/samtools/index/main.nf"}] + with ( + responses.RequestsMock() as rsps, + mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh, + ): + add_components_json(rsps) + mock_gh.get.return_value = mock_gh_response(contents) + rsps.add(responses.GET, raw_url(OLD_SHA, "modules/nf-core/samtools/index/main.nf"), body="old main.nf") + assert registry.install_component("samtools/index", install_dir, OLD_SHA, "modules") + assert mock_gh.get.call_args.kwargs["params"] == {"ref": OLD_SHA} + module_dir = install_dir / "samtools/index" + assert (module_dir / "main.nf").read_text() == "old main.nf" + # meta.yml is in the latest file list but did not exist at the old SHA + assert not (module_dir / "meta.yml").exists() + + +def test_install_component_recurses_into_directories(registry, tmp_path): + install_dir = tmp_path / "install" + listings = { + "modules/nf-core/samtools/index": [ + {"type": "file", "path": "modules/nf-core/samtools/index/main.nf"}, + {"type": "dir", "path": "modules/nf-core/samtools/index/tests"}, + ], + "modules/nf-core/samtools/index/tests": [ + {"type": "file", "path": "modules/nf-core/samtools/index/tests/main.nf.test"}, + ], + } + with ( + responses.RequestsMock() as rsps, + mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh, + ): + add_components_json(rsps) + mock_gh.get.side_effect = lambda url, params=None: mock_gh_response(listings[url.split("/contents/")[1]]) + for file_path in ( + "modules/nf-core/samtools/index/main.nf", + "modules/nf-core/samtools/index/tests/main.nf.test", + ): + rsps.add(responses.GET, raw_url(OLD_SHA, file_path), body="content") + assert registry.install_component("samtools/index", install_dir, OLD_SHA, "modules") + assert (install_dir / "samtools/index/tests/main.nf.test").is_file() + + +def test_install_component_cleans_up_on_failure(registry, tmp_path): + """A failed download must not leave a partially-installed component behind.""" + install_dir = tmp_path / "install" + with responses.RequestsMock() as rsps: + add_components_json(rsps) + files = COMPONENTS["modules"][1]["files"] + rsps.add(responses.GET, raw_url(LATEST_SHA, files[0]), body="content") + rsps.add(responses.GET, raw_url(LATEST_SHA, files[1]), status=500) + assert not registry.install_component("fastqc", install_dir, LATEST_SHA, "modules") + assert not (install_dir / "fastqc").exists() + + +def test_install_component_unknown_at_latest(registry, tmp_path): + """A component the contents API can't list either reports failure.""" + with ( + responses.RequestsMock() as rsps, + mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh, + ): + add_components_json(rsps) + mock_gh.get.return_value = mock_gh_response({"message": "Not Found"}, status_code=404) + assert not registry.install_component("nonexistent", tmp_path, LATEST_SHA, "modules") + + +# file comparison + + +def test_component_files_identical(registry, tmp_path): + (tmp_path / "main.nf").write_text("process FOO {}") + (tmp_path / "meta.yml").write_text("name: foo") + with responses.RequestsMock() as rsps: + rsps.add(responses.GET, raw_url(LATEST_SHA, "modules/nf-core/foo/main.nf"), body="process FOO {}") + rsps.add(responses.GET, raw_url(LATEST_SHA, "modules/nf-core/foo/meta.yml"), body="name: bar") + result = registry.component_files_identical("foo", tmp_path, LATEST_SHA, "modules") + assert result == {"main.nf": True, "meta.yml": False} + + +def test_component_files_identical_skips_missing_files(registry, tmp_path): + """Files missing locally or on the remote are skipped (left as identical), mirroring SyncedRepo.""" + (tmp_path / "main.nf").write_text("process FOO {}") + # meta.yml missing locally; main.nf missing on the remote at this commit + with responses.RequestsMock() as rsps: + rsps.add(responses.GET, raw_url(OLD_SHA, "modules/nf-core/foo/main.nf"), status=404) + result = registry.component_files_identical("foo", tmp_path, OLD_SHA, "modules") + assert result == {"main.nf": True, "meta.yml": True} + + +def test_component_files_identical_network_failure_is_not_identical(registry, tmp_path, caplog): + """A network failure must report 'not identical' instead of silently matching.""" + (tmp_path / "main.nf").write_text("process FOO {}") + with responses.RequestsMock() as rsps: + rsps.add( + responses.GET, + raw_url(LATEST_SHA, "modules/nf-core/foo/main.nf"), + body=requests.exceptions.ConnectionError("boom"), + ) + result = registry.component_files_identical("foo", tmp_path, LATEST_SHA, "modules") + assert result["main.nf"] is False + assert "Could not fetch" in caplog.text + + +# git log and SHA checks via the GitHub API + + +def test_get_component_git_log_paginates(): + registry = RegistryClient() + page1 = [{"sha": f"sha{i}", "commit": {"message": f"commit {i}"}} for i in range(100)] + page2 = [{"sha": f"sha{i}", "commit": {"message": f"commit {i}"}} for i in range(100, 130)] + with mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh: + mock_gh.get.side_effect = [mock_gh_response(page1), mock_gh_response(page2)] + commits = registry.get_component_git_log("fastqc", "modules") + assert len(commits) == 130 + assert commits[0] == {"git_sha": "sha0", "trunc_message": "commit 0"} + pages = [call.kwargs["params"]["page"] for call in mock_gh.get.call_args_list] + assert pages == [1, 2] + + +def test_get_component_git_log_respects_depth(): + registry = RegistryClient() + page1 = [{"sha": f"sha{i}", "commit": {"message": f"commit {i}"}} for i in range(100)] + with mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh: + mock_gh.get.return_value = mock_gh_response(page1) + commits = registry.get_component_git_log("fastqc", "modules", depth=5) + assert len(commits) == 5 + assert mock_gh.get.call_count == 1 + + +@pytest.mark.parametrize( + "status,compare_status,expected", + [ + (200, "behind", True), + (200, "identical", True), + (200, "ahead", False), + (200, "diverged", False), + (404, None, False), + ], +) +def test_sha_exists_on_branch(status, compare_status, expected): + registry = RegistryClient() + with mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh: + mock_gh.get.return_value = mock_gh_response({"status": compare_status}, status_code=status) + assert registry.sha_exists_on_branch(LATEST_SHA) is expected + + +# single-file fetching (used for schema loading in lint) + + +def test_get_file_content_is_memoized(registry): + url = raw_url("master", "modules/meta-schema.json") + with responses.RequestsMock() as rsps: + rsps.add(responses.GET, url, body='{"type": "object"}') + assert registry.get_file_content("modules/meta-schema.json") == '{"type": "object"}' + assert registry.get_file_content("modules/meta-schema.json") == '{"type": "object"}' + assert len(rsps.calls) == 1