bids2table

bids2table

CI Docs codecov Ruff Python3 License

Index BIDS datasets fast, locally or in the cloud.

Installation

Install the core package using pip:

pip install bids2table

Variants

Depending on your use case, you may need extra dependencies. Choose the option that matches your use case:

If you want to... Run this command
Add cloud storage support (S3, GCS) pip install bids2table[cloud]
Enable pybids compatibility pip install bids2table[pybids]
Install everything pip install bids2table[cloud,pybids]
Warning

Deprecation Warning: Previous versions used bids2table[s3] for cloud support. While the s3 extra still works for now, it will be removed upon release of 2.4.x. Please update your installation scripts to use [cloud].

Development Version

To test out the absolute latest features directly from the main branch, install directly from GitHub:

pip install "bids2table[cloud,pybids] @ git+https://github.com/childmindresearch/bids2table.git"

Usage

To run these examples, you will need to clone the bids-examples repo.

git clone -b 1.9.0 https://github.com/bids-standard/bids-examples.git

Finding BIDS datasets

You can search a directory for valid BIDS datasets using b2t2 find

(bids2table) clane$ b2t2 find bids-examples | head -n 10
bids-examples/asl002
bids-examples/ds002
bids-examples/ds005
bids-examples/asl005
bids-examples/ds051
bids-examples/eeg_rishikesh
bids-examples/asl004
bids-examples/asl003
bids-examples/ds003
bids-examples/eeg_cbm

Indexing datasets from the command line

Indexing datasets is done with b2t2 index. Here we index a single example dataset, saving the output as a parquet file.

(bids2table) clane$ b2t2 index -o ds102.parquet bids-examples/ds102
ds102: 100%|███████████████████████████████████████| 26/26 [00:00<00:00, 154.12it/s, sub=26, N=130]

You can also index a list of datasets. Note that each iteration in the progress bar represents one dataset.

(bids2table) clane$ b2t2 index -o bids-examples.parquet bids-examples/*
100%|████████████████████████████████████████████| 87/87 [00:00<00:00, 113.59it/s, ds=None, N=9727]

You can pipe the output of b2t2 find to b2t2 index to create an index of all datasets under a root directory.

(bids2table) clane$ b2t2 find bids-examples | b2t2 index -o bids-examples.parquet
97it [00:01, 96.05it/s, ds=ieeg_filtered_speech, N=10K]

The resulting index will include both top-level datasets (as in the previous command) as well nested derivatives datasets.

Indexing datasets hosted on S3

bids2table supports indexing datasets hosted on S3 via cloudpathlib. To use this functionality, make sure to install bids2table with the s3 extra. Or you can also just install cloudpathlib directly

pip install cloudpathlib[s3]

As an example, here we index all datasets on OpenNeuro

(bids2table) clane$ b2t2 index -o openneuro.parquet \
  -j 8 --use-threads s3://openneuro.org/ds*
100%|█████████████████████████████████████| 1408/1408 [12:25<00:00,  1.89it/s, ds=ds006193, N=1.2M]

Using 8 threads, we can index all ~1400 OpenNeuro datasets (1.2M files) in less than 15 minutes.

Indexing datasets from python

You can also index datasets using the Python API.

import bids2table as b2t2
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq

# Index a single dataset.
tab = b2t2.index_dataset("bids-examples/ds102")

# Find and index a batch of datasets.
tabs = b2t2.batch_index_dataset(
    b2t2.find_bids_datasets("bids-examples"),
)
tab = pa.concat_tables(tabs)

# Index a dataset on S3.
tab = b2t2.index_dataset("s3://openneuro.org/ds000224")

# Save as parquet.
pq.write_table(tab, "ds000224.parquet")

# Convert to a pandas dataframe.
df = tab.to_pandas(types_mapper=pd.ArrowDtype)

Indexing with a custom BIDS schema

By default, bids2table uses the BIDS schema bundled with bidsschematools. Pass a schema= argument to index_dataset, batch_index_dataset, get_arrow_schema, get_column_names, or validate_bids_entities to use a different schema. The argument may be a path to a schema directory, a string URI accepted by bidsschematools.schema.load_schema, or a pre-loaded bidsschematools.types.Namespace.

import bidsschematools.schema
import bids2table as b2t2

# Use a pre-loaded schema (e.g. when indexing several datasets that share one).
schema = bidsschematools.schema.load_schema()
tab = b2t2.index_dataset("bids-examples/ds102", schema=schema)

# Or pass a path to a custom schema directory.
tab = b2t2.index_dataset("/data/ds001", schema="/path/to/custom-schema")

Different schema arguments may be used for different calls within the same process; per-call schemas propagate to worker processes when max_workers > 0.

 1""".. include:: ../README.md"""  # noqa: D415
 2
 3__all__ = [
 4    "SchemaSpec",
 5    "batch_index_dataset",
 6    "cloudpathlib_is_available",
 7    "find_bids_datasets",
 8    "format_bids_path",
 9    "get_arrow_schema",
10    "get_column_names",
11    "index_dataset",
12    "load_bids_metadata",
13    "parse_bids_entities",
14    "validate_bids_entities",
15]
16
17import importlib.util
18
19if importlib.util.find_spec("pandas"):
20    __all__.append("pybids")
21
22from bids2table._entities import (
23    format_bids_path,
24    parse_bids_entities,
25    validate_bids_entities,
26)
27from bids2table._indexing import (
28    batch_index_dataset,
29    find_bids_datasets,
30    get_arrow_schema,
31    get_column_names,
32    index_dataset,
33)
34from bids2table._metadata import load_bids_metadata
35from bids2table._pathlib import cloudpathlib_is_available
36from bids2table._schema import SchemaSpec
37from bids2table._version import *  # noqa: F403 - import all of generated module
SchemaSpec = bidsschematools.types.namespace.Namespace | str | pathlib.Path | cloudpathlib.cloudpath.CloudPath | None
def batch_index_dataset( roots: Sequence[str | pathlib.Path | cloudpathlib.cloudpath.CloudPath], max_workers: int | None = 0, executor_cls: type[concurrent.futures.process.ProcessPoolExecutor | concurrent.futures.thread.ThreadPoolExecutor] = <class 'concurrent.futures.process.ProcessPoolExecutor'>, *, show_progress: bool = False, schema: bidsschematools.types.namespace.Namespace | str | pathlib.Path | cloudpathlib.cloudpath.CloudPath | None = None) -> Generator[pyarrow.lib.Table, None, None]:
239def batch_index_dataset(
240    roots: Sequence[str | PathT],
241    max_workers: int | None = 0,
242    executor_cls: type[ProcessPoolExecutor | ThreadPoolExecutor] = ProcessPoolExecutor,
243    *,
244    show_progress: bool = False,
245    schema: SchemaSpec = None,
246) -> Generator[pa.Table, None, None]:
247    """Index a batch of BIDS datasets.
248
249    Args:
250        roots: List of BIDS dataset root directories.
251        max_workers: Number of indexing processes to run in parallel. Setting
252            `max_workers=0` (the default) uses the main process only. Setting
253            `max_workers=None` starts as many workers as there are available CPUs. See
254            `concurrent.futures.ProcessPoolExecutor` for details.
255        executor_cls: Executor class to use for parallel indexing.
256        show_progress: Show progress bar.
257        schema: Optional `SchemaSpec`. `None` uses the default BIDS schema.
258
259    Yields:
260        An Arrow table index for each BIDS dataset.
261    """
262    func = partial(_batch_index_func, schema=schema)
263    file_count = 0
264    for dataset, table in (
265        pbar := tqdm(
266            _pmap(func, roots, max_workers, executor_cls=executor_cls),
267            total=len(roots) if isinstance(roots, Sequence) else None,
268            disable=show_progress not in {True, "dataset"},
269        )
270    ):
271        file_count += len(table)
272        pbar.set_postfix({"ds": dataset, "N": _hfmt(file_count)}, refresh=False)
273        yield table

Index a batch of BIDS datasets.

Arguments:
  • roots: List of BIDS dataset root directories.
  • max_workers: Number of indexing processes to run in parallel. Setting max_workers=0 (the default) uses the main process only. Setting max_workers=None starts as many workers as there are available CPUs. See concurrent.futures.ProcessPoolExecutor for details.
  • executor_cls: Executor class to use for parallel indexing.
  • show_progress: Show progress bar.
  • schema: Optional SchemaSpec. None uses the default BIDS schema.
Yields:

An Arrow table index for each BIDS dataset.

def cloudpathlib_is_available() -> bool:
42def cloudpathlib_is_available() -> bool:
43    """Check if cloudpathlib is available."""
44    return _CLOUDPATHLIB_AVAILABLE

Check if cloudpathlib is available.

def find_bids_datasets( root: str | pathlib.Path | cloudpathlib.cloudpath.CloudPath, exclude: str | list[str] | None = None, maxdepth: int | None = None) -> Generator[pathlib.Path | cloudpathlib.cloudpath.CloudPath, None, None]:
132def find_bids_datasets(
133    root: str | PathT,
134    exclude: str | list[str] | None = None,
135    maxdepth: int | None = None,
136) -> Generator[PathT, None, None]:
137    """Find all BIDS datasets under a root directory.
138
139    Args:
140        root: Root path to begin search.
141        exclude: Glob pattern or list of patterns matching sub-directory names to
142            exclude from the search.
143        maxdepth: Maximum depth to search.
144
145    Yields:
146        Root paths of all BIDS datasets under `root`.
147    """
148    root = as_path(root)
149
150    if isinstance(exclude, str):
151        exclude = [exclude]
152    elif exclude is None:
153        exclude = []
154    exclude_patterns = [re.compile(fnmatch.translate(pat)) for pat in exclude]
155
156    entry_count = 1
157    ds_count = 0
158
159    if _is_bids_dataset(root):
160        ds_count += 1
161        yield root
162
163    # Tuple of path, depth
164    stack = [(root, 0)]
165
166    while stack:
167        top, depth = stack.pop()
168
169        inside_bids = _is_bids_dataset(top)
170        depth += 1
171
172        for entry in top.iterdir():
173            entry_count += 1
174
175            if any(re.fullmatch(pat, entry.name) for pat in exclude_patterns):
176                continue
177
178            if _is_bids_dataset(entry):
179                ds_count += 1
180                yield entry
181
182            # Checks if we should descend into this directory.
183            # Check not reached final depth.
184            descend = maxdepth is None or depth < maxdepth
185            # Heuristic checks whether the filename looks like a (visible) directory.
186            descend = descend and not (entry.suffix or entry.name.startswith("."))
187            # Only descend into specific subdirectories of BIDS directories.
188            descend = descend and (
189                not inside_bids or entry.name in _BIDS_NESTED_PARENT_DIRNAMES
190            )
191            # Finally, check if actually a directory (which is slow so we want to
192            # short-circuit as much as possible).
193            if descend and entry.is_dir():
194                stack.append((entry, depth))

Find all BIDS datasets under a root directory.

Arguments:
  • root: Root path to begin search.
  • exclude: Glob pattern or list of patterns matching sub-directory names to exclude from the search.
  • maxdepth: Maximum depth to search.
Yields:

Root paths of all BIDS datasets under root.

def format_bids_path(entities: dict[str, typing.Any], int_format: str = '%d') -> pathlib.Path:
184def format_bids_path(entities: dict[str, Any], int_format: str = "%d") -> Path:
185    """Construct a formatted BIDS path from entities dict.
186
187    Args:
188        entities: dict mapping BIDS keys to values.
189        int_format: format string for integer (index) BIDS values.
190
191    Returns:
192        A formatted `Path` instance.
193    """
194    special = {"datatype", "suffix", "ext"}
195
196    # Formatted key-value entities.
197    entities_fmt = []
198    for name, value in entities.items():
199        if name not in special:
200            if isinstance(value, int):
201                value = int_format % value
202            entities_fmt.append(f"{name}-{value}")
203    name = "_".join(entities_fmt)
204
205    # Append suffix and extension.
206    if suffix := entities.get("suffix"):
207        name += f"_{suffix}"
208    if ext := entities.get("ext"):
209        name += ext
210
211    # Prepend parent directories.
212    path = Path(name)
213    if datatype := entities.get("datatype"):
214        path = datatype / path
215    if ses := entities.get("ses"):
216        path = f"ses-{ses}" / path
217    return f"sub-{entities['sub']}" / path

Construct a formatted BIDS path from entities dict.

Arguments:
  • entities: dict mapping BIDS keys to values.
  • int_format: format string for integer (index) BIDS values.
Returns:

A formatted Path instance.

def get_arrow_schema( *, schema: bidsschematools.types.namespace.Namespace | str | pathlib.Path | cloudpathlib.cloudpath.CloudPath | None = None) -> pyarrow.lib.Schema:
 93def get_arrow_schema(*, schema: SchemaSpec = None) -> pa.Schema:
 94    """Get Arrow schema of the BIDS dataset index."""
 95    adapter = load_bids_schema(schema)
 96    entity_schema = entity_arrow_schema(adapter)
 97    index_fields = {
 98        name: pa.field(name, cfg["dtype"], metadata=cfg["metadata"])
 99        for name, cfg in _INDEX_ARROW_FIELDS.items()
100    }
101    fields = [
102        index_fields["dataset"],
103        *entity_schema,
104        index_fields["extra_entities"],
105        index_fields["root"],
106        index_fields["path"],
107    ]
108    metadata = {
109        **entity_schema.metadata,
110        b"bids2table_version": version.encode(),
111    }
112    return pa.schema(fields, metadata=metadata)

Get Arrow schema of the BIDS dataset index.

def get_column_names( *, schema: bidsschematools.types.namespace.Namespace | str | pathlib.Path | cloudpathlib.cloudpath.CloudPath | None = None) -> type[enum.StrEnum]:
115def get_column_names(*, schema: SchemaSpec = None) -> type[enum.StrEnum]:
116    """Get an enum of the BIDS index columns."""
117    # TODO: It might be nice if the column names were statically available. One option
118    # would be to generate a static _schema.py module at install time (similar to how
119    # _version.py is generated) which defines the static default schema and column
120    # names.
121    arrow_schema = get_arrow_schema(schema=schema)
122    items = []
123    for f in arrow_schema:
124        name = f.metadata[b"name"].decode()
125        items.append((name, name))
126
127    BIDSColumn = enum.StrEnum("BIDSColumn", items)  # noqa: N806 - class type
128    BIDSColumn.__doc__ = "Enum of BIDS index column names."
129    return BIDSColumn

Get an enum of the BIDS index columns.

def index_dataset( root: str | pathlib.Path | cloudpathlib.cloudpath.CloudPath, include_subjects: str | list[str] | None = None, *, schema: bidsschematools.types.namespace.Namespace | str | pathlib.Path | cloudpathlib.cloudpath.CloudPath | None = None) -> pyarrow.lib.Table:
197def index_dataset(
198    root: str | PathT,
199    include_subjects: str | list[str] | None = None,
200    *,
201    schema: SchemaSpec = None,
202) -> pa.Table:
203    """Index a BIDS dataset.
204
205    Args:
206        root: BIDS dataset root directory.
207        include_subjects: Glob pattern or list of patterns for matching subjects to
208            include in the index.
209        schema: BIDS schema specification to use. If ``None``, uses the bundled
210            default schema.
211
212    Returns:
213        An Arrow table index of the BIDS dataset.
214    """
215    root = as_path(root)
216
217    arrow_schema = get_arrow_schema(schema=schema)
218
219    dataset, _ = _get_bids_dataset(root)
220    if dataset is None:
221        _logger.warning(f"Path {root} is not a valid BIDS dataset directory.")
222        return pa.Table.from_pylist([], schema=arrow_schema)
223
224    subject_dirs = _find_bids_subject_dirs(root, include_subjects)
225    subject_dirs = sorted(subject_dirs, key=lambda p: p.name)
226    if len(subject_dirs) == 0:
227        _logger.warning(f"Path {root} contains no matching subject dirs.")
228        return pa.Table.from_pylist([], schema=arrow_schema)
229
230    tables = []
231    file_count = 0
232    for sub in subject_dirs:
233        _, table = _index_bids_subject_dir(sub, schema=arrow_schema, dataset=dataset)
234        tables.append(table)
235        file_count += len(table)
236    return pa.concat_tables(tables).combine_chunks()

Index a BIDS dataset.

Arguments:
  • root: BIDS dataset root directory.
  • include_subjects: Glob pattern or list of patterns for matching subjects to include in the index.
  • schema: BIDS schema specification to use. If None, uses the bundled default schema.
Returns:

An Arrow table index of the BIDS dataset.

def load_bids_metadata( path: str | pathlib.Path | cloudpathlib.cloudpath.CloudPath, *, inherit: bool = True) -> dict[str, typing.Any]:
12def load_bids_metadata(path: str | PathT, *, inherit: bool = True) -> dict[str, Any]:
13    """Load the full JSON sidecar metadata for a BIDS file.
14
15    Sidecar files are loaded according to the inheritance principle in top-down order.
16
17    Args:
18        path: BIDS file path
19        inherit: Load the full metadata according to inheritance. Otherwise, load only
20            the first JSON sidecar found in the bottom-up search.
21
22    Returns:
23        A sidecar metadata dictionary.
24    """
25    path = as_path(path)
26    entities = _cache_parse_bids_entities(path)
27    query = dict(entities, ext=".json")
28
29    metadata = {}
30
31    parent = path.parent
32    if inherit:
33        sidecars = reversed(list(_find_bids_parents(parent, query)))
34    else:
35        sidecars = [next(_find_bids_parents(parent, query))]
36
37    for path in sidecars:
38        try:
39            data = _load_json(path)
40            metadata.update(data)
41        except (json.JSONDecodeError, TypeError):
42            continue
43    return metadata

Load the full JSON sidecar metadata for a BIDS file.

Sidecar files are loaded according to the inheritance principle in top-down order.

Arguments:
  • path: BIDS file path
  • inherit: Load the full metadata according to inheritance. Otherwise, load only the first JSON sidecar found in the bottom-up search.
Returns:

A sidecar metadata dictionary.

def parse_bids_entities(path: str | pathlib.Path) -> dict[str, str]:
39def parse_bids_entities(path: str | Path) -> dict[str, str]:
40    """Parse entities from BIDS file path.
41
42    Parses all BIDS filename `"{key}-{value}"` entities as well as special entities:
43    datatype, suffix, ext (extension). Does not validate entities or cast to types.
44
45    Args:
46        path: BIDS path to parse.
47
48    Returns:
49        A dict mapping BIDS entity keys to values.
50    """
51    if isinstance(path, str):
52        path = Path(path)
53    entities = {}
54
55    filename = path.name
56    parts = filename.split("_")
57
58    datatype = _parse_bids_datatype(path)
59
60    # Get suffix and extension.
61    suffix_ext = parts.pop()
62    suffix, dot, ext = suffix_ext.partition(".")
63    ext = dot + ext if ext else None
64
65    # Suffix is actually an entity, put back in list.
66    if "-" in suffix:
67        parts.append(suffix)
68        suffix = None
69
70    # Split entities, skipping any that don't contain a '-'.
71    for part in parts:
72        if "-" in part:
73            key, val = part.split("-", maxsplit=1)
74            entities[key] = val
75
76    entities |= {
77        k: v
78        for k, v in zip(
79            ["datatype", "suffix", "ext"], [datatype, suffix, ext], strict=True
80        )
81        if v is not None
82    }
83    return entities

Parse entities from BIDS file path.

Parses all BIDS filename "{key}-{value}" entities as well as special entities: datatype, suffix, ext (extension). Does not validate entities or cast to types.

Arguments:
  • path: BIDS path to parse.
Returns:

A dict mapping BIDS entity keys to values.

def validate_bids_entities( entities: dict[str, typing.Any], *, schema: bidsschematools.types.namespace.Namespace | str | pathlib.Path | cloudpathlib.cloudpath.CloudPath | None = None) -> tuple[dict[str, str | int], dict[str, typing.Any]]:
101def validate_bids_entities(
102    entities: dict[str, Any],
103    *,
104    schema: SchemaSpec = None,
105) -> tuple[dict[str, BIDSValue], dict[str, Any]]:
106    """Validate BIDS entities against a BIDS schema.
107
108    Args:
109        entities: dict mapping BIDS keys to unvalidated entities
110        schema: optional `SchemaSpec` (`Namespace | str | PathT | None`).
111            `None` uses the default BIDS schema bundled with bidsschematools.
112
113    Returns:
114        `(valid_entities, extra_entities)` — valid entities cast to the
115        declared type, plus any leftover entries that did not match a
116        known entity or failed validation.
117    """
118    adapter = load_bids_schema(schema)
119    pa_schema = entity_arrow_schema(adapter)
120    return _pyarrow_validate_entities(entities, pa_schema=pa_schema)

Validate BIDS entities against a BIDS schema.

Arguments:
  • entities: dict mapping BIDS keys to unvalidated entities
  • schema: optional SchemaSpec (Namespace | str | PathT | None). None uses the default BIDS schema bundled with bidsschematools.
Returns:

(valid_entities, extra_entities) — valid entities cast to the declared type, plus any leftover entries that did not match a known entity or failed validation.