Source code for reprostim.bids.inject_sidecar

# SPDX-FileCopyrightText: 2020-2026 ReproNim Team <info@repronim.org>
#
# SPDX-License-Identifier: MIT

"""
Core logic for bids-inject-sidecar: extract BIDS media-file metadata from
audio/video files and write or update their ``.json`` sidecars.
"""

import json
import logging
import os
from enum import Enum
from typing import Any, Callable, Dict, List, Optional

from pydantic import BaseModel, Field

from reprostim.bids.media import BidsMediaInfo, parse_bids_media_info
from reprostim.bids.properties import (
    bids_properties_from_ffprobe,
    bids_properties_from_video_audit,
)

# initialize the logger
logger = logging.getLogger(__name__)
logger.debug(f"name={__name__}")


[docs] class OverwriteMode(str, Enum): """Sidecar JSON write mode for bids-inject-sidecar's ``--mode`` option.""" REPLACE = "replace" """Overwrite the entire sidecar with only the freshly extracted/added fields.""" UPDATE = "update" """Merge freshly extracted/added fields into the existing sidecar, preserving other existing keys untouched."""
[docs] class ConflictPolicy(str, Enum): """Policy for bids-inject-sidecar's ``--existing-different`` option: how to handle a field that already exists in the sidecar with a different, non-``n/a`` value.""" ERROR = "error" """Abort processing that file and report the conflict.""" OVERWRITE = "overwrite" """Log a warning and proceed with the new value."""
[docs] class BisContext(BaseModel): """Context for bids-inject-sidecar processing of scan records.""" mode: OverwriteMode = Field( default=OverwriteMode.UPDATE, description="Sidecar JSON write mode.", ) conflict_policy: ConflictPolicy = Field( default=ConflictPolicy.ERROR, description="Policy for a field with a differing existing value.", ) videos_tsv: Optional[str] = Field( default=None, description="Optional path to videos.tsv used to look up cached " "fields instead of re-running ffprobe.", ) strict: bool = Field( default=False, description="When True, an invalid BidsMediaInfo (per " "parse_bids_media_info) is a fatal error for that file. When False " "(default), the problem is reported as a warning and processing " "continues.", ) dry_run: bool = Field( default=False, description="When True, compute but do not write any sidecar.", ) verbose: bool = Field( default=False, description="When True, emit verbose per-field extraction/merge/conflict " "output.", ) out_func: Optional[Callable] = Field( default=None, description="Callable used for user-facing output (e.g. click.echo). " "When None, output is suppressed.", ) ext_props: Dict[str, Any] = Field( default_factory=dict, description="Extra BIDS sidecar properties parsed from --add " "META=VALUE (or bare JSON object) values; see _parse_ext_props.", )
def _parse_ext_props(add_meta: List[str]) -> Dict[str, Any]: """Parse ``--add`` values into a dict of extra BIDS sidecar properties. Each entry in *add_meta* is one of: - A bare JSON object string, e.g. ``'{"AudioCodec": "aac", "AudioSampleRate": 48000}'``. Its top-level keys are treated as property names and merged directly into the result (a later entry's keys win over an earlier one's on conflict). - A ``META=VALUE`` pair, e.g. ``'AudioCodec=aac'``. If ``VALUE`` itself parses as JSON (e.g. ``'ExtraInfo={"key": "val"}'``), the parsed JSON value (dict/list/number/bool/null) is used as the property value; otherwise ``VALUE`` is stored verbatim as a string. :param add_meta: Raw values from repeated ``--add`` options. :type add_meta: List[str] :return: Property name -> value mapping. :rtype: Dict[str, Any] :raises ValueError: If an entry is neither a JSON object nor a ``META=VALUE`` pair with a non-empty ``META``. """ props: Dict[str, Any] = {} for entry in add_meta: try: parsed = json.loads(entry) except json.JSONDecodeError: parsed = None if isinstance(parsed, dict): props.update(parsed) continue if "=" not in entry: raise ValueError( f"Invalid --add value {entry!r}: expected META=VALUE or a JSON object" ) meta, _, value_str = entry.partition("=") meta = meta.strip() if not meta: raise ValueError(f"Invalid --add value {entry!r}: empty META name") try: value: Any = json.loads(value_str) except json.JSONDecodeError: value = value_str props[meta] = value return props def _error(ctx: BisContext, msg: str) -> None: """Log *msg* as an error and report it via ``ctx.out_func``, if set. Shared by every per-file processing function in this module as the single place a fatal-for-this-file error is reported, so logging and ``out_func`` reporting stay consistent. Returns nothing — callers decide their own control flow, typically ``_error(ctx, msg); return False``. :param ctx: Processing context (used for ``out_func``). :type ctx: BisContext :param msg: Human-readable error message. :type msg: str """ logger.error(msg) if ctx.out_func: ctx.out_func(f"Error: {msg}") def _verbose(ctx: BisContext, msg: str) -> None: """Log *msg* at debug level and, when ``ctx.verbose`` is set, also report it via ``ctx.out_func``. Mirrors :func:`_error` for non-fatal, verbose-only diagnostic output. :param ctx: Processing context (used for ``verbose``/``out_func``). :type ctx: BisContext :param msg: Human-readable message. :type msg: str """ logger.debug(msg) if ctx.verbose and ctx.out_func: ctx.out_func(msg) def _warn(ctx: BisContext, msg: str) -> None: """Log *msg* at info level and report it via ``ctx.out_func``, if set. Used for non-strict-mode diagnostics that are notable but not fatal for the file being processed — the caller continues processing after this is called. Mirrors :func:`_error`, at a lower severity. :param ctx: Processing context (used for ``out_func``). :type ctx: BisContext :param msg: Human-readable message. :type msg: str """ logger.info(msg) if ctx.out_func: ctx.out_func(f"Warn: {msg}") def _do_sidecar(ctx: BisContext, path: str) -> bool: """Extract BIDS media-file metadata for *path* and write/update its sidecar JSON. When ``ctx.videos_tsv`` is set, extraction prefers :func:`bids_properties_from_video_audit` (cached ``videos.tsv`` lookup, falling back to auditing *path* directly when it has no matching row — see that function). If it raises, extraction falls back to the plain ``ffprobe``-based :func:`bids_properties_from_ffprobe` for this file. When ``ctx.videos_tsv`` is not set, extraction is ``ffprobe``-only, as before. Either way, ``ctx.ext_props`` (``--add`` overrides) is merged on top. :param ctx: Processing context (mode, conflict policy, ext_props, etc.) :type ctx: BisContext :param path: Path to the audio/video file. :type path: str :return: ``True`` on success (sidecar written, or dry-run computed cleanly); ``False`` if the file was skipped due to an error. :rtype: bool """ logger.debug(f"_do_sidecar(path={path})") # TODO: check file exists and when not report error and processing error bmi: BidsMediaInfo = parse_bids_media_info(path) logger.debug(f"bmi: {bmi}") if not bmi.valid: errors = "; ".join(f"{e.code.value}: {e.message}" for e in bmi.errors) msg = f"{path}: invalid BIDS media file: {errors}" if ctx.strict: _error(ctx, msg) return False _warn(ctx, msg) props: Optional[dict] = None if ctx.videos_tsv: try: props = bids_properties_from_video_audit(path, ctx.videos_tsv) logger.debug(f"bids_props_video_audit: {props}") except Exception as e: _warn( ctx, f"{path}: bids_properties_from_video_audit failed ({e}), " f"falling back to ffprobe", ) props = None if props is None: props = bids_properties_from_ffprobe(path) logger.debug(f"bids_props_ffprobe: {props}") # --add overrides take priority over extracted fields. fields: Dict[str, Any] = dict(props) fields.update(ctx.ext_props) sidecar_path = os.path.splitext(path)[0] + ".json" existing: Dict[str, Any] = {} if ctx.mode == OverwriteMode.UPDATE and os.path.isfile(sidecar_path): try: with open(sidecar_path) as f: existing = json.load(f) except (OSError, json.JSONDecodeError) as e: _error(ctx, f"{sidecar_path}: failed to read existing sidecar: {e}") return False merged: Dict[str, Any] = dict(existing) for field, value in fields.items(): current = existing.get(field) if current is None or current == "n/a" or current == value: merged[field] = value continue # Conflict: existing has a different, non-"n/a" value. if ctx.conflict_policy == ConflictPolicy.ERROR: _error( ctx, f"{path}: conflicting value for {field!r}: " f"existing={current!r}, new={value!r}", ) return False logger.warning(f"{path}: overwriting {field!r}: {current!r} -> {value!r}") _verbose( ctx, f"Warning: {path}: overwriting {field!r}: {current!r} -> {value!r}" ) merged[field] = value if ctx.dry_run: if ctx.out_func: ctx.out_func(f"[DRY-RUN] {path} -> {sidecar_path}: {merged}") return True try: with open(sidecar_path, "w") as f: json.dump(merged, f, indent=2) except OSError as e: _error(ctx, f"{sidecar_path}: failed to write sidecar: {e}") return False _verbose(ctx, f"{path} -> {sidecar_path}: wrote {len(merged)} fields") return True def _do_sidecar_all(ctx: BisContext, files: List[str]) -> int: """Process every path in *files*, writing/updating its sidecar. :param ctx: Processing context. :type ctx: BisContext :param files: Audio/video file paths to process. :type files: List[str] :return: Number of files that errored (invalid path, or a failure reported by :func:`_do_sidecar`). :rtype: int """ errors = 0 for path in files: if os.path.isfile(path): if not _do_sidecar(ctx, path): errors += 1 else: _error(ctx, f"Skipping invalid path: {path}") errors += 1 return errors
[docs] def do_main( files: List[str], videos: Optional[str], mode: str, add_meta: List[str], existing_different: str, strict: bool, dry_run: bool, verbose: bool, out_func: Callable, ) -> int: """Main entry point for the bids-inject-sidecar command. For each file in ``files``, extracts BIDS media-file metadata (see :func:`_do_sidecar`: prefers the ``videos.tsv`` cache via ``bids_properties_from_video_audit`` when ``videos`` is given, falling back to ``ffprobe`` on lookup failure or when ``videos`` is not given), merges in ``add`` overrides, and writes/updates the corresponding ``.json`` sidecar according to ``mode`` and ``existing_different``. :param files: Audio/video file paths from the CLI ``FILES`` argument. :type files: List[str] :param videos: Optional path to ``videos.tsv`` (produced by ``video-audit``) used to look up cached fields instead of re-running ``ffprobe``. :type videos: Optional[str] :param mode: Sidecar write mode, ``"replace"`` or ``"update"``. :type mode: str :param add_meta: Raw values from repeated ``--add`` options — each either a ``META=VALUE`` pair or a bare JSON object string. See :func:`_parse_ext_props`. :type add_meta: List[str] :param existing_different: Conflict policy when a field already exists in the sidecar with a different value, ``"error"`` or ``"overwrite"``. :type existing_different: str :param strict: When ``True``, an invalid BidsMediaInfo (per ``parse_bids_media_info``) is a fatal error for that file. When ``False`` (default), the problem is reported as a warning and processing continues. :type strict: bool :param dry_run: When ``True``, compute but do not write any sidecar. :type dry_run: bool :param verbose: Enable verbose output. :type verbose: bool :param out_func: Callable used to print user-facing output (e.g. ``click.echo``). :type out_func: Callable :return: Exit code (0 on success, non-zero on error). :rtype: int """ try: ext_props = _parse_ext_props(add_meta) logger.debug(f"ext_props={ext_props}") except ValueError as e: logger.error(str(e)) if out_func: out_func(f"Error: {e}") return 1 ctx: BisContext = BisContext( mode=OverwriteMode(mode), conflict_policy=ConflictPolicy(existing_different), videos_tsv=videos, strict=strict, dry_run=dry_run, verbose=verbose, out_func=out_func, ext_props=ext_props, ) error_count = _do_sidecar_all(ctx, files) n = len(files) prefix = "[DRY-RUN] " if dry_run else "" summary_line = ( f"{prefix}{n} processed, {n - error_count} written, {error_count} errors" ) logger.debug(summary_line) if out_func: out_func(summary_line) return 1 if error_count > 0 else 0