Skip to content

Commit 38cf11b

Browse files
committed
Add a function to save bids validator and schema version
1 parent 5ba0c71 commit 38cf11b

File tree

3 files changed

+238
-1
lines changed

3 files changed

+238
-1
lines changed

cubids/cli.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,41 @@ def _enter_validate(argv=None):
107107
workflows.validate(**args)
108108

109109

110+
def _parse_bids_version():
111+
parser = argparse.ArgumentParser(
112+
description="cubids bids-version: Get BIDS Validator and Schema version",
113+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
114+
)
115+
PathExists = partial(_path_exists, parser=parser)
116+
117+
parser.add_argument(
118+
"bids_dir",
119+
type=PathExists,
120+
action="store",
121+
help=(
122+
"the root of a BIDS dataset. It should contain "
123+
"sub-X directories and dataset_description.json"
124+
),
125+
)
126+
parser.add_argument(
127+
"--write",
128+
action="store_true",
129+
default=False,
130+
help=(
131+
"Save the validator and schema version to 'dataset_description.json' "
132+
"when using `cubids bids-version /bids/path --write`. "
133+
"By default, `cubids bids-version /bids/path` prints to the terminal."
134+
),
135+
)
136+
return parser
137+
138+
139+
def _enter_bids_version(argv=None):
140+
options = _parse_bids_version().parse_args(argv)
141+
args = vars(options).copy()
142+
workflows.bids_version(**args)
143+
144+
110145
def _parse_bids_sidecar_merge():
111146
parser = argparse.ArgumentParser(
112147
description=("bids-sidecar-merge: merge critical keys from one sidecar to another"),
@@ -655,6 +690,7 @@ def _enter_print_metadata_fields(argv=None):
655690

656691
COMMANDS = [
657692
("validate", _parse_validate, workflows.validate),
693+
("bids-version", _parse_bids_version, workflows.bids_version),
658694
("sidecar-merge", _parse_bids_sidecar_merge, workflows.bids_sidecar_merge),
659695
("group", _parse_group, workflows.group),
660696
("apply", _parse_apply, workflows.apply),

cubids/validator.py

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
import pathlib
88
import subprocess
9+
import re
910

1011
import pandas as pd
1112

@@ -24,6 +25,22 @@ def build_validator_call(path, ignore_headers=False):
2425
return command
2526

2627

28+
def get_bids_validator_version():
29+
"""Get the version of the BIDS validator.
30+
31+
Returns
32+
-------
33+
version : :obj:`str`
34+
Version of the BIDS validator.
35+
"""
36+
command = ["deno", "run", "-A", "jsr:@bids/validator", "--version"]
37+
result = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
38+
output = result.stdout.decode("utf-8").strip()
39+
version = output.split()[-1]
40+
clean_ver = re.sub(r'\x1b\[[0-9;]*m', '', version) # Remove ANSI color codes
41+
return {"ValidatorVersion": clean_ver}
42+
43+
2744
def build_subject_paths(bids_dir):
2845
"""Build a list of BIDS dirs with 1 subject each."""
2946
bids_dir = str(bids_dir)
@@ -52,6 +69,26 @@ def build_subject_paths(bids_dir):
5269
return subjects_dict
5370

5471

72+
def build_first_subject_path(bids_dir, subject):
73+
"""Build a list of BIDS dirs with 1 subject each."""
74+
bids_dir = str(bids_dir)
75+
if not bids_dir.endswith("/"):
76+
bids_dir += "/"
77+
78+
root_files = [x for x in glob.glob(bids_dir + "*") if os.path.isfile(x)]
79+
80+
subject_dict = {}
81+
82+
purepath = pathlib.PurePath(subject)
83+
sub_label = purepath.name
84+
85+
files = [x for x in glob.glob(subject + "**", recursive=True) if os.path.isfile(x)]
86+
files.extend(root_files)
87+
subject_dict[sub_label] = files
88+
89+
return subject_dict
90+
91+
5592
def run_validator(call):
5693
"""Run the validator with subprocess.
5794
@@ -103,6 +140,7 @@ def parse_issue(issue_dict):
103140
return {
104141
"location": issue_dict.get("location", ""),
105142
"code": issue_dict.get("code", ""),
143+
"issueMessage": issue_dict.get("issueMessage", ""),
106144
"subCode": issue_dict.get("subCode", ""),
107145
"severity": issue_dict.get("severity", ""),
108146
"rule": issue_dict.get("rule", ""),
@@ -114,7 +152,9 @@ def parse_issue(issue_dict):
114152
# Extract issues
115153
issues = data.get("issues", {}).get("issues", [])
116154
if not issues:
117-
return pd.DataFrame(columns=["location", "code", "subCode", "severity", "rule"])
155+
return pd.DataFrame(
156+
columns=["location", "code", "issueMessage", "subCode", "severity", "rule"]
157+
)
118158

119159
# Parse all issues
120160
parsed_issues = [parse_issue(issue) for issue in issues]
@@ -135,7 +175,99 @@ def get_val_dictionary():
135175
return {
136176
"location": {"Description": "File with the validation issue."},
137177
"code": {"Description": "Code of the validation issue."},
178+
"issueMessage": {"Description": "Validation issue message."},
138179
"subCode": {"Description": "Subcode providing additional issue details."},
139180
"severity": {"Description": "Severity of the issue (e.g., warning, error)."},
140181
"rule": {"Description": "Validation rule that triggered the issue."},
141182
}
183+
184+
185+
def extract_summary_info(output):
186+
"""Extract summary information from the JSON output.
187+
188+
Parameters
189+
----------
190+
output : str
191+
JSON string of BIDS validator output.
192+
193+
Returns
194+
-------
195+
dict
196+
Dictionary containing SchemaVersion and other summary info.
197+
"""
198+
try:
199+
data = json.loads(output)
200+
except json.JSONDecodeError as e:
201+
raise ValueError("Invalid JSON provided to get SchemaVersion.") from e
202+
203+
summary = data.get("summary", {})
204+
205+
return {"SchemaVersion": summary.get("schemaVersion", "")}
206+
207+
208+
def update_dataset_description(path, new_info):
209+
"""Update or append information to dataset_description.json.
210+
211+
Parameters
212+
----------
213+
path : :obj:`str`
214+
Path to the dataset.
215+
new_info : :obj:`dict`
216+
Information to add or update.
217+
"""
218+
description_path = os.path.join(path, "dataset_description.json")
219+
220+
# Load existing data if the file exists
221+
if os.path.exists(description_path):
222+
with open(description_path, "r") as f:
223+
existing_data = json.load(f)
224+
else:
225+
existing_data = {}
226+
227+
# Update the existing data with the new info
228+
existing_data.update(new_info)
229+
230+
# Write the updated data back to the file
231+
with open(description_path, "w") as f:
232+
json.dump(existing_data, f, indent=4)
233+
print(f"Updated dataset_description.json at: {description_path}")
234+
235+
# Check if .datalad directory exists before running the DataLad save command
236+
datalad_dir = os.path.join(path, ".datalad")
237+
if os.path.exists(datalad_dir) and os.path.isdir(datalad_dir):
238+
try:
239+
subprocess.run(
240+
["datalad", "save", "-m",
241+
"Save BIDS validator and schema version to dataset_description",
242+
description_path],
243+
check=True
244+
)
245+
print("Changes saved with DataLad.")
246+
except subprocess.CalledProcessError as e:
247+
print(f"Error running DataLad save: {e}")
248+
249+
250+
def bids_validator_version(output, path, write=False):
251+
"""Save BIDS validator and schema version.
252+
253+
Parameters
254+
----------
255+
output : :obj:`str`
256+
Path to JSON file of BIDS validator output.
257+
path : :obj:`str`
258+
Path to the dataset.
259+
write : :obj:`bool`
260+
If True, write to dataset_description.json. If False, print to terminal.
261+
"""
262+
# Get the BIDS validator version
263+
validator_version = get_bids_validator_version()
264+
# Extract schemaVersion
265+
summary_info = extract_summary_info(output)
266+
267+
combined_info = {**validator_version, **summary_info}
268+
269+
if write:
270+
# Update the dataset_description.json file
271+
update_dataset_description(path, combined_info)
272+
elif not write:
273+
print(combined_info)

cubids/workflows.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
get_val_dictionary,
2323
parse_validator_output,
2424
run_validator,
25+
build_first_subject_path,
26+
bids_validator_version,
2527
)
2628

2729
warnings.simplefilter(action="ignore", category=FutureWarning)
@@ -258,6 +260,73 @@ def validate(
258260
sys.exit(proc.returncode)
259261

260262

263+
def bids_version(
264+
bids_dir,
265+
write=False
266+
):
267+
"""Get BIDS validator and schema version.
268+
269+
Parameters
270+
----------
271+
bids_dir : :obj:`pathlib.Path`
272+
Path to the BIDS directory.
273+
write : :obj:`bool`
274+
If True, write to dataset_description.json. If False, print to terminal.
275+
"""
276+
# Need to run validator to get output with schema version
277+
# Copy code from `validate --sequential`
278+
279+
try: # return first subject
280+
# Get all folders that start with "sub-"
281+
sub_folders = [
282+
name
283+
for name in os.listdir(bids_dir)
284+
if os.path.isdir(os.path.join(bids_dir, name)) and name.startswith("sub-")
285+
]
286+
if not sub_folders:
287+
raise ValueError("No folders starting with 'sub-' found. Please provide a valid BIDS.")
288+
subject = sub_folders[0]
289+
except FileNotFoundError:
290+
raise FileNotFoundError(f"The directory {bids_dir} does not exist.")
291+
except ValueError as ve:
292+
raise ve
293+
294+
# build a dictionary with {SubjectLabel: [List of files]}
295+
# run first subject only
296+
subject_dict = build_first_subject_path(bids_dir, subject)
297+
298+
# iterate over the dictionary
299+
for subject, files_list in subject_dict.items():
300+
# logger.info(" ".join(["Processing subject:", subject]))
301+
# create a temporary directory and symlink the data
302+
with tempfile.TemporaryDirectory() as tmpdirname:
303+
for fi in files_list:
304+
# cut the path down to the subject label
305+
bids_start = fi.find(subject)
306+
307+
# maybe it's a single file
308+
if bids_start < 1:
309+
bids_folder = tmpdirname
310+
fi_tmpdir = tmpdirname
311+
312+
else:
313+
bids_folder = Path(fi[bids_start:]).parent
314+
fi_tmpdir = tmpdirname + "/" + str(bids_folder)
315+
316+
if not os.path.exists(fi_tmpdir):
317+
os.makedirs(fi_tmpdir)
318+
output = fi_tmpdir + "/" + str(Path(fi).name)
319+
shutil.copy2(fi, output)
320+
321+
# run the validator
322+
call = build_validator_call(tmpdirname)
323+
ret = run_validator(call)
324+
325+
# Get BIDS validator and schema version
326+
decoded = ret.stdout.decode("UTF-8")
327+
bids_validator_version(decoded, bids_dir, write=write)
328+
329+
261330
def bids_sidecar_merge(from_json, to_json):
262331
"""Merge critical keys from one sidecar to another."""
263332
merge_status = merge_json_into_json(from_json, to_json, raise_on_error=False)

0 commit comments

Comments
 (0)