Skip to content

Commit 2bc29fd

Browse files
authored
Merge pull request #3592 from nf-core/refactor-module-lint-test
Refactor module lint tests
2 parents 22a879d + 584527b commit 2bc29fd

16 files changed

+958
-636
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,6 @@ pip-wheel-metadata
117117

118118
# Textual
119119
snapshot_report.html
120+
121+
# AI
122+
CLAUDE.md

.vscode/settings.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,12 @@
55
"python.testing.pytestArgs": ["tests", "-v", "--tb=short"],
66
"python.testing.autoTestDiscoverOnSaveEnabled": true,
77
"python.terminal.activateEnvInCurrentTerminal": true,
8-
"python.terminal.shellIntegration.enabled": true
8+
"python.terminal.shellIntegration.enabled": true,
9+
"[python]": {
10+
"editor.defaultFormatter": "charliermarsh.ruff",
11+
"editor.codeActionsOnSave": {
12+
"source.fixAll": "explicit",
13+
"source.organizeImports": "explicit"
14+
}
15+
}
916
}

nf_core/utils.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -313,12 +313,18 @@ def fetch_wf_config(wf_path: Path, cache_config: bool = True) -> dict:
313313
cache_path = Path(cache_basedir, cache_fn)
314314
if cache_path.is_file() and cache_config is True:
315315
log.debug(f"Found a config cache, loading: {cache_path}")
316-
with open(cache_path) as fh:
317-
try:
316+
try:
317+
with open(cache_path) as fh:
318318
config = json.load(fh)
319-
except json.JSONDecodeError as e:
320-
raise UserWarning(f"Unable to load JSON file '{cache_path}' due to error {e}")
321-
return config
319+
return config
320+
except json.JSONDecodeError as e:
321+
# Log warning but don't raise - just regenerate the cache
322+
log.warning(f"Unable to load cached JSON file '{cache_path}' due to error: {e}")
323+
log.debug("Removing corrupted cache file and regenerating...")
324+
try:
325+
cache_path.unlink()
326+
except OSError:
327+
pass # If we can't delete it, just continue
322328
log.debug("No config cache found")
323329

324330
# Call `nextflow config`
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import nf_core.modules.lint
2+
3+
from ...test_modules import TestModules
4+
5+
6+
# A skeleton object with the passed/warned/failed list attrs
7+
# Use this in place of a ModuleLint object to test behaviour of
8+
# linting methods which don't need the full setup
9+
class MockModuleLint:
10+
def __init__(self):
11+
self.passed = []
12+
self.warned = []
13+
self.failed = []
14+
15+
self.main_nf = "main_nf"
16+
17+
18+
class TestModulesLint(TestModules):
19+
"""Core ModuleLint functionality tests"""
20+
21+
def test_modules_lint_init(self):
22+
"""Test ModuleLint initialization"""
23+
module_lint = nf_core.modules.lint.ModuleLint(directory=self.pipeline_dir)
24+
assert module_lint.directory == self.pipeline_dir
25+
assert hasattr(module_lint, "passed")
26+
assert hasattr(module_lint, "warned")
27+
assert hasattr(module_lint, "failed")
28+
29+
def test_mock_module_lint(self):
30+
"""Test MockModuleLint utility class"""
31+
mock_lint = MockModuleLint()
32+
assert isinstance(mock_lint.passed, list)
33+
assert isinstance(mock_lint.warned, list)
34+
assert isinstance(mock_lint.failed, list)
35+
assert mock_lint.main_nf == "main_nf"

tests/modules/lint/test_main_nf.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import pytest
2+
3+
import nf_core.modules.lint
4+
import nf_core.modules.patch
5+
from nf_core.modules.lint.main_nf import check_container_link_line, check_process_labels
6+
7+
from ...test_modules import TestModules
8+
from .test_lint_utils import MockModuleLint
9+
10+
11+
@pytest.mark.parametrize(
12+
"content,passed,warned,failed",
13+
[
14+
# Valid process label
15+
("label 'process_high'\ncpus 12", 1, 0, 0),
16+
# Non-alphanumeric characters in label
17+
("label 'a:label:with:colons'\ncpus 12", 0, 2, 0),
18+
# Conflicting labels
19+
("label 'process_high'\nlabel 'process_low'\ncpus 12", 0, 1, 0),
20+
# Duplicate labels
21+
("label 'process_high'\nlabel 'process_high'\ncpus 12", 0, 2, 0),
22+
# Valid and non-standard labels
23+
("label 'process_high'\nlabel 'process_extra_label'\ncpus 12", 1, 1, 0),
24+
# Non-standard label only
25+
("label 'process_extra_label'\ncpus 12", 0, 2, 0),
26+
# Non-standard duplicates without quotes
27+
("label process_extra_label\nlabel process_extra_label\ncpus 12", 0, 3, 0),
28+
# No label found
29+
("cpus 12", 0, 1, 0),
30+
],
31+
)
32+
def test_process_labels(content, passed, warned, failed):
33+
"""Test process label validation"""
34+
mock_lint = MockModuleLint()
35+
check_process_labels(mock_lint, content.splitlines())
36+
37+
assert len(mock_lint.passed) == passed
38+
assert len(mock_lint.warned) == warned
39+
assert len(mock_lint.failed) == failed
40+
41+
42+
@pytest.mark.parametrize(
43+
"content,passed,warned,failed",
44+
[
45+
# Single-line container definition should pass
46+
('container "quay.io/nf-core/gatk:4.4.0.0" //Biocontainers is missing a package', 2, 0, 0),
47+
# Multi-line container definition should pass
48+
(
49+
'''container "${ workflow.containerEngine == 'singularity' && !task.ext.singularity_pull_docker_container ?
50+
'https://depot.galaxyproject.org/singularity/gatk4:4.4.0.0--py36hdfd78af_0':
51+
'biocontainers/gatk4:4.4.0.0--py36hdfd78af_0' }"''',
52+
6,
53+
0,
54+
0,
55+
),
56+
# Space in container URL should fail
57+
(
58+
'''container "${ workflow.containerEngine == 'singularity' && !task.ext.singularity_pull_docker_container ?
59+
'https://depot.galaxyproject.org/singularity/gatk4:4.4.0.0--py36hdfd78af_0 ':
60+
'biocontainers/gatk4:4.4.0.0--py36hdfd78af_0' }"''',
61+
5,
62+
0,
63+
1,
64+
),
65+
# Incorrect quoting of container string should fail
66+
(
67+
'''container "${ workflow.containerEngine == 'singularity' && !task.ext.singularity_pull_docker_container ?
68+
'https://depot.galaxyproject.org/singularity/gatk4:4.4.0.0--py36hdfd78af_0 ':
69+
"biocontainers/gatk4:4.4.0.0--py36hdfd78af_0" }"''',
70+
4,
71+
0,
72+
1,
73+
),
74+
],
75+
)
76+
def test_container_links(content, passed, warned, failed):
77+
"""Test container link validation"""
78+
mock_lint = MockModuleLint()
79+
80+
for line in content.splitlines():
81+
if line.strip():
82+
check_container_link_line(mock_lint, line, registry="quay.io")
83+
84+
assert len(mock_lint.passed) == passed
85+
assert len(mock_lint.warned) == warned
86+
assert len(mock_lint.failed) == failed
87+
88+
89+
class TestMainNfLinting(TestModules):
90+
"""
91+
Test main.nf linting functionality.
92+
93+
This class tests various aspects of main.nf file linting including:
94+
- Process label validation and standards compliance
95+
- Container definition syntax and URL validation
96+
- Integration testing with alternative registries
97+
- General module linting workflow
98+
"""
99+
100+
def setUp(self):
101+
"""Set up test fixtures by installing required modules"""
102+
super().setUp()
103+
# Install samtools/sort module for all tests in this class
104+
if not self.mods_install.install("samtools/sort"):
105+
self.skipTest("Could not install samtools/sort module")
106+
107+
def test_main_nf_lint_with_alternative_registry(self):
108+
"""Test main.nf linting with alternative container registry"""
109+
# Test with alternative registry - should warn/fail when containers don't match the registry
110+
module_lint = nf_core.modules.lint.ModuleLint(directory=self.pipeline_dir, registry="public.ecr.aws")
111+
module_lint.lint(print_results=False, module="samtools/sort")
112+
113+
# Alternative registry should produce warnings or failures for container mismatches
114+
# since samtools/sort module likely uses biocontainers/quay.io, not public.ecr.aws
115+
total_issues = len(module_lint.failed) + len(module_lint.warned)
116+
assert total_issues > 0, (
117+
"Expected warnings/failures when using alternative registry that doesn't match module containers"
118+
)
119+
120+
# Test with default registry - should pass cleanly
121+
module_lint = nf_core.modules.lint.ModuleLint(directory=self.pipeline_dir)
122+
module_lint.lint(print_results=False, module="samtools/sort")
123+
assert len(module_lint.failed) == 0, f"Linting failed with {[x.__dict__ for x in module_lint.failed]}"
124+
assert len(module_lint.passed) > 0
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from pathlib import Path
2+
3+
import yaml
4+
5+
import nf_core.modules.lint
6+
7+
from ...test_modules import TestModules
8+
9+
10+
class TestMetaYml(TestModules):
11+
"""Test meta.yml functionality"""
12+
13+
def test_modules_lint_update_meta_yml(self):
14+
"""update the meta.yml of a module"""
15+
module_lint = nf_core.modules.lint.ModuleLint(directory=self.nfcore_modules, fix=True)
16+
module_lint.lint(print_results=False, module="bpipe/test")
17+
assert len(module_lint.failed) == 0, f"Linting failed with {[x.__dict__ for x in module_lint.failed]}"
18+
assert len(module_lint.passed) > 0
19+
assert len(module_lint.warned) >= 0
20+
21+
def test_modules_meta_yml_incorrect_licence_field(self):
22+
"""Test linting a module with an incorrect Licence field in meta.yml"""
23+
with open(self.bpipe_test_module_path / "meta.yml") as fh:
24+
meta_yml = yaml.safe_load(fh)
25+
meta_yml["tools"][0]["bpipe"]["licence"] = "[MIT]"
26+
with open(
27+
self.bpipe_test_module_path / "meta.yml",
28+
"w",
29+
) as fh:
30+
fh.write(yaml.dump(meta_yml))
31+
module_lint = nf_core.modules.lint.ModuleLint(directory=self.nfcore_modules)
32+
module_lint.lint(print_results=False, module="bpipe/test")
33+
34+
assert len(module_lint.failed) == 1, f"Linting failed with {[x.__dict__ for x in module_lint.failed]}"
35+
assert len(module_lint.passed) >= 0
36+
assert len(module_lint.warned) >= 0
37+
assert module_lint.failed[0].lint_test == "meta_yml_valid"
38+
39+
def test_modules_meta_yml_output_mismatch(self):
40+
"""Test linting a module with an extra entry in output fields in meta.yml compared to module.output"""
41+
with open(Path(self.nfcore_modules, "modules", "nf-core", "bpipe", "test", "main.nf")) as fh:
42+
main_nf = fh.read()
43+
main_nf_new = main_nf.replace("emit: sequence_report", "emit: bai")
44+
with open(Path(self.nfcore_modules, "modules", "nf-core", "bpipe", "test", "main.nf"), "w") as fh:
45+
fh.write(main_nf_new)
46+
module_lint = nf_core.modules.lint.ModuleLint(directory=self.nfcore_modules)
47+
module_lint.lint(print_results=False, module="bpipe/test")
48+
with open(Path(self.nfcore_modules, "modules", "nf-core", "bpipe", "test", "main.nf"), "w") as fh:
49+
fh.write(main_nf)
50+
assert len(module_lint.failed) == 1, f"Linting failed with {[x.__dict__ for x in module_lint.failed]}"
51+
assert len(module_lint.passed) >= 0
52+
assert "Module `meta.yml` does not match `main.nf`" in module_lint.failed[0].message
53+
54+
def test_modules_meta_yml_incorrect_name(self):
55+
"""Test linting a module with an incorrect name in meta.yml"""
56+
with open(Path(self.nfcore_modules, "modules", "nf-core", "bpipe", "test", "meta.yml")) as fh:
57+
meta_yml = yaml.safe_load(fh)
58+
meta_yml["name"] = "bpipe/test"
59+
with open(
60+
Path(self.nfcore_modules, "modules", "nf-core", "bpipe", "test", "meta.yml"),
61+
"w",
62+
) as fh:
63+
fh.write(yaml.dump(meta_yml))
64+
module_lint = nf_core.modules.lint.ModuleLint(directory=self.nfcore_modules)
65+
module_lint.lint(print_results=False, module="bpipe/test")
66+
67+
assert len(module_lint.failed) == 1, f"Linting failed with {[x.__dict__ for x in module_lint.failed]}"
68+
assert len(module_lint.passed) >= 0
69+
assert len(module_lint.warned) >= 0
70+
assert module_lint.failed[0].lint_test == "meta_name"
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import pytest
2+
3+
import nf_core.modules.lint
4+
5+
from ...test_modules import TestModules
6+
7+
8+
class TestModuleChanges(TestModules):
9+
"""Test module_changes.py functionality"""
10+
11+
def setUp(self):
12+
"""Set up test fixtures by installing required modules"""
13+
super().setUp()
14+
# Install samtools/sort module for all tests in this class
15+
assert self.mods_install.install("samtools/sort")
16+
17+
def test_module_changes_unchanged(self):
18+
"""Test module changes when module is unchanged"""
19+
# Run lint on the unchanged module
20+
module_lint = nf_core.modules.lint.ModuleLint(directory=self.pipeline_dir)
21+
module_lint.lint(print_results=False, module="samtools/sort", key=["module_changes"])
22+
23+
# Check that module_changes test passed (no changes detected)
24+
assert len(module_lint.failed) == 0, f"Linting failed with {[x.__dict__ for x in module_lint.failed]}"
25+
26+
# Should have passed entries for files being up to date
27+
passed_test_names = [test.lint_test for test in module_lint.passed]
28+
assert "check_local_copy" in passed_test_names
29+
30+
def test_module_changes_modified_main_nf(self):
31+
"""Test module changes when main.nf is modified"""
32+
# Modify the main.nf file
33+
main_nf_path = self.pipeline_dir / "modules" / "nf-core" / "samtools" / "sort" / "main.nf"
34+
with open(main_nf_path, "a") as fh:
35+
fh.write("\n// This is a test modification\n")
36+
37+
# Run lint on the modified module
38+
module_lint = nf_core.modules.lint.ModuleLint(directory=self.pipeline_dir)
39+
module_lint.lint(print_results=False, module="samtools/sort", key=["module_changes"])
40+
41+
# Check that module_changes test failed (changes detected)
42+
assert len(module_lint.failed) > 0, "Expected linting to fail due to modified file"
43+
44+
# Should have failed entry for local copy not matching remote
45+
failed_test_names = [test.lint_test for test in module_lint.failed]
46+
assert "check_local_copy" in failed_test_names
47+
48+
def test_module_changes_modified_meta_yml(self):
49+
"""Test module changes when meta.yml is modified"""
50+
# Modify the meta.yml file
51+
meta_yml_path = self.pipeline_dir / "modules" / "nf-core" / "samtools" / "sort" / "meta.yml"
52+
with open(meta_yml_path, "a") as fh:
53+
fh.write("\n# This is a test comment\n")
54+
55+
# Run lint on the modified module
56+
module_lint = nf_core.modules.lint.ModuleLint(directory=self.pipeline_dir)
57+
module_lint.lint(print_results=False, module="samtools/sort", key=["module_changes"])
58+
59+
# Check that module_changes test failed (changes detected)
60+
assert len(module_lint.failed) > 0, "Expected linting to fail due to modified file"
61+
62+
# Should have failed entry for local copy not matching remote
63+
failed_test_names = [test.lint_test for test in module_lint.failed]
64+
assert "check_local_copy" in failed_test_names
65+
66+
@pytest.mark.skip(reason="Patch testing requires complex setup - test framework needs improvement")
67+
def test_module_changes_patched_module(self):
68+
"""Test module changes when module is patched"""
69+
# This test would require creating a patched module which is complex
70+
# in the current test framework. Skip for now until patch test infrastructure
71+
# is improved.
72+
pass

0 commit comments

Comments
 (0)