Skip to content

Commit c08db2d

Browse files
authored
Merge pull request #3580 from mirpedrol/pipeline-nf-test-linting
Linting: add linting of nf-test files content
2 parents d822d96 + 70e40e7 commit c08db2d

File tree

10 files changed

+343
-1
lines changed

10 files changed

+343
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- Fix the unexpected warning and sychronize the `README.md` and `RO-crate-metadata.json` ([#3493](https://github.com/nf-core/tools/pull/3493))
2828
- Adapt the linter to the new notation used to include the centralized nf-core configs ([#3491](https://github.com/nf-core/tools/pull/3491))
2929
- Addressing more cases than can happen when processing input and output values ([#3541](https://github.com/nf-core/tools/pull/3541))
30+
- add linting of nf-test files content ([#3580](https://github.com/nf-core/tools/pull/3580))
3031

3132
### Modules
3233

docs/api/_src/pipeline_lint_tests/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- [modules_structure](./modules_structure/)
1616
- [multiqc_config](./multiqc_config/)
1717
- [nextflow_config](./nextflow_config/)
18+
- [nf_test_content](./nf_test_content/)
1819
- [nfcore_yml](./nfcore_yml/)
1920
- [pipeline_if_empty_null](./pipeline_if_empty_null/)
2021
- [pipeline_name_conventions](./pipeline_name_conventions/)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# nf_test_content
2+
3+
```{eval-rst}
4+
.. automethod:: nf_core.pipelines.lint.PipelineLint.nf_test_content
5+
```

nf_core/pipeline-template/tests/nextflow.config

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,14 @@
77
// TODO nf-core: Specify any additional parameters here
88
// Or any resources requirements
99
params.modules_testdata_base_path = 'https://raw.githubusercontent.com/nf-core/test-datasets/modules/data/'
10+
params.pipelines_testdata_base_path = 'https://raw.githubusercontent.com/nf-core/test-datasets/refs/heads/{{ short_name }}'
11+
12+
process {
13+
resourceLimits = [
14+
cpus: 4,
15+
memory: '15.GB',
16+
time: '1.h'
17+
]
18+
}
1019

1120
aws.client.anonymous = true // fixes S3 access issues on self-hosted runners

nf_core/pipelines/create/template_features.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ nf-test:
456456
- ".github/actions/nf-test/action.yml"
457457
- "nf-test.config"
458458
- "tests/default.nf.test"
459+
nf_test_content: False
459460
nfcore_pipelines: False
460461
custom_pipelines: True
461462
seqera_platform:

nf_core/pipelines/lint/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from .modules_structure import modules_structure
4545
from .multiqc_config import multiqc_config
4646
from .nextflow_config import nextflow_config
47+
from .nf_test_content import nf_test_content
4748
from .nfcore_yml import nfcore_yml
4849
from .pipeline_if_empty_null import pipeline_if_empty_null
4950
from .pipeline_name_conventions import pipeline_name_conventions
@@ -95,6 +96,7 @@ class PipelineLint(nf_core.utils.Pipeline):
9596
local_component_structure = local_component_structure
9697
multiqc_config = multiqc_config
9798
nextflow_config = nextflow_config
99+
nf_test_content = nf_test_content
98100
nfcore_yml = nfcore_yml
99101
pipeline_name_conventions = pipeline_name_conventions
100102
pipeline_todos = pipeline_todos
@@ -140,6 +142,7 @@ def _get_all_lint_tests(release_mode):
140142
return [
141143
"files_exist",
142144
"nextflow_config",
145+
"nf_test_content",
143146
"files_unchanged",
144147
"actions_nf_test",
145148
"actions_awstest",
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import logging
2+
import re
3+
from pathlib import Path
4+
from typing import Dict, List, Union
5+
6+
from nf_core.utils import load_tools_config, run_cmd
7+
8+
log = logging.getLogger(__name__)
9+
10+
11+
def nf_test_content(self) -> Dict[str, List[str]]:
12+
"""Checks that the pipeline nf-test files have the appropriate content.
13+
14+
This lint test checks the following files and content of these files:
15+
16+
* `*.nf.test` files should specify the `outdir` parameter:
17+
18+
.. code-block:: groovy
19+
20+
when {
21+
params {
22+
outdir = "$outputDir"
23+
}
24+
}
25+
26+
* A `versions.yml` file should be included in the snapshot of all `*.nf.test` files
27+
28+
* The `nextflow.config` file should contain:
29+
.. code-block:: groovy
30+
modules_testdata_base_path = <path>
31+
32+
.. code-block:: groovy
33+
pipelines_testdata_base_path = <path>
34+
35+
And should set the correct resource limits, as defined in the `test` profile
36+
37+
* The `nf-test.config` file should:
38+
* Make sure tests are relative to root directory
39+
40+
.. code-block:: groovy
41+
42+
testsDir "."
43+
44+
* Ensure a user-configurable nf-test directory
45+
46+
.. code-block:: groovy
47+
48+
workDir System.getenv("NFT_WORKDIR") ?: ".nf-test"
49+
50+
* Use a test specific config
51+
52+
.. code-block:: groovy
53+
54+
configFile "tests/nextflow.config"
55+
56+
All these checks can be skipped in the `.nf-core.yml` file using:
57+
58+
.. code-block:: groovy
59+
lint:
60+
nf_test_content: False
61+
62+
or
63+
64+
.. code-block:: groovy
65+
lint:
66+
nf_test_content:
67+
- tests/<test_name>.nf.test
68+
- tests/nextflow.config
69+
- nf-test.config
70+
"""
71+
passed: List[str] = []
72+
failed: List[str] = []
73+
ignored: List[str] = []
74+
75+
_, pipeline_conf = load_tools_config(self.wf_path)
76+
lint_conf = getattr(pipeline_conf, "lint", None) or None
77+
nf_test_content_conf = getattr(lint_conf, "nf_test_content", None) or None
78+
79+
# Content of *.nf.test files
80+
test_fns = list(Path(self.wf_path, "tests").glob("*.nf.test"))
81+
test_checks: Dict[str, Dict[str, Union[str, bool]]] = {
82+
"outdir": {
83+
"pattern": r"outdir *= *[\"']\${?outputDir}?[\"']",
84+
"description": "`outdir` parameter",
85+
"failure_msg": 'does not contain `outdir` parameter, it should contain `outdir = "$outputDir"`',
86+
"when_block": True,
87+
},
88+
"versions.yml": {
89+
"pattern": r"versions\.yml",
90+
"description": "snapshots a 'versions.yml' file",
91+
"failure_msg": "does not snapshot a 'versions.yml' file",
92+
"when_block": False,
93+
},
94+
}
95+
96+
for test_fn in test_fns:
97+
if nf_test_content_conf is not None and (
98+
not nf_test_content_conf or str(test_fn.relative_to(self.wf_path)) in nf_test_content_conf
99+
):
100+
ignored.append(f"'{test_fn.relative_to(self.wf_path)}' checking ignored")
101+
continue
102+
103+
checks_passed = {check: False for check in test_checks}
104+
with open(test_fn) as fh:
105+
for line in fh:
106+
for check_name, check_info in test_checks.items():
107+
if check_info["when_block"] and "when" in line:
108+
while "}\n" not in line:
109+
line = next(fh)
110+
if re.search(str(check_info["pattern"]), line):
111+
passed.append(
112+
f"'{test_fn.relative_to(self.wf_path)}' contains {check_info['description']}"
113+
)
114+
checks_passed[check_name] = True
115+
break
116+
elif not check_info["when_block"] and re.search(str(check_info["pattern"]), line):
117+
passed.append(f"'{test_fn.relative_to(self.wf_path)}' {check_info['description']}")
118+
checks_passed[check_name] = True
119+
120+
for check_name, check_info in test_checks.items():
121+
if not checks_passed[check_name]:
122+
failed.append(f"'{test_fn.relative_to(self.wf_path)}' {check_info['failure_msg']}")
123+
124+
# Content of nextflow.config file
125+
conf_fn = Path(self.wf_path, "tests", "nextflow.config")
126+
127+
# Get the CPU, memory and time values defined in the test profile configuration.
128+
cmd = f"config -profile test -flat {self.wf_path}"
129+
result = run_cmd("nextflow", cmd)
130+
config_values = {"cpus": "4", "memory": "15.GB", "time": "1.h"}
131+
if result is not None:
132+
stdout, _ = result
133+
for config_line in stdout.splitlines():
134+
ul = config_line.decode("utf-8")
135+
try:
136+
k, v = ul.split(" = ", 1)
137+
if k == "cpus":
138+
config_values["cpus"] = v.strip("'\"")
139+
elif k == "memory":
140+
config_values["memory"] = v.strip("'\"")
141+
elif k == "time":
142+
config_values["time"] = v.strip("'\"")
143+
except ValueError:
144+
log.debug(f"Couldn't find key=value config pair:\n {ul}")
145+
pass
146+
147+
config_checks: Dict[str, Dict[str, str]] = {
148+
"modules_testdata_base_path": {
149+
"pattern": "modules_testdata_base_path",
150+
"description": "`modules_testdata_base_path`",
151+
},
152+
"pipelines_testdata_base_path": {
153+
"pattern": "pipelines_testdata_base_path",
154+
"description": "`pipelines_testdata_base_path`",
155+
},
156+
"cpus": {
157+
"pattern": f"cpus: *[\"']?{config_values['cpus']}[\"']?",
158+
"description": f"correct CPU resource limits. Should be {config_values['cpus']}",
159+
},
160+
"memory": {
161+
"pattern": f"memory: *[\"']?{config_values['memory']}[\"']?",
162+
"description": f"correct memory resource limits. Should be {config_values['memory']}",
163+
},
164+
"time": {
165+
"pattern": f"time: *[\"']?{config_values['time']}[\"']?",
166+
"description": f"correct time resource limits. Should be {config_values['time']}",
167+
},
168+
}
169+
170+
if nf_test_content_conf is None or str(conf_fn.relative_to(self.wf_path)) not in nf_test_content_conf:
171+
checks_passed = {check: False for check in config_checks}
172+
with open(conf_fn) as fh:
173+
for line in fh:
174+
line = line.strip()
175+
for check_name, config_check_info in config_checks.items():
176+
if re.search(str(config_check_info["pattern"]), line):
177+
passed.append(
178+
f"'{conf_fn.relative_to(self.wf_path)}' contains {config_check_info['description']}"
179+
)
180+
checks_passed[check_name] = True
181+
for check_name, config_check_info in config_checks.items():
182+
if not checks_passed[check_name]:
183+
failed.append(
184+
f"'{conf_fn.relative_to(self.wf_path)}' does not contain {config_check_info['description']}"
185+
)
186+
else:
187+
ignored.append(f"'{conf_fn.relative_to(self.wf_path)}' checking ignored")
188+
189+
# Content of nf-test.config file
190+
nf_test_conf_fn = Path(self.wf_path, "nf-test.config")
191+
nf_test_checks: Dict[str, Dict[str, str]] = {
192+
"testsDir": {
193+
"pattern": r'testsDir "\."',
194+
"description": "sets a `testsDir`",
195+
"failure_msg": 'does not set a `testsDir`, it should contain `testsDir "."`',
196+
},
197+
"workDir": {
198+
"pattern": r'workDir System\.getenv\("NFT_WORKDIR"\) \?: "\.nf-test"',
199+
"description": "sets a `workDir`",
200+
"failure_msg": 'does not set a `workDir`, it should contain `workDir System.getenv("NFT_WORKDIR") ?: ".nf-test"`',
201+
},
202+
"configFile": {
203+
"pattern": r'configFile "tests/nextflow\.config"',
204+
"description": "sets a `configFile`",
205+
"failure_msg": 'does not set a `configFile`, it should contain `configFile "tests/nextflow.config"`',
206+
},
207+
}
208+
209+
if nf_test_content_conf is None or str(nf_test_conf_fn.relative_to(self.wf_path)) not in nf_test_content_conf:
210+
checks_passed = {check: False for check in nf_test_checks}
211+
with open(nf_test_conf_fn) as fh:
212+
for line in fh:
213+
line = line.strip()
214+
for check_name, nf_test_check_info in nf_test_checks.items():
215+
if re.search(str(nf_test_check_info["pattern"]), line):
216+
passed.append(
217+
f"'{nf_test_conf_fn.relative_to(self.wf_path)}' {nf_test_check_info['description']}"
218+
)
219+
checks_passed[check_name] = True
220+
for check_name, nf_test_check_info in nf_test_checks.items():
221+
if not checks_passed[check_name]:
222+
failed.append(f"'{nf_test_conf_fn.relative_to(self.wf_path)}' {nf_test_check_info['failure_msg']}")
223+
else:
224+
ignored.append(f"'{nf_test_conf_fn.relative_to(self.wf_path)}' checking ignored")
225+
226+
return {"passed": passed, "failed": failed, "ignored": ignored}

nf_core/utils.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,11 @@ class NFCoreYamlLintConfig(BaseModel):
11901190
template_strings:
11911191
- docs/my_pdf.pdf
11921192
nfcore_components: False
1193+
# nf_test_content: False
1194+
nf_test_content:
1195+
- tests/<test_name>.nf.test
1196+
- tests/nextflow.config
1197+
- nf-test.config
11931198
"""
11941199

11951200
files_unchanged: Optional[Union[bool, List[str]]] = None
@@ -1200,6 +1205,8 @@ class NFCoreYamlLintConfig(BaseModel):
12001205
""" List of files that should not contain merge markers """
12011206
nextflow_config: Optional[Optional[Union[bool, List[Union[str, Dict[str, List[str]]]]]]] = None
12021207
""" List of Nextflow config files that should not be changed """
1208+
nf_test_content: Optional[Union[bool, List[str]]] = None
1209+
""" List of nf-test content that should not be changed """
12031210
multiqc_config: Optional[Union[bool, List[str]]] = None
12041211
""" List of MultiQC config options that be changed """
12051212
files_exist: Optional[Union[bool, List[str]]] = None

0 commit comments

Comments
 (0)