Skip to content

Commit 7baab82

Browse files
committed
add unit test
1 parent 9d55b1e commit 7baab82

1 file changed

Lines changed: 361 additions & 0 deletions

File tree

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
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

Comments
 (0)