From e858bb04d463502c7d30b490992ffc66726e5f8f Mon Sep 17 00:00:00 2001 From: Andrew Davison Date: Fri, 29 May 2026 10:24:55 +0200 Subject: [PATCH 1/2] Add --openminds-version option to choose v4 or v5 output The converter previously hard-coded openMINDS v4. This adds an `--openminds-version {v4,v5}` CLI flag and an `openminds_version` parameter to `convert()`, defaulting to v4 so existing behaviour is unchanged and v5 is opt-in. A new `openminds_version` module is the single source of truth for the active schema: `configure()` rebinds the `core`/`controlled_terms` submodules for the chosen version, which main.py and utility.py reference via `om.core` / `om.controlled_terms`. v5 schema differences handled: - DatasetVersion/Dataset: `authors` -> `contributions` (a Contribution of type "authoring"); `behavioral_protocols` is dropped; the version-to-dataset link moves from Dataset.has_versions to DatasetVersion.is_version_of. - Person: set `preferred_name` (the required identifying field in v5). - SubjectState.age: wrap the QuantitativeValue in a SpecimenAge (reference = age since birth). The chosen version is printed in the conversion report, since it is not recorded in the JSON-LD output and must be passed to `Collection.load(..., version=...)` when reloading. Adds tests for version selection plus a conftest fixture that resets the global version around each test. Updates README and usage docs. --- README.md | 2 + bids2openminds/converter.py | 14 ++- bids2openminds/main.py | 162 +++++++++++++++++++--------- bids2openminds/openminds_version.py | 60 +++++++++++ bids2openminds/report.py | 44 +++++--- bids2openminds/utility.py | 28 +++-- docs/source/usage.rst | 5 +- test/conftest.py | 19 ++++ test/test_openminds_version.py | 85 +++++++++++++++ 9 files changed, 337 insertions(+), 82 deletions(-) create mode 100644 bids2openminds/openminds_version.py create mode 100644 test/conftest.py create mode 100644 test/test_openminds_version.py diff --git a/README.md b/README.md index b24eb1f..1d35522 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,8 @@ Options: final file. -q, --quiet Not generate the final report and no warning. + --openminds-version [v4|v5] openMINDS schema version to use for the + output. [default: v4] --help Show this message and exit. ``` diff --git a/bids2openminds/converter.py b/bids2openminds/converter.py index 1a545f3..8aa22bb 100644 --- a/bids2openminds/converter.py +++ b/bids2openminds/converter.py @@ -9,6 +9,7 @@ from . import main from . import utility from . import report +from . import openminds_version as om _ENTITY_RENAMES = {"sub": "subject", "ses": "session"} @@ -58,11 +59,14 @@ def layout_to_df(layout): return pd.DataFrame(rows) -def convert(input_path, save_output=False, output_path=None, multiple_files=False, include_empty_properties=False, quiet=False): +def convert(input_path, save_output=False, output_path=None, multiple_files=False, include_empty_properties=False, quiet=False, openminds_version="v4"): if not (os.path.isdir(input_path)): raise NotADirectoryError( f"The input directory is not valid, you have specified {input_path} which is not a directory." ) + + # Select the openMINDS schema version for all objects created below. + om.configure(openminds_version) # TODO use BIDSValidator to check if input directory is a valid BIDS directory # if not(BIDSValidator().is_bids(input_path)): # raise NotADirectoryError(f"The input directory is not valid, you have specified {input_path} which is not a BIDS directory.") @@ -112,7 +116,7 @@ def convert(input_path, save_output=False, output_path=None, multiple_files=Fal if not quiet: print(report.create_report(dataset, dataset_version, collection, - dataset_description, input_path, output_path)) + dataset_description, input_path, output_path, openminds_version)) else: print("Conversion was successful") @@ -127,9 +131,11 @@ def convert(input_path, save_output=False, output_path=None, multiple_files=Fal @click.option("--multiple-files", "multiple_files", flag_value=True, help="Each node is saved into a separate file within the specified directory. 'output-path' if specified, must be a directory.") @click.option("-e", "--include-empty-properties", is_flag=True, default=False, help="Whether to include empty properties in the final file.") @click.option("-q", "--quiet", is_flag=True, default=False, help="Not generate the final report and no warning.") -def convert_click(input_path, output_path, multiple_files, include_empty_properties, quiet): +@click.option("--openminds-version", type=click.Choice(["v4", "v5"]), default="v4", show_default=True, help="openMINDS schema version to use for the output.") +def convert_click(input_path, output_path, multiple_files, include_empty_properties, quiet, openminds_version): convert(input_path, save_output=True, output_path=output_path, - multiple_files=multiple_files, include_empty_properties=include_empty_properties, quiet=quiet) + multiple_files=multiple_files, include_empty_properties=include_empty_properties, quiet=quiet, + openminds_version=openminds_version) if __name__ == "__main__": diff --git a/bids2openminds/main.py b/bids2openminds/main.py index 77ae924..62a7d86 100644 --- a/bids2openminds/main.py +++ b/bids2openminds/main.py @@ -6,12 +6,11 @@ import pandas as pd from nameparser import HumanName -import openminds.v4.core as omcore -import openminds.v4.controlled_terms as controlled_terms from openminds import IRI from .utility import table_filter, pd_table_value, file_hash, file_storage_size, detect_nifti_version from . import mapping +from . import openminds_version as om def create_openminds_person(full_name): @@ -41,8 +40,13 @@ def create_openminds_person(full_name): if not alternate_names: alternate_names = None - openminds_person = omcore.Person( + person_kwargs = dict( alternate_names=alternate_names, given_name=given_name, family_name=family_name) + if om.version == "v5": + # In v5 `preferred_name` is the required identifying property of a Person; + # use the original full name string as supplied in the BIDS metadata. + person_kwargs["preferred_name"] = full_name + openminds_person = om.core.Person(**person_kwargs) return openminds_person @@ -84,7 +88,7 @@ def create_behavioral_protocol(layout, collection): for task in tasks: - behavioral_protocol = omcore.BehavioralProtocol(name=task, + behavioral_protocol = om.core.BehavioralProtocol(name=task, internal_identifier=task, description="To be defined") behavioral_protocols.append(behavioral_protocol) @@ -110,7 +114,7 @@ def techniques_openminds(suffix): openminds_techniques_list = [] for item_openminds in items_openminds: for possible_type in possible_types: - openminds_type = getattr(controlled_terms, possible_type) + openminds_type = getattr(om.controlled_terms, possible_type) openminds_obj = None try: openminds_obj = openminds_type.by_name(item_openminds) @@ -146,7 +150,7 @@ def approaches_openminds(datatype): for item in items_openminds: approches_list.append( - controlled_terms.ExperimentalApproach.by_name(item)) + om.controlled_terms.ExperimentalApproach.by_name(item)) return approches_list @@ -171,19 +175,28 @@ def create_openminds_age(data_subject): if age is None or pd.isna(age): return None elif isinstance(age, float) or isinstance(age, int) or age.isnumeric(): - return omcore.QuantitativeValue( + age_value = om.core.QuantitativeValue( value=age, - unit=controlled_terms.UnitOfMeasurement.year + unit=om.controlled_terms.UnitOfMeasurement.year ) elif age == "89+": - return omcore.QuantitativeValueRange( + age_value = om.core.QuantitativeValueRange( max_value=None, min_value=89, - min_value_unit=controlled_terms.UnitOfMeasurement.year + min_value_unit=om.controlled_terms.UnitOfMeasurement.year ) else: return None + if om.version == "v4": + return age_value + # In v5, SubjectState.age expects a SpecimenAge wrapping the quantitative value; + # BIDS records age since birth. + return om.core.SpecimenAge( + age=age_value, + reference=om.controlled_terms.AgeReference.by_name("birth") + ) + def create_dataset_version(bids_layout, dataset_description, layout_df, studied_specimens, file_repository, behavioral_protocols, collection): @@ -194,7 +207,7 @@ def create_dataset_version(bids_layout, dataset_description, layout_df, studied_ # Fetch the digitalIdentifier from dataset description file if "DatasetDOI" in dataset_description: - digital_identifier = omcore.DOI( + digital_identifier = om.core.DOI( identifier=dataset_description["DatasetDOI"]) else: digital_identifier = None @@ -212,9 +225,9 @@ def create_dataset_version(bids_layout, dataset_description, layout_df, studied_ how_to_cite = None if ("DatasetType" in dataset_description) and (dataset_description == "derivative"): - dataset_type = controlled_terms.SemanticDataType.derived_data + dataset_type = om.controlled_terms.SemanticDataType.derived_data else: - dataset_type = controlled_terms.SemanticDataType.raw_data + dataset_type = om.controlled_terms.SemanticDataType.raw_data # TODO funding # if "Funding" in dataset_description: @@ -234,36 +247,89 @@ def create_dataset_version(bids_layout, dataset_description, layout_df, studied_ experimental_approaches = create_approaches(layout_df) - dataset_version = omcore.DatasetVersion( + common_properties = dict( digital_identifier=digital_identifier, experimental_approaches=experimental_approaches, short_name=dataset_description["Name"], full_name=dataset_description["Name"], studied_specimens=studied_specimens, - authors=authors, techniques=techniques, how_to_cite=how_to_cite, repository=file_repository, - behavioral_protocols=behavioral_protocols, data_types=dataset_type # other_contributions=other_contribution # needs to be a Contribution object # version_identifier ) + if om.version == "v4": + dataset_version = om.core.DatasetVersion( + authors=authors, + behavioral_protocols=behavioral_protocols, + **common_properties + ) + else: + # v5 replaced `authors` with `contributions` and merged the v4 + # `behavioral_protocols` property into the general `protocols` property + # (openMINDS_core #377), which now accepts both BehavioralProtocol and Protocol. + # `or None` avoids passing an empty list, which would trip the schema's + # minItems=1 constraint on `protocols` for datasets with no task labels. + dataset_version = om.core.DatasetVersion( + contributions=_authors_to_contributions(authors), + protocols=behavioral_protocols or None, + **common_properties + ) collection.add(dataset_version) return dataset_version +def _authors_to_contributions(authors): + """Wrap author Persons in an openMINDS v5 Contribution with the "authoring" role. + + In v5 the `DatasetVersion`/`Dataset` `authors` property was replaced by + `contributions`, a list of `Contribution` objects each pairing one or more + contributors with a `ContributionType`. BIDS only records who the authors are, + so they are all mapped to a single contribution of type "authoring". + + Parameters: + - authors: a list of Person objects, a single Person, or None (as returned by + :func:`create_persons`). + + Returns: + - list[Contribution] or None: None when there are no authors. + """ + if not authors: + return None + if not isinstance(authors, list): + authors = [authors] + return [ + om.core.Contribution( + contributors=authors, + type=om.controlled_terms.ContributionType.by_name("authoring") + ) + ] + + def create_dataset(dataset_description, dataset_version, collection): - dataset = omcore.Dataset( - digital_identifier=dataset_version.digital_identifier, - authors=dataset_version.authors, - full_name=dataset_version.full_name, - short_name=dataset_version.short_name, - has_versions=dataset_version - ) + if om.version == "v4": + dataset = om.core.Dataset( + digital_identifier=dataset_version.digital_identifier, + authors=dataset_version.authors, + full_name=dataset_version.full_name, + short_name=dataset_version.short_name, + has_versions=dataset_version + ) + else: + # v5 replaced `authors` with `contributions` and removed `has_versions`; + # the version-to-dataset link is expressed on the DatasetVersion instead. + dataset = om.core.Dataset( + digital_identifier=dataset_version.digital_identifier, + contributions=dataset_version.contributions, + full_name=dataset_version.full_name, + short_name=dataset_version.short_name + ) + dataset_version.is_version_of = dataset collection.add(dataset) @@ -274,13 +340,13 @@ def spices_openminds(data_subject: pd.DataFrame): bids_species = pd_table_value(data_subject, "species") if bids_species is None: # In BIDS the default species is homo sapiens. - return controlled_terms.Species.homo_sapiens + return om.controlled_terms.Species.homo_sapiens if bids_species in mapping.MAP_2_SPECIES: openminds_species = mapping.MAP_2_SPECIES[bids_species] - return controlled_terms.Species.by_name(openminds_species[0]) + return om.controlled_terms.Species.by_name(openminds_species[0]) else: try: - openminds_species = controlled_terms.Species.by_name(bids_species) + openminds_species = om.controlled_terms.Species.by_name(bids_species) warn( f"You have specified {bids_species} as species, we have autodetected {openminds_species.name}, please verify it.") return openminds_species @@ -296,7 +362,7 @@ def handedness_openminds(data_subject: pd.DataFrame): return None if bids_handedness in mapping.MAP_2_HANDEDNESS: openminds_handedness = mapping.MAP_2_HANDEDNESS[bids_handedness] - return controlled_terms.Handedness.by_name(openminds_handedness[0]) + return om.controlled_terms.Handedness.by_name(openminds_handedness[0]) else: warn( f"You have specified {bids_handedness} which is not a allowed value for handedness defined by BIDS standard.") @@ -309,7 +375,7 @@ def sex_openminds(data_subject: pd.DataFrame): return None if bids_sex in mapping.MAP_2_BIOLOGICALSEX: bids_sex = mapping.MAP_2_BIOLOGICALSEX[bids_sex] - return controlled_terms.BiologicalSex.by_name(bids_sex[0]) + return om.controlled_terms.BiologicalSex.by_name(bids_sex[0]) else: warn( f"You have specified {bids_sex} which is not a allowed value for handedness defined by BIDS standard.") @@ -333,7 +399,7 @@ def create_subjects(subject_id, layout_df, layout, collection): state_cache = [] # dealing with condition that have no seasion if not sessions: - state = omcore.SubjectState( + state = om.core.SubjectState( internal_identifier=f"Studied state {subject_name}".strip( ), lookup_label=f"Studied state {subject_name}".strip() @@ -345,7 +411,7 @@ def create_subjects(subject_id, layout_df, layout, collection): # create a subject state for each state for session in sessions: if not (table_filter(table_filter(layout_df, session, "session"), subject, "subject").empty): - state = omcore.SubjectState( + state = om.core.SubjectState( internal_identifier=f"Studied state {subject_name} {session}".strip( ), lookup_label=f"Studied state {subject_name} {session}".strip( @@ -355,7 +421,7 @@ def create_subjects(subject_id, layout_df, layout, collection): state_cache_dict[f"{session}"] = state state_cache.append(state) subject_state_dict[f"{subject}"] = state_cache_dict - subject_cache = omcore.Subject( + subject_cache = om.core.Subject( lookup_label=f"{subject_name}", internal_identifier=f"{subject_name}", studied_states=state_cache @@ -378,7 +444,7 @@ def create_subjects(subject_id, layout_df, layout, collection): state_cache_dict = {} state_cache = [] if not sessions: - state = omcore.SubjectState( + state = om.core.SubjectState( age=create_openminds_age(data_subject), handedness=handedness_openminds(data_subject), internal_identifier=f"Studied state {subject_name}".strip(), @@ -390,7 +456,7 @@ def create_subjects(subject_id, layout_df, layout, collection): else: for session in sessions: if not (table_filter(table_filter(layout_df, session, "session"), subject, "subject").empty): - state = omcore.SubjectState( + state = om.core.SubjectState( age=create_openminds_age(data_subject), handedness=handedness_openminds(data_subject), internal_identifier=f"Studied state {subject_name} {session}".strip( @@ -402,7 +468,7 @@ def create_subjects(subject_id, layout_df, layout, collection): state_cache_dict[f"{session}"] = state state_cache.append(state) subject_state_dict[f"{subject}"] = state_cache_dict - subject_cache = omcore.Subject( + subject_cache = om.core.Subject( biological_sex=sex_openminds(data_subject), lookup_label=f"{subject_name}", internal_identifier=f"{subject_name}", @@ -420,14 +486,14 @@ def create_subjects(subject_id, layout_df, layout, collection): def create_file_bundle(BIDS_path, path, collection, parent_file_bundle=None, is_file_repository=False): if is_file_repository: - openminds_file_bundle = omcore.FileRepository(format=omcore.ContentType.by_name("application/vnd.bids"), + openminds_file_bundle = om.core.FileRepository(format=om.core.ContentType.by_name("application/vnd.bids"), iri=IRI(pathlib.Path(BIDS_path).absolute().as_uri())) else: relative_path = os.path.relpath(path, BIDS_path) name = str(relative_path).replace("\\", "/") if name[0] == "_": name = name[1:] - openminds_file_bundle = omcore.FileBundle(content_description=f"File bundle created for {relative_path}", + openminds_file_bundle = om.core.FileBundle(content_description=f"File bundle created for {relative_path}", name=name, is_part_of=parent_file_bundle) @@ -461,8 +527,8 @@ def create_file_bundle(BIDS_path, path, collection, parent_file_bundle=None, is_ files_size += child_filesizes - openminds_file_bundle.storage_size = omcore.QuantitativeValue(value=files_size, - unit=controlled_terms.UnitOfMeasurement.by_name( + openminds_file_bundle.storage_size = om.core.QuantitativeValue(value=files_size, + unit=om.controlled_terms.UnitOfMeasurement.by_name( "byte") ) collection.add(openminds_file_bundle) @@ -497,34 +563,34 @@ def create_file(layout_df, BIDS_path, collection): if file["suffix"] == "participants": if extension == ".json": content_description = f"A JSON metadata file of participants TSV." - data_types = controlled_terms.DataType.by_name( + data_types = om.controlled_terms.DataType.by_name( "associative array") - file_format = omcore.ContentType.by_name( + file_format = om.core.ContentType.by_name( "application/json") elif extension == [".tsv"]: content_description = f"A metadata table for participants." - data_types = controlled_terms.DataType.by_name("table") - file_format = omcore.ContentType.by_name( + data_types = om.controlled_terms.DataType.by_name("table") + file_format = om.core.ContentType.by_name( "text/tab-separated-values") else: if extension == ".json": content_description = f"A JSON metadata file for {file['suffix']} of subject {file['subject']}" - data_types = controlled_terms.DataType.by_name( + data_types = om.controlled_terms.DataType.by_name( "associative array") - file_format = omcore.ContentType.by_name("application/json") + file_format = om.core.ContentType.by_name("application/json") elif extension in [".nii", ".nii.gz"]: content_description = f"Data file for {file['suffix']} of subject {file['subject']}" - data_types = controlled_terms.DataType.by_name("voxel data") + data_types = om.controlled_terms.DataType.by_name("voxel data") file_format = detect_nifti_version(path, extension, file_size) elif extension == [".tsv"]: if file["suffix"] == "events": content_description = f"Event file for {file['suffix']} of subject {file['subject']}" - data_types = controlled_terms.DataType.by_name( + data_types = om.controlled_terms.DataType.by_name( "event sequence") - file_format = omcore.ContentType.by_name( + file_format = om.core.ContentType.by_name( "text/tab-separated-values") - file = omcore.File( + file = om.core.File( iri=iri, content_description=content_description, data_types=data_types, diff --git a/bids2openminds/openminds_version.py b/bids2openminds/openminds_version.py new file mode 100644 index 0000000..b8b82db --- /dev/null +++ b/bids2openminds/openminds_version.py @@ -0,0 +1,60 @@ +""" +Single source of truth for the active openMINDS schema version. + +The bids2openminds converter can emit either openMINDS v4 or v5. The ``openminds`` +package exposes each schema version under a separate import path +(``openminds.v4.core``, ``openminds.v5.core``, ...), so this module holds the +``core`` and ``controlled_terms`` submodules of the *currently selected* version. +The rest of the package references them through ``om.core`` / ``om.controlled_terms`` +(``from . import openminds_version as om``) instead of hard-coding a version. + +Always use attribute access (``om.core.X``), never ``from .openminds_version import core``, +so the reference reflects the version chosen at runtime by :func:`configure`. + +It is named ``openminds_version`` rather than ``schema`` because both BIDS and +openMINDS use the word "schema"; this module is specifically about the openMINDS +package *version*. +""" + +import importlib + +SUPPORTED_VERSIONS = ("v4", "v5") +DEFAULT_VERSION = "v4" + +#: The currently configured version string (one of SUPPORTED_VERSIONS). +version = None +#: The ``openminds..core`` submodule for the configured version. +core = None +#: The ``openminds..controlled_terms`` submodule for the configured version. +controlled_terms = None + + +def configure(requested_version=DEFAULT_VERSION): + """Select the openMINDS schema version to use for the output. + + Rebinds the module-level :data:`core` and :data:`controlled_terms` to the + submodules of the requested version and records the choice in :data:`version`. + Call this before creating any openMINDS objects. + + Parameters: + - requested_version (str): one of :data:`SUPPORTED_VERSIONS` ("v4" or "v5"). + + Raises: + - ValueError: if ``requested_version`` is not supported. + """ + global version, core, controlled_terms + if requested_version not in SUPPORTED_VERSIONS: + raise ValueError( + f"Unsupported openMINDS version {requested_version!r}; " + f"choose one of {', '.join(SUPPORTED_VERSIONS)}." + ) + core = importlib.import_module(f"openminds.{requested_version}.core") + controlled_terms = importlib.import_module( + f"openminds.{requested_version}.controlled_terms") + version = requested_version + + +# Initialise to the default version on import so that simply importing the package +# (and the existing unit tests, which exercise functions directly) works without an +# explicit configure() call. +configure(DEFAULT_VERSION) diff --git a/bids2openminds/report.py b/bids2openminds/report.py index 8357f13..12c8768 100644 --- a/bids2openminds/report.py +++ b/bids2openminds/report.py @@ -1,12 +1,11 @@ import os -def create_report(dataset, dataset_version, collection, dataset_description, input_path, output_path): +def create_report(dataset, dataset_version, collection, dataset_description, input_path, output_path, openminds_version="v4"): subject_number = 0 subject_state_numbers = [] file_bundle_number = 0 files_number = 0 - behavioral_protocols_numbers = 0 content_type_list = "" for item in collection: @@ -23,14 +22,30 @@ def create_report(dataset, dataset_version, collection, dataset_description, inp file_bundle_number += 1 - if item.type_.endswith("BehavioralProtocol"): - - behavioral_protocols_numbers += 1 - if item.type_.endswith("ContentType"): content_type_list += f"{item.name}\n" + # In v5, DatasetVersion no longer carries `authors`: they live inside + # `contributions`. Derive them version-agnostically. + authors = getattr(dataset_version, "authors", None) + if authors is None and getattr(dataset_version, "contributions", None): + authors = [] + for contribution in dataset_version.contributions: + authors.extend(contribution.contributors or []) + + # Behavioral protocols are linked on the DatasetVersion: in v4 via the dedicated + # `behavioral_protocols` property, in v5 merged into the general `protocols` + # property (which may also hold non-behavioral Protocols), see openMINDS_core #377. + behavioral_protocols = getattr(dataset_version, "behavioral_protocols", None) + if not behavioral_protocols: + protocols = getattr(dataset_version, "protocols", None) or [] + behavioral_protocols = [ + p for p in protocols if p.type_.endswith("BehavioralProtocol") + ] + behavioral_protocols = behavioral_protocols or None + behavioral_protocols_numbers = len(behavioral_protocols or []) + experimental_approaches_list = "" if dataset_version.experimental_approaches is not None: for approache in dataset_version.experimental_approaches: @@ -52,16 +67,16 @@ def create_report(dataset, dataset_version, collection, dataset_description, inp techniques_list = "No techniques were detected. Please follow the BIDS recommendations for suffixes, as bids2openminds detects techniques based on suffixes." behavioral_protocols_list = "" - if dataset_version.behavioral_protocols is not None: - for behavioral_protocol in dataset_version.behavioral_protocols: + if behavioral_protocols is not None: + for behavioral_protocol in behavioral_protocols: behavioral_protocols_list += f"{behavioral_protocol.name}\n" else: behavioral_protocols_list = "No behavioral protocols were detected. Please follow the BIDS recommendations for task labels, as bids2openminds detects behavioral protocols based on task labels." author_list = "" i = 1 - if dataset_version.authors is not None: - for author in dataset_version.authors: + if authors is not None: + for author in authors: if author.family_name is not None: author_list += f" {i}. {author.family_name}, {author.given_name}\n" i += 1 @@ -78,15 +93,16 @@ def create_report(dataset, dataset_version, collection, dataset_description, inp report = f""" Conversion Report -================= +================= Conversion was successful, the openMINDS file is in {output_path} +openMINDS schema version: {openminds_version} (load it back with Collection.load(..., version="{openminds_version}")) Dataset title : {dataset.full_name} -The following elements were converted: ------------------------------------------- -+ number of authors : {len(dataset_version.authors or [])} +The following elements were converted: +------------------------------------------ ++ number of authors : {len(authors or [])} + number of converted subjects: {subject_number} + number of states per subject: {text_subject_state_numbers} + number of files: {files_number} diff --git a/bids2openminds/utility.py b/bids2openminds/utility.py index 18e563f..4703ca1 100644 --- a/bids2openminds/utility.py +++ b/bids2openminds/utility.py @@ -7,9 +7,7 @@ import pandas as pd -import openminds.v4.controlled_terms as controlled_terms -from openminds.v4.core import Hash, QuantitativeValue, ContentType -from openminds.v4.controlled_terms import UnitOfMeasurement +from . import openminds_version as om def read_json(file_path: str) -> dict: @@ -115,7 +113,7 @@ def file_hash(file_path: str, algorithm: str = "MD5"): hash_value = hash_object.hexdigest() # Create a openMINDS Hash object with the algorithm and digest - openminds_hash = Hash(algorithm=algorithm, digest=hash_value) + openminds_hash = om.core.Hash(algorithm=algorithm, digest=hash_value) return openminds_hash @@ -131,8 +129,8 @@ def file_storage_size(file_path: str): as an openMINDS object and the int is the raw byte count. """ file_stats = os.stat(file_path) - file_size = QuantitativeValue( - value=file_stats.st_size, unit=UnitOfMeasurement.by_name("byte")) + file_size = om.core.QuantitativeValue( + value=file_stats.st_size, unit=om.controlled_terms.UnitOfMeasurement.by_name("byte")) return file_size, file_stats.st_size @@ -162,19 +160,19 @@ def detect_nifti_version(file_name, extension, file_size): return None if sizeof_hdr == nii1_sizeof_hdr: - return ContentType.by_name("application/vnd.nifti.1") + return om.core.ContentType.by_name("application/vnd.nifti.1") elif sizeof_hdr == nii2_sizeof_hdr: - return ContentType.by_name("application/vnd.nifti.2") + return om.core.ContentType.by_name("application/vnd.nifti.2") else: # big endian sizeof_hdr = int.from_bytes(byte_data, byteorder='big') if sizeof_hdr == nii1_sizeof_hdr: - return ContentType.by_name("application/vnd.nifti.1") + return om.core.ContentType.by_name("application/vnd.nifti.1") elif sizeof_hdr == nii2_sizeof_hdr: - return ContentType.by_name("application/vnd.nifti.2") + return om.core.ContentType.by_name("application/vnd.nifti.2") if extension == ".nii.gz": try: @@ -189,18 +187,18 @@ def detect_nifti_version(file_name, extension, file_size): return None if sizeof_hdr == nii1_sizeof_hdr: - return ContentType.by_name("application/vnd.nifti.1") + return om.core.ContentType.by_name("application/vnd.nifti.1") elif sizeof_hdr == nii2_sizeof_hdr: - return ContentType.by_name("application/vnd.nifti.2") + return om.core.ContentType.by_name("application/vnd.nifti.2") else: # big endian sizeof_hdr = int.from_bytes(byte_data, byteorder='big') if sizeof_hdr == nii1_sizeof_hdr: - return ContentType.by_name("application/vnd.nifti.1") + return om.core.ContentType.by_name("application/vnd.nifti.1") elif sizeof_hdr == nii2_sizeof_hdr: - return ContentType.by_name("application/vnd.nifti.2") + return om.core.ContentType.by_name("application/vnd.nifti.2") - return ContentType.by_name("application/vnd.nifti.1") + return om.core.ContentType.by_name("application/vnd.nifti.1") diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 0f05068..9f5a612 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -13,7 +13,7 @@ The ``convert`` function processes a Brain Imaging Data Structure (BIDS) directo Function Signature ################## ->>> def convert(input_path, save_output=False, output_path=None, multiple_files=False, include_empty_properties=False, quiet=False): +>>> def convert(input_path, save_output=False, output_path=None, multiple_files=False, include_empty_properties=False, quiet=False, openminds_version="v4"): Parameters ########## @@ -23,6 +23,7 @@ Parameters - ``multiple_files`` (bool, default=False): If True, the openMINDS data will be saved into multiple files within the specified output_path. - ``include_empty_properties`` (bool, default=False): If True, includes all the openMINDS properties with empty values in the final output. Otherwise includes only properties that have a non `None` value. - ``quiet`` (bool, default=False): If True, suppresses warnings and the final report output. Only prints success messages. +- ``openminds_version`` (str, default="v4"): The openMINDS schema version to use for the output, either ``"v4"`` or ``"v5"``. The chosen version is not recorded in the output, so pass the same value to ``Collection.load(..., version=...)`` when reloading. Returns ####### @@ -65,3 +66,5 @@ This function is also accessible via a command-line interface using the `click` -e, --include-empty-properties Include empty properties in the final file. -q, --quiet Suppress warnings and reports. + --openminds-version [v4|v5] + openMINDS schema version to use for the output (default: v4). diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..61fa9e2 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,19 @@ +import pytest + +import bids2openminds.openminds_version as om + + +@pytest.fixture(autouse=True) +def reset_openminds_version(): + """Reset the global openMINDS version around every test. + + ``convert(..., openminds_version=...)`` (and tests calling + ``om.configure``) mutate module-level state in + ``bids2openminds.openminds_version``. Resetting to the default before and + after each test keeps a test that selects a non-default version from leaking + that choice into later tests, which call the conversion functions directly + and expect the default (v4). + """ + om.configure(om.DEFAULT_VERSION) + yield + om.configure(om.DEFAULT_VERSION) diff --git a/test/test_openminds_version.py b/test/test_openminds_version.py new file mode 100644 index 0000000..79b20de --- /dev/null +++ b/test/test_openminds_version.py @@ -0,0 +1,85 @@ +# Tests for choosing the openMINDS output version (v4 vs v5). +import os + +import pytest +from openminds import Collection + +import bids2openminds.converter +import bids2openminds.main as main +import bids2openminds.openminds_version as om + + +def test_configure_rebinds_modules(): + om.configure("v5") + assert om.version == "v5" + assert om.core.__name__ == "openminds.v5.core" + assert om.controlled_terms.__name__ == "openminds.v5.controlled_terms" + + om.configure("v4") + assert om.version == "v4" + assert om.core.__name__ == "openminds.v4.core" + assert om.controlled_terms.__name__ == "openminds.v4.controlled_terms" + + +def test_configure_rejects_unknown_version(): + with pytest.raises(ValueError): + om.configure("v3") + + +def test_authors_to_contributions(): + om.configure("v5") + person_1 = main.create_openminds_person("Jane Doe") + person_2 = main.create_openminds_person("John Smith") + + # No authors -> no contributions. + assert main._authors_to_contributions(None) is None + assert main._authors_to_contributions([]) is None + + # A single Person (not wrapped in a list) is normalised to a list. + single = main._authors_to_contributions(person_1) + assert len(single) == 1 + assert single[0].contributors == [person_1] + assert single[0].type.name == "authoring" + + # A list of Persons is kept as-is. + multiple = main._authors_to_contributions([person_1, person_2]) + assert len(multiple) == 1 + assert multiple[0].contributors == [person_1, person_2] + + +def test_convert_v5_uses_contributions_and_is_version_of(tmp_path): + output_path = os.path.join(str(tmp_path), "openminds") + bids2openminds.converter.convert( + os.path.join("bids-examples", "ds005"), + save_output=True, + output_path=output_path, + multiple_files=True, + quiet=True, + openminds_version="v5", + ) + + # Reloading with the matching version must succeed. + collection = Collection() + collection.load(output_path, version="v5") + + dataset_version = None + dataset = None + for item in collection: + if item.type_.endswith("/DatasetVersion"): + dataset_version = item + elif item.type_.endswith("/Dataset"): + dataset = item + + assert dataset_version is not None + assert dataset is not None + + # v5-specific structure: authors are expressed as contributions and the + # version links back to the dataset via is_version_of. + assert dataset_version.contributions + assert dataset_version.is_version_of is not None + assert dataset.contributions + + # v4-only properties must not exist on v5 objects. + assert not hasattr(dataset_version, "authors") + assert not hasattr(dataset_version, "behavioral_protocols") + assert not hasattr(dataset, "has_versions") From c5a3af425bcf8bb95a830fe15ca6c548acf2c76f Mon Sep 17 00:00:00 2001 From: Andrew Davison Date: Mon, 1 Jun 2026 14:14:54 +0200 Subject: [PATCH 2/2] Add v5 unit tests for `usage_conditions` and `SpecimenAge` --- test/test_dataset_version.py | 30 ++++++++++++++++++++++++++++++ test/test_subject_age.py | 26 ++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/test/test_dataset_version.py b/test/test_dataset_version.py index e604712..2f9fe07 100644 --- a/test/test_dataset_version.py +++ b/test/test_dataset_version.py @@ -39,6 +39,36 @@ def test_create_dataset_version_citation(): assert citation_dataset_version.digital_identifier.identifier == expected.digital_identifier.identifier +def test_create_dataset_version_citation_v5(): + om.configure("v5") + citation = { + "title": "My Dataset", + "version": "1.05.9", + "doi": "10.5281/zenodo.123456", + "license": "Apache-2.0" + } + + citation_dataset_version = create_dataset_version( + "", citation, {}, layout_df, [], [], [], Collection() + ) + + assert citation_dataset_version.usage_conditions is not None + assert len(citation_dataset_version.usage_conditions) == 1 + assert citation_dataset_version.usage_conditions[0].id == om.core.License.apache_2_0.id + assert not hasattr(citation_dataset_version, "license") + + +def test_create_dataset_version_no_license_v5(): + om.configure("v5") + citation = {"title": "My Dataset"} + + dataset_version = create_dataset_version( + "", citation, {}, layout_df, [], [], [], Collection() + ) + + assert dataset_version.usage_conditions is None + + def test_create_dataset_version_without_citation(): # Mock missing CITATION.cff citation = None diff --git a/test/test_subject_age.py b/test/test_subject_age.py index 1c43ab0..086a9b7 100644 --- a/test/test_subject_age.py +++ b/test/test_subject_age.py @@ -1,6 +1,7 @@ import pandas as pd import pytest from bids2openminds.main import create_openminds_age +import bids2openminds.openminds_version as om example_ages = [("89+", "QuantitativeValueRange"), (45, "QuantitativeValue"), ("XX", None)] @@ -19,3 +20,28 @@ def test_subject_age(age, age_type): assert openminds_age.value == age if age_type == None: assert openminds_age is None + + +@pytest.mark.parametrize("age,age_type", [("89+", "QuantitativeValueRange"), (45, "QuantitativeValue")]) +def test_subject_age_v5(age, age_type): + om.configure("v5") + data_subject_table = pd.DataFrame(data={'age': [age]}) + specimen_age = create_openminds_age(data_subject_table) + + assert specimen_age.type_.endswith("SpecimenAge") + assert specimen_age.reference.name == "birth" + + inner = specimen_age.age + if age_type == "QuantitativeValueRange": + assert inner.type_.endswith("QuantitativeValueRange") + assert inner.max_value is None + assert inner.min_value == 89 + else: + assert inner.type_.endswith("QuantitativeValue") + assert inner.value == age + + +def test_subject_age_unknown_v5(): + om.configure("v5") + data_subject_table = pd.DataFrame(data={'age': ["XX"]}) + assert create_openminds_age(data_subject_table) is None