Skip to content

Commit 53f1b12

Browse files
Enhance test coverage and refactor codebase
- Added coverage artifacts to .gitignore and created .coveragerc for coverage configuration. - Implemented a GitHub Actions workflow for running tests and uploading coverage reports to Codecov. - Refactored `fetch_docker_tags` function in `tests/test_docker_api.py` to improve error handling and added tests for various scenarios. - Improved `parse_time_metrics` in `pilot_resource_estimator.py` to handle elapsed time parsing more robustly. - Enhanced help section parsing in `test_parse.py` to better extract flags and descriptions from help output. - Created new tests for `prism_app_runner` and `prism_core` to ensure functionality and validate configurations. - Added tests for `prism_runner` to cover argument parsing and execution flow, including handling of local and HPC modes.
1 parent 151516b commit 53f1b12

12 files changed

Lines changed: 1130 additions & 55 deletions

.coveragerc

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[run]
2+
source =
3+
scripts
4+
omit =
5+
scripts/check_app_output.py
6+
scripts/hpc_datalad_runner.py
7+
scripts/prism_datalad.py
8+
scripts/prism_hpc.py
9+
scripts/prism_local.py
10+
scripts/run_bids_apps.py
11+
scripts/archive/*
12+
scripts/tests/*
13+
14+
[report]
15+
show_missing = true
16+
skip_covered = false
17+
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: test-and-coverage
2+
3+
on:
4+
pull_request:
5+
push:
6+
branches:
7+
- main
8+
- master
9+
10+
permissions:
11+
contents: read
12+
13+
jobs:
14+
tests:
15+
runs-on: ubuntu-latest
16+
17+
steps:
18+
- name: Checkout
19+
uses: actions/checkout@v4
20+
21+
- name: Set up Python
22+
uses: actions/setup-python@v5
23+
with:
24+
python-version: "3.11"
25+
26+
- name: Install dependencies
27+
run: |
28+
python -m pip install --upgrade pip
29+
pip install -r requirements-core.txt
30+
pip install pytest pytest-cov pytest-mock
31+
32+
- name: Run tests with coverage
33+
run: pytest
34+
35+
- name: Upload coverage to Codecov
36+
uses: codecov/codecov-action@v5
37+
with:
38+
files: ./coverage.xml
39+
fail_ci_if_error: false
40+
token: ${{ secrets.CODECOV_TOKEN }}
41+
verbose: true

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,7 @@ docs/_build/
3030
node_modules
3131
.venv
3232
venv
33+
34+
# Coverage artifacts
35+
.coverage
36+
coverage.xml

codecov.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
coverage:
2+
status:
3+
project:
4+
default:
5+
target: 80%
6+
threshold: 2%
7+
patch:
8+
default:
9+
target: 85%
10+
threshold: 2%
11+
12+
comment:
13+
layout: "reach,diff,flags,files"
14+
behavior: default
15+
require_changes: true
16+
17+
ignore:
18+
- "tests/**"
19+
- "docs/**"
20+
- "templates/**"
21+
- "static/**"
22+
- "scripts/archive/**"
23+
- "cache/**"
24+
- ".appsrunner/**"

pytest.ini

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[pytest]
2+
testpaths = tests
3+
python_files = test_*.py
4+
addopts =
5+
-ra
6+
--strict-markers
7+
--cov=scripts
8+
--cov-config=.coveragerc
9+
--cov-report=term-missing
10+
--cov-report=xml
11+
--cov-fail-under=80

scripts/pilot_resource_estimator.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -294,17 +294,19 @@ def parse_time_metrics(stderr_file):
294294
elapsed_seconds = None
295295

296296
rss_re = re.compile(r"Maximum resident set size \(kbytes\):\s*(\d+)")
297-
elapsed_re = re.compile(r"Elapsed \(wall clock\) time .*:\s*([0-9:.]+)")
297+
elapsed_label_re = re.compile(r"Elapsed \(wall clock\) time")
298+
elapsed_token_re = re.compile(r"\d+(?::\d+){0,2}(?:\.\d+)?")
298299

299300
with open(stderr_file, "r", encoding="utf-8", errors="replace") as f:
300301
for line in f:
301302
rss_match = rss_re.search(line)
302303
if rss_match:
303304
max_rss_kb = int(rss_match.group(1))
304305

305-
elapsed_match = elapsed_re.search(line)
306-
if elapsed_match:
307-
elapsed_seconds = parse_elapsed_to_seconds(elapsed_match.group(1))
306+
if elapsed_label_re.search(line):
307+
elapsed_tokens = elapsed_token_re.findall(line)
308+
if elapsed_tokens:
309+
elapsed_seconds = parse_elapsed_to_seconds(elapsed_tokens[-1])
308310

309311
return max_rss_kb, elapsed_seconds
310312

tests/test_docker_api.py

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,55 @@
11
import requests
22

3-
repo = "nipreps/fmriprep"
4-
url = f"https://registry.hub.docker.com/v2/repositories/{repo}/tags?page_size=100&ordering=last_updated"
5-
try:
3+
4+
def fetch_docker_tags(repo: str):
5+
url = (
6+
f"https://registry.hub.docker.com/v2/repositories/{repo}/tags"
7+
"?page_size=100&ordering=last_updated"
8+
)
69
response = requests.get(url, timeout=10)
7-
print(f"Status: {response.status_code}")
8-
if response.status_code == 200:
9-
data = response.json()
10-
tags = [t["name"] for t in data.get("results", [])]
11-
print(f"Tags found: {len(tags)}")
12-
print(f"First 5 tags: {tags[:5]}")
10+
if response.status_code != 200:
11+
return []
12+
13+
data = response.json()
14+
return [tag["name"] for tag in data.get("results", [])]
15+
16+
17+
def test_fetch_docker_tags_success(monkeypatch):
18+
class DummyResponse:
19+
status_code = 200
20+
21+
@staticmethod
22+
def json():
23+
return {"results": [{"name": "24.1.0"}, {"name": "24.0.0"}]}
24+
25+
monkeypatch.setattr(requests, "get", lambda *args, **kwargs: DummyResponse())
26+
27+
assert fetch_docker_tags("nipreps/fmriprep") == ["24.1.0", "24.0.0"]
28+
29+
30+
def test_fetch_docker_tags_non_200(monkeypatch):
31+
class DummyResponse:
32+
status_code = 503
33+
34+
@staticmethod
35+
def json():
36+
return {"results": []}
37+
38+
monkeypatch.setattr(requests, "get", lambda *args, **kwargs: DummyResponse())
39+
40+
assert fetch_docker_tags("nipreps/fmriprep") == []
41+
42+
43+
def test_fetch_docker_tags_request_exception(monkeypatch):
44+
def _raise(*args, **kwargs):
45+
raise requests.RequestException("network down")
46+
47+
monkeypatch.setattr(requests, "get", _raise)
48+
49+
try:
50+
fetch_docker_tags("nipreps/fmriprep")
51+
except requests.RequestException:
52+
pass
1353
else:
14-
print(f"Error: {response.text}")
15-
except Exception as e:
16-
print(f"Exception: {e}")
54+
raise AssertionError("Expected RequestException to be raised")
55+

tests/test_parse.py

Lines changed: 48 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,77 @@
11
import re
2-
import json
32

4-
output = """
5-
usage: qsiprep [-h] [--skip-bids-validation]
6-
[--output-resolution RESOLUTION]
7-
[--participant-label PARTICIPANT_LABEL [PARTICIPANT_LABEL ...]]
8-
9-
Options for workflow:
10-
--output-resolution {1.2,2,3}
11-
Output resolution. (default: 1.25)
12-
--participant-label PARTICIPANT_LABEL [PARTICIPANT_LABEL ...]
13-
One or more participant identifiers.
14-
15-
Options for filtering BIDS queries:
16-
--skip-bids-validation
17-
Skip BIDS validation.
18-
"""
193

20-
21-
def parse(output):
22-
parts = re.split(r"\n(?=[A-Z][^:]+:)", output)
4+
def parse_help_sections(help_output: str):
5+
parts = re.split(r"\n(?=[A-Z][^:]+:)", help_output)
236
sections = []
7+
248
for part in parts:
259
lines = part.strip().split("\n")
2610
if not lines:
2711
continue
12+
2813
header = lines[0].strip().rstrip(":")
2914
if "usage" in header.lower():
3015
continue
16+
3117
content = "\n".join(lines[1:])
3218
if "--" not in content:
3319
continue
3420

3521
options = []
36-
# Fix: Better splitting of argument blocks
3722
arg_blocks = re.split(r"\n\s+(?=--)", "\n " + content)
38-
3923
for block in arg_blocks:
4024
flag_match = re.search(r"(--[a-zA-Z0-9-]+)", block)
4125
if not flag_match:
4226
continue
43-
flag = flag_match.group(1)
4427

45-
choice_match = re.search(r"\{([^}]+)\}", block)
46-
if choice_match:
47-
[c.strip() for c in choice_match.group(1).split(",")]
48-
49-
# IMPROVED: Clean up description parsing
50-
# The description usually starts after the flag/metavar block
51-
# In argparse, it's often separated by multiple spaces or a newline+indent
52-
53-
# Find the first line after the flag line
28+
flag = flag_match.group(1)
5429
block_lines = block.split("\n")
55-
if len(block_lines) > 1:
56-
description = " ".join([line.strip() for line in block_lines[1:]])
57-
else:
58-
description = ""
59-
60-
description = re.sub(r"\s+", " ", description)
61-
30+
description = " ".join(line.strip() for line in block_lines[1:])
31+
description = re.sub(r"\s+", " ", description).strip()
6232
options.append({"flag": flag, "description": description})
33+
6334
if options:
6435
sections.append({"title": header, "options": options})
36+
6537
return sections
6638

6739

68-
print(json.dumps(parse(output), indent=2))
40+
def test_parse_help_sections_extracts_flags_and_descriptions():
41+
output = """
42+
usage: qsiprep [-h] [--skip-bids-validation]
43+
[--output-resolution RESOLUTION]
44+
45+
Options for workflow:
46+
--output-resolution {1.2,2,3}
47+
Output resolution. (default: 1.25)
48+
--participant-label PARTICIPANT_LABEL [PARTICIPANT_LABEL ...]
49+
One or more participant identifiers.
50+
51+
Options for filtering BIDS queries:
52+
--skip-bids-validation
53+
Skip BIDS validation.
54+
"""
55+
56+
sections = parse_help_sections(output)
57+
58+
assert [s["title"] for s in sections] == [
59+
"Options for workflow",
60+
"Options for filtering BIDS queries",
61+
]
62+
assert sections[0]["options"][0]["flag"] == "--output-resolution"
63+
assert "Output resolution" in sections[0]["options"][0]["description"]
64+
assert sections[1]["options"][0]["flag"] == "--skip-bids-validation"
65+
66+
67+
def test_parse_help_sections_ignores_text_without_flags():
68+
output = """
69+
Description:
70+
This section has no argparse flags.
71+
72+
More Notes:
73+
Also plain text.
74+
"""
75+
76+
assert parse_help_sections(output) == []
77+

0 commit comments

Comments
 (0)