|
| 1 | +"""Tests for the HTTP-based registry client and the get_modules_repo backend dispatch.""" |
| 2 | + |
| 3 | +import json |
| 4 | +from unittest import mock |
| 5 | + |
| 6 | +import pytest |
| 7 | +import requests |
| 8 | +import responses |
| 9 | +import yaml |
| 10 | + |
| 11 | +import nf_core.modules.registry_client |
| 12 | +from nf_core.components.constants import NF_CORE_MODULES_REMOTE |
| 13 | +from nf_core.modules.modules_repo import get_modules_repo |
| 14 | +from nf_core.modules.registry_client import COMPONENTS_JSON_URL, GITHUB_RAW_BASE, RegistryClient |
| 15 | + |
| 16 | +LATEST_SHA = "1234567890abcdef1234567890abcdef12345678" |
| 17 | +OLD_SHA = "fedcba0987654321fedcba0987654321fedcba09" |
| 18 | + |
| 19 | +COMPONENTS = { |
| 20 | + "modules": [ |
| 21 | + { |
| 22 | + "path": "modules/nf-core/samtools/index/meta.yml", |
| 23 | + "git_sha": LATEST_SHA, |
| 24 | + "files": [ |
| 25 | + "modules/nf-core/samtools/index/main.nf", |
| 26 | + "modules/nf-core/samtools/index/meta.yml", |
| 27 | + "modules/nf-core/samtools/index/tests/main.nf.test", |
| 28 | + ], |
| 29 | + "meta": {"name": "samtools_index"}, |
| 30 | + }, |
| 31 | + { |
| 32 | + "path": "modules/nf-core/fastqc/meta.yml", |
| 33 | + "git_sha": LATEST_SHA, |
| 34 | + "files": ["modules/nf-core/fastqc/main.nf", "modules/nf-core/fastqc/meta.yml"], |
| 35 | + "meta": {"name": "fastqc"}, |
| 36 | + }, |
| 37 | + ], |
| 38 | + "subworkflows": [ |
| 39 | + { |
| 40 | + "path": "subworkflows/nf-core/bam_sort_stats_samtools/meta.yml", |
| 41 | + "git_sha": LATEST_SHA, |
| 42 | + "files": ["subworkflows/nf-core/bam_sort_stats_samtools/main.nf"], |
| 43 | + "meta": {"name": "bam_sort_stats_samtools"}, |
| 44 | + } |
| 45 | + ], |
| 46 | +} |
| 47 | + |
| 48 | + |
| 49 | +@pytest.fixture |
| 50 | +def registry(monkeypatch, tmp_path): |
| 51 | + """A RegistryClient with its on-disk cache redirected to a temporary directory.""" |
| 52 | + monkeypatch.setattr(nf_core.modules.registry_client, "NFCORE_CACHE_DIR", tmp_path) |
| 53 | + return RegistryClient() |
| 54 | + |
| 55 | + |
| 56 | +def add_components_json(rsps, payload=COMPONENTS, **kwargs): |
| 57 | + rsps.add(responses.GET, COMPONENTS_JSON_URL, json=payload, **kwargs) |
| 58 | + |
| 59 | + |
| 60 | +def raw_url(sha, path): |
| 61 | + return GITHUB_RAW_BASE.format(sha=sha, path=path) |
| 62 | + |
| 63 | + |
| 64 | +def mock_gh_response(json_data, status_code=200): |
| 65 | + resp = mock.Mock() |
| 66 | + resp.status_code = status_code |
| 67 | + resp.json.return_value = json_data |
| 68 | + if status_code >= 400: |
| 69 | + resp.raise_for_status.side_effect = requests.HTTPError(f"{status_code} error") |
| 70 | + else: |
| 71 | + resp.raise_for_status.return_value = None |
| 72 | + return resp |
| 73 | + |
| 74 | + |
| 75 | +# get_modules_repo backend dispatch |
| 76 | + |
| 77 | + |
| 78 | +@pytest.mark.parametrize( |
| 79 | + "remote_url,branch", |
| 80 | + [ |
| 81 | + (None, None), |
| 82 | + (NF_CORE_MODULES_REMOTE, None), |
| 83 | + (None, "master"), |
| 84 | + (NF_CORE_MODULES_REMOTE, "master"), |
| 85 | + ], |
| 86 | +) |
| 87 | +def test_get_modules_repo_registry_for_default_remote_and_branch(remote_url, branch): |
| 88 | + """The default nf-core/modules remote on its default branch uses the HTTP registry.""" |
| 89 | + assert isinstance(get_modules_repo(remote_url, branch), RegistryClient) |
| 90 | + |
| 91 | + |
| 92 | +@pytest.mark.parametrize( |
| 93 | + "remote_url,branch", |
| 94 | + [ |
| 95 | + (NF_CORE_MODULES_REMOTE, "dev"), |
| 96 | + ("https://github.com/other/modules.git", None), |
| 97 | + ("https://github.com/other/modules.git", "master"), |
| 98 | + ], |
| 99 | +) |
| 100 | +def test_get_modules_repo_git_backend_for_custom_remote_or_branch(remote_url, branch): |
| 101 | + """Custom remotes or branches fall back to the git-backed ModulesRepo.""" |
| 102 | + with mock.patch("nf_core.modules.modules_repo.ModulesRepo") as mock_repo: |
| 103 | + get_modules_repo(remote_url, branch) |
| 104 | + mock_repo.assert_called_once_with(remote_url=remote_url, branch=branch, no_pull=False, hide_progress=False) |
| 105 | + |
| 106 | + |
| 107 | +# components.json fetching and caching |
| 108 | + |
| 109 | + |
| 110 | +def test_path_to_name(): |
| 111 | + assert RegistryClient._path_to_name("modules/nf-core/samtools/index/meta.yml", "modules") == "samtools/index" |
| 112 | + assert RegistryClient._path_to_name("modules/nf-core/fastqc/meta.yml", "modules") == "fastqc" |
| 113 | + assert ( |
| 114 | + RegistryClient._path_to_name("subworkflows/nf-core/bam_sort_stats_samtools/meta.yml", "subworkflows") |
| 115 | + == "bam_sort_stats_samtools" |
| 116 | + ) |
| 117 | + |
| 118 | + |
| 119 | +def test_load_fetches_and_writes_cache(registry): |
| 120 | + with responses.RequestsMock() as rsps: |
| 121 | + add_components_json(rsps, headers={"ETag": 'W/"etag123"'}) |
| 122 | + assert registry.get_avail_components("modules") == ["samtools/index", "fastqc"] |
| 123 | + assert registry.get_avail_components("subworkflows") == ["bam_sort_stats_samtools"] |
| 124 | + # No conditional header on a cold cache |
| 125 | + assert "If-None-Match" not in rsps.calls[0].request.headers |
| 126 | + assert json.loads(registry._cache_path.read_text()) == COMPONENTS |
| 127 | + assert registry._etag_path.read_text() == 'W/"etag123"' |
| 128 | + |
| 129 | + |
| 130 | +def test_load_uses_cache_on_304(registry): |
| 131 | + registry._cache_path.parent.mkdir(parents=True, exist_ok=True) |
| 132 | + registry._cache_path.write_text(json.dumps(COMPONENTS)) |
| 133 | + registry._etag_path.write_text('W/"etag123"') |
| 134 | + with responses.RequestsMock() as rsps: |
| 135 | + add_components_json(rsps, payload=None, status=304) |
| 136 | + assert registry.component_exists("samtools/index", "modules") |
| 137 | + assert rsps.calls[0].request.headers["If-None-Match"] == 'W/"etag123"' |
| 138 | + |
| 139 | + |
| 140 | +@pytest.mark.parametrize("failure", ["connection_error", "server_error"]) |
| 141 | +def test_load_falls_back_to_cache_on_fetch_failure(registry, caplog, failure): |
| 142 | + """Both connection errors and HTTP errors fall back to a valid cached components.json.""" |
| 143 | + registry._cache_path.parent.mkdir(parents=True, exist_ok=True) |
| 144 | + registry._cache_path.write_text(json.dumps(COMPONENTS)) |
| 145 | + with responses.RequestsMock() as rsps: |
| 146 | + if failure == "connection_error": |
| 147 | + rsps.add(responses.GET, COMPONENTS_JSON_URL, body=requests.exceptions.ConnectionError("boom")) |
| 148 | + else: |
| 149 | + add_components_json(rsps, payload=None, status=500) |
| 150 | + assert registry.component_exists("fastqc", "modules") |
| 151 | + assert "using cached components.json" in caplog.text |
| 152 | + |
| 153 | + |
| 154 | +@pytest.mark.parametrize("failure", ["connection_error", "server_error"]) |
| 155 | +def test_load_raises_without_cache(registry, failure): |
| 156 | + with responses.RequestsMock() as rsps: |
| 157 | + if failure == "connection_error": |
| 158 | + rsps.add(responses.GET, COMPONENTS_JSON_URL, body=requests.exceptions.ConnectionError("boom")) |
| 159 | + else: |
| 160 | + add_components_json(rsps, payload=None, status=500) |
| 161 | + with pytest.raises(LookupError, match="no local cache"): |
| 162 | + registry.get_avail_components("modules") |
| 163 | + |
| 164 | + |
| 165 | +# registry lookups |
| 166 | + |
| 167 | + |
| 168 | +def test_component_exists_and_latest_version(registry): |
| 169 | + with responses.RequestsMock() as rsps: |
| 170 | + add_components_json(rsps) |
| 171 | + assert registry.component_exists("samtools/index", "modules") |
| 172 | + assert not registry.component_exists("samtools/index", "subworkflows") |
| 173 | + assert not registry.component_exists("nonexistent", "modules") |
| 174 | + assert registry.get_latest_component_version("fastqc", "modules") == LATEST_SHA |
| 175 | + assert registry.get_latest_component_version("nonexistent", "modules") is None |
| 176 | + |
| 177 | + |
| 178 | +def test_get_meta_yml(registry): |
| 179 | + with responses.RequestsMock() as rsps: |
| 180 | + add_components_json(rsps) |
| 181 | + meta = registry.get_meta_yml("modules", "fastqc") |
| 182 | + assert yaml.safe_load(meta) == {"name": "fastqc"} |
| 183 | + assert registry.get_meta_yml("modules", "nonexistent") is None |
| 184 | + |
| 185 | + |
| 186 | +# component installation |
| 187 | + |
| 188 | + |
| 189 | +def test_install_component_at_latest_sha(registry, tmp_path): |
| 190 | + """At the registry's latest SHA the file list from components.json is used directly.""" |
| 191 | + install_dir = tmp_path / "install" |
| 192 | + with responses.RequestsMock() as rsps: |
| 193 | + add_components_json(rsps) |
| 194 | + for file_path in COMPONENTS["modules"][0]["files"]: |
| 195 | + rsps.add(responses.GET, raw_url(LATEST_SHA, file_path), body=f"content of {file_path}") |
| 196 | + assert registry.install_component("samtools/index", install_dir, LATEST_SHA, "modules") |
| 197 | + module_dir = install_dir / "samtools/index" |
| 198 | + assert (module_dir / "main.nf").read_text() == "content of modules/nf-core/samtools/index/main.nf" |
| 199 | + assert (module_dir / "meta.yml").is_file() |
| 200 | + assert (module_dir / "tests/main.nf.test").is_file() |
| 201 | + |
| 202 | + |
| 203 | +def test_install_component_at_old_sha_lists_files_at_ref(registry, tmp_path): |
| 204 | + """At an older SHA the file list must come from the GitHub contents API at that ref, |
| 205 | + since the file set may differ from the registry's latest.""" |
| 206 | + install_dir = tmp_path / "install" |
| 207 | + contents = [{"type": "file", "path": "modules/nf-core/samtools/index/main.nf"}] |
| 208 | + with ( |
| 209 | + responses.RequestsMock() as rsps, |
| 210 | + mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh, |
| 211 | + ): |
| 212 | + add_components_json(rsps) |
| 213 | + mock_gh.get.return_value = mock_gh_response(contents) |
| 214 | + rsps.add(responses.GET, raw_url(OLD_SHA, "modules/nf-core/samtools/index/main.nf"), body="old main.nf") |
| 215 | + assert registry.install_component("samtools/index", install_dir, OLD_SHA, "modules") |
| 216 | + assert mock_gh.get.call_args.kwargs["params"] == {"ref": OLD_SHA} |
| 217 | + module_dir = install_dir / "samtools/index" |
| 218 | + assert (module_dir / "main.nf").read_text() == "old main.nf" |
| 219 | + # meta.yml is in the latest file list but did not exist at the old SHA |
| 220 | + assert not (module_dir / "meta.yml").exists() |
| 221 | + |
| 222 | + |
| 223 | +def test_install_component_recurses_into_directories(registry, tmp_path): |
| 224 | + install_dir = tmp_path / "install" |
| 225 | + listings = { |
| 226 | + "modules/nf-core/samtools/index": [ |
| 227 | + {"type": "file", "path": "modules/nf-core/samtools/index/main.nf"}, |
| 228 | + {"type": "dir", "path": "modules/nf-core/samtools/index/tests"}, |
| 229 | + ], |
| 230 | + "modules/nf-core/samtools/index/tests": [ |
| 231 | + {"type": "file", "path": "modules/nf-core/samtools/index/tests/main.nf.test"}, |
| 232 | + ], |
| 233 | + } |
| 234 | + with ( |
| 235 | + responses.RequestsMock() as rsps, |
| 236 | + mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh, |
| 237 | + ): |
| 238 | + add_components_json(rsps) |
| 239 | + mock_gh.get.side_effect = lambda url, params=None: mock_gh_response(listings[url.split("/contents/")[1]]) |
| 240 | + for file_path in ( |
| 241 | + "modules/nf-core/samtools/index/main.nf", |
| 242 | + "modules/nf-core/samtools/index/tests/main.nf.test", |
| 243 | + ): |
| 244 | + rsps.add(responses.GET, raw_url(OLD_SHA, file_path), body="content") |
| 245 | + assert registry.install_component("samtools/index", install_dir, OLD_SHA, "modules") |
| 246 | + assert (install_dir / "samtools/index/tests/main.nf.test").is_file() |
| 247 | + |
| 248 | + |
| 249 | +def test_install_component_cleans_up_on_failure(registry, tmp_path): |
| 250 | + """A failed download must not leave a partially-installed component behind.""" |
| 251 | + install_dir = tmp_path / "install" |
| 252 | + with responses.RequestsMock() as rsps: |
| 253 | + add_components_json(rsps) |
| 254 | + files = COMPONENTS["modules"][1]["files"] |
| 255 | + rsps.add(responses.GET, raw_url(LATEST_SHA, files[0]), body="content") |
| 256 | + rsps.add(responses.GET, raw_url(LATEST_SHA, files[1]), status=500) |
| 257 | + assert not registry.install_component("fastqc", install_dir, LATEST_SHA, "modules") |
| 258 | + assert not (install_dir / "fastqc").exists() |
| 259 | + |
| 260 | + |
| 261 | +def test_install_component_unknown_at_latest(registry, tmp_path): |
| 262 | + """A component the contents API can't list either reports failure.""" |
| 263 | + with ( |
| 264 | + responses.RequestsMock() as rsps, |
| 265 | + mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh, |
| 266 | + ): |
| 267 | + add_components_json(rsps) |
| 268 | + mock_gh.get.return_value = mock_gh_response({"message": "Not Found"}, status_code=404) |
| 269 | + assert not registry.install_component("nonexistent", tmp_path, LATEST_SHA, "modules") |
| 270 | + |
| 271 | + |
| 272 | +# file comparison |
| 273 | + |
| 274 | + |
| 275 | +def test_component_files_identical(registry, tmp_path): |
| 276 | + (tmp_path / "main.nf").write_text("process FOO {}") |
| 277 | + (tmp_path / "meta.yml").write_text("name: foo") |
| 278 | + with responses.RequestsMock() as rsps: |
| 279 | + rsps.add(responses.GET, raw_url(LATEST_SHA, "modules/nf-core/foo/main.nf"), body="process FOO {}") |
| 280 | + rsps.add(responses.GET, raw_url(LATEST_SHA, "modules/nf-core/foo/meta.yml"), body="name: bar") |
| 281 | + result = registry.component_files_identical("foo", tmp_path, LATEST_SHA, "modules") |
| 282 | + assert result == {"main.nf": True, "meta.yml": False} |
| 283 | + |
| 284 | + |
| 285 | +def test_component_files_identical_skips_missing_files(registry, tmp_path): |
| 286 | + """Files missing locally or on the remote are skipped (left as identical), mirroring SyncedRepo.""" |
| 287 | + (tmp_path / "main.nf").write_text("process FOO {}") |
| 288 | + # meta.yml missing locally; main.nf missing on the remote at this commit |
| 289 | + with responses.RequestsMock() as rsps: |
| 290 | + rsps.add(responses.GET, raw_url(OLD_SHA, "modules/nf-core/foo/main.nf"), status=404) |
| 291 | + result = registry.component_files_identical("foo", tmp_path, OLD_SHA, "modules") |
| 292 | + assert result == {"main.nf": True, "meta.yml": True} |
| 293 | + |
| 294 | + |
| 295 | +def test_component_files_identical_network_failure_is_not_identical(registry, tmp_path, caplog): |
| 296 | + """A network failure must report 'not identical' instead of silently matching.""" |
| 297 | + (tmp_path / "main.nf").write_text("process FOO {}") |
| 298 | + with responses.RequestsMock() as rsps: |
| 299 | + rsps.add( |
| 300 | + responses.GET, |
| 301 | + raw_url(LATEST_SHA, "modules/nf-core/foo/main.nf"), |
| 302 | + body=requests.exceptions.ConnectionError("boom"), |
| 303 | + ) |
| 304 | + result = registry.component_files_identical("foo", tmp_path, LATEST_SHA, "modules") |
| 305 | + assert result["main.nf"] is False |
| 306 | + assert "Could not fetch" in caplog.text |
| 307 | + |
| 308 | + |
| 309 | +# git log and SHA checks via the GitHub API |
| 310 | + |
| 311 | + |
| 312 | +def test_get_component_git_log_paginates(): |
| 313 | + registry = RegistryClient() |
| 314 | + page1 = [{"sha": f"sha{i}", "commit": {"message": f"commit {i}"}} for i in range(100)] |
| 315 | + page2 = [{"sha": f"sha{i}", "commit": {"message": f"commit {i}"}} for i in range(100, 130)] |
| 316 | + with mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh: |
| 317 | + mock_gh.get.side_effect = [mock_gh_response(page1), mock_gh_response(page2)] |
| 318 | + commits = registry.get_component_git_log("fastqc", "modules") |
| 319 | + assert len(commits) == 130 |
| 320 | + assert commits[0] == {"git_sha": "sha0", "trunc_message": "commit 0"} |
| 321 | + pages = [call.kwargs["params"]["page"] for call in mock_gh.get.call_args_list] |
| 322 | + assert pages == [1, 2] |
| 323 | + |
| 324 | + |
| 325 | +def test_get_component_git_log_respects_depth(): |
| 326 | + registry = RegistryClient() |
| 327 | + page1 = [{"sha": f"sha{i}", "commit": {"message": f"commit {i}"}} for i in range(100)] |
| 328 | + with mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh: |
| 329 | + mock_gh.get.return_value = mock_gh_response(page1) |
| 330 | + commits = registry.get_component_git_log("fastqc", "modules", depth=5) |
| 331 | + assert len(commits) == 5 |
| 332 | + assert mock_gh.get.call_count == 1 |
| 333 | + |
| 334 | + |
| 335 | +@pytest.mark.parametrize( |
| 336 | + "status,compare_status,expected", |
| 337 | + [ |
| 338 | + (200, "behind", True), |
| 339 | + (200, "identical", True), |
| 340 | + (200, "ahead", False), |
| 341 | + (200, "diverged", False), |
| 342 | + (404, None, False), |
| 343 | + ], |
| 344 | +) |
| 345 | +def test_sha_exists_on_branch(status, compare_status, expected): |
| 346 | + registry = RegistryClient() |
| 347 | + with mock.patch("nf_core.modules.registry_client.gh_api") as mock_gh: |
| 348 | + mock_gh.get.return_value = mock_gh_response({"status": compare_status}, status_code=status) |
| 349 | + assert registry.sha_exists_on_branch(LATEST_SHA) is expected |
| 350 | + |
| 351 | + |
| 352 | +# single-file fetching (used for schema loading in lint) |
| 353 | + |
| 354 | + |
| 355 | +def test_get_file_content_is_memoized(registry): |
| 356 | + url = raw_url("master", "modules/meta-schema.json") |
| 357 | + with responses.RequestsMock() as rsps: |
| 358 | + rsps.add(responses.GET, url, body='{"type": "object"}') |
| 359 | + assert registry.get_file_content("modules/meta-schema.json") == '{"type": "object"}' |
| 360 | + assert registry.get_file_content("modules/meta-schema.json") == '{"type": "object"}' |
| 361 | + assert len(rsps.calls) == 1 |
0 commit comments