From 0e43a121bc83029365b124027cc6c0299b35e169 Mon Sep 17 00:00:00 2001 From: Kay Robbins <1189050+VisLab@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:51:05 -0600 Subject: [PATCH] Added a validate sidecar script --- hed/cli/cli.py | 150 ++++++++++++++++++-- hed/scripts/validate_hed_sidecar.py | 157 +++++++++++++++++++++ pyproject.toml | 1 + tests/scripts/test_validate_hed_sidecar.py | 120 ++++++++++++++++ tests/test_cli_parameter_parity.py | 43 +++++- 5 files changed, 459 insertions(+), 12 deletions(-) create mode 100644 hed/scripts/validate_hed_sidecar.py create mode 100644 tests/scripts/test_validate_hed_sidecar.py diff --git a/hed/cli/cli.py b/hed/cli/cli.py index 11830c39..0b7c542a 100644 --- a/hed/cli/cli.py +++ b/hed/cli/cli.py @@ -251,7 +251,7 @@ def validate_bids_cmd( @validate.command( - name="hed-string", + name="string", epilog=""" This command validates a HED annotation string against a specified HED schema version. It can optionally process definitions and check for warnings in addition @@ -260,25 +260,25 @@ def validate_bids_cmd( \b Examples: # Basic validation of a HED string - hedpy validate hed-string "Event, (Sensory-event, (Visual-presentation, (Computer-screen, Face)))" -sv 8.3.0 + hedpy validate string "Event, (Sensory-event, (Visual-presentation, (Computer-screen, Face)))" -sv 8.3.0 # Validate with definitions - hedpy validate hed-string "Event, Def/MyDef" -sv 8.4.0 -d "(Definition/MyDef, (Action, Move))" + hedpy validate string "Event, Def/MyDef" -sv 8.4.0 -d "(Definition/MyDef, (Action, Move))" # Validate with multiple schemas (base + library) - hedpy validate hed-string "Event, Action" -sv 8.3.0 -sv score_1.1.0 + hedpy validate string "Event, Action" -sv 8.3.0 -sv score_1.1.0 # Check for warnings as well as errors - hedpy validate hed-string "Event, Action/Button-press" -sv 8.4.0 --check-for-warnings + hedpy validate string "Event, Action/Button-press" -sv 8.4.0 --check-for-warnings # Save validation results to a file - hedpy validate hed-string "Event" -sv 8.4.0 -o validation_results.txt + hedpy validate string "Event" -sv 8.4.0 -o validation_results.txt # Output results in JSON format - hedpy validate hed-string "Event, Action" -sv 8.4.0 -f json + hedpy validate string "Event, Action" -sv 8.4.0 -f json # Verbose output with informational messages - hedpy validate hed-string "Event, (Action, Move)" -sv 8.4.0 --verbose + hedpy validate string "Event, (Action, Move)" -sv 8.4.0 --verbose """, ) @click.argument("hed_string") @@ -404,6 +404,140 @@ def validate_hed_string_cmd( ctx.exit(result if result is not None else 0) +@validate.command( + name="sidecar", + epilog=""" +This command validates a BIDS JSON sidecar file against a specified HED schema +version. + +\b +Examples: + # Basic HED validation of a BIDS sidecar + hedpy validate sidecar path/to/sidecar.json -sv 8.3.0 + + # Validate with multiple schemas (base + library) + hedpy validate sidecar path/to/sidecar.json -sv 8.3.0 -sv score_1.1.0 + + # Check for warnings as well as errors + hedpy validate sidecar path/to/sidecar.json -sv 8.4.0 --check-for-warnings + + # Save validation results to a file + hedpy validate sidecar path/to/sidecar.json -sv 8.4.0 -o validation_results.txt +""", +) +@click.argument("sidecar_file", type=click.Path(exists=True)) +# Validation options +@optgroup.group("Validation options") +@optgroup.option( + "-sv", + "--schema-version", + required=True, + multiple=True, + metavar="VERSION", + help="HED schema version(s) to validate against (e.g., '8.4.0'). Can be specified multiple times for multiple schemas (e.g., -sv lang_1.1.0 -sv score_2.1.0)", +) +@optgroup.option( + "-w", + "--check-for-warnings", + is_flag=True, + help="Check for warnings as well as errors", +) +# Output options +@optgroup.group("Output options") +@optgroup.option( + "-f", + "--format", + type=click.Choice(["text", "json"]), + default="text", + show_default="text", + help="Output format for validation results (text: human-readable; json: structured format for programmatic use)", +) +@optgroup.option( + "-o", + "--output-file", + type=click.Path(), + default="", + metavar=METAVAR_FILE, + help="Path for output file to hold validation results; if not specified, output to stdout", +) +# Logging options +@optgroup.group("Logging options") +@optgroup.option( + "-l", + "--log-level", + type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), + default="WARNING", + show_default="WARNING", + help="Log level for diagnostic messages", +) +@optgroup.option( + "-v", + "--verbose", + is_flag=True, + help="Output informational messages (equivalent to --log-level INFO)", +) +@optgroup.option( + "-lf", + "--log-file", + type=click.Path(), + metavar=METAVAR_FILE, + help="File path for saving log output; logs still go to stderr unless --log-quiet is also used", +) +@optgroup.option( + "-lq", + "--log-quiet", + is_flag=True, + help="Suppress log output to stderr; only applicable when --log-file is used (logs go only to file)", +) +@optgroup.option( + "--no-log", + is_flag=True, + help="Disable all logging output", +) +@click.pass_context +def validate_sidecar_cmd( + ctx, + sidecar_file, + schema_version, + check_for_warnings, + format, + output_file, + log_level, + log_file, + log_quiet, + no_log, + verbose, +): + """Validate HED in a BIDS sidecar file. + + SIDECAR_FILE: The path to the BIDS sidecar file to validate. + """ + from hed.scripts.validate_hed_sidecar import main as validate_sidecar_main + + args = [sidecar_file] + for version in schema_version: + args.extend(["-sv", version]) + if check_for_warnings: + args.append("-w") + if format: + args.extend(["-f", format]) + if output_file: + args.extend(["-o", output_file]) + if log_level: + args.extend(["-l", log_level]) + if log_file: + args.extend(["-lf", log_file]) + if log_quiet: + args.append("-lq") + if no_log: + args.append("--no-log") + if verbose: + args.append("-v") + + result = validate_sidecar_main(args) + ctx.exit(result if result is not None else 0) + + @schema.command(name="validate") @click.argument("schema_path", type=click.Path(exists=True), nargs=-1, required=True) @click.option("--add-all-extensions", is_flag=True, help="Always verify all versions of the same schema are equal") diff --git a/hed/scripts/validate_hed_sidecar.py b/hed/scripts/validate_hed_sidecar.py new file mode 100644 index 00000000..0eb72555 --- /dev/null +++ b/hed/scripts/validate_hed_sidecar.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python +""" +Validates a BIDS sidecar against a specified schema version. + +This script validates HED in a BIDS JSON sidecar file +against a specified HED schema version. +""" + +import argparse +import sys +import os +from hed.models import Sidecar +from hed.errors import ErrorHandler +from hed.schema import load_schema_version +from hed.scripts.script_utils import setup_logging, format_validation_results + + +def get_parser(): + """Create the argument parser for validate_hed_sidecar. + + Returns: + argparse.ArgumentParser: Configured argument parser. + """ + parser = argparse.ArgumentParser( + description="Validate a BIDS sidecar file against a HED schema", formatter_class=argparse.RawDescriptionHelpFormatter + ) + + # Required arguments + parser.add_argument("sidecar_file", help="BIDS sidecar file to validate") + parser.add_argument( + "-sv", + "--schema-version", + required=True, + nargs="+", + dest="schema_version", + help="HED schema version(s) to validate against (e.g., '8.4.0' or '8.3.0 score_1.1.0' for multiple schemas)", + ) + + # Optional arguments + parser.add_argument( + "-w", + "--check-for-warnings", + action="store_true", + dest="check_for_warnings", + help="Check for warnings in addition to errors", + ) + + # Output options + output_group = parser.add_argument_group("Output options") + output_group.add_argument( + "-f", + "--format", + choices=["text", "json"], + default="text", + help="Output format for validation results (default: %(default)s)", + ) + output_group.add_argument( + "-o", + "--output-file", + default="", + dest="output_file", + help="Output file for validation results; if not specified, output to stdout", + ) + + # Logging options + logging_group = parser.add_argument_group("Logging options") + logging_group.add_argument( + "-l", + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + default="WARNING", + dest="log_level", + help="Logging level (default: %(default)s)", + ) + logging_group.add_argument("-lf", "--log-file", default="", dest="log_file", help="File path for saving log output") + logging_group.add_argument( + "-lq", "--log-quiet", action="store_true", dest="log_quiet", help="Suppress log output to stderr when using --log-file" + ) + logging_group.add_argument("--no-log", action="store_true", dest="no_log", help="Disable all logging output") + logging_group.add_argument("-v", "--verbose", action="store_true", help="Output informational messages") + + return parser + + +def main(arg_list=None): + """Main function for validating a BIDS sidecar. + + Parameters: + arg_list (list or None): Command line arguments. + """ + parser = get_parser() + args = parser.parse_args(arg_list) + + # Set up logging + setup_logging(args.log_level, args.log_file, args.log_quiet, args.verbose, args.no_log) + + import logging + + logger = logging.getLogger("validate_hed_sidecar") + effective_level_name = logging.getLevelName(logger.getEffectiveLevel()) + logger.info( + "Starting BIDS sidecar HED validation with effective log level: %s (requested: %s, verbose=%s)", + effective_level_name, + args.log_level, + "on" if args.verbose else "off", + ) + + try: + # Load schema (handle single version or list of versions) + schema_versions = args.schema_version[0] if len(args.schema_version) == 1 else args.schema_version + logging.info(f"Loading HED schema version(s) {schema_versions}") + schema = load_schema_version(schema_versions) + + # Parse Sidecar + logging.info("Loading BIDS sidecar file") + sidecar = Sidecar(args.sidecar_file, name=os.path.basename(args.sidecar_file)) + + # Validate BIDS sidecar + logging.info("Validating BIDS sidecar") + error_handler = ErrorHandler(check_for_warnings=args.check_for_warnings) + issues = sidecar.validate(schema, name=sidecar.name, error_handler=error_handler) + + # Handle output + if issues: + # Format validation errors + output = format_validation_results( + issues, output_format=args.format, title_message="BIDS sidecar validation errors:" + ) + + # Write output + if args.output_file: + with open(args.output_file, "w") as f: + f.write(output) + logging.info(f"Validation errors written to {args.output_file}") + else: + print(output) + + return 1 # Exit with error code if validation failed + else: + # Success message + success_msg = "BIDS sidecar has valid HED!" + if args.output_file: + with open(args.output_file, "w") as f: + f.write(success_msg + "\n") + logging.info(f"Validation results written to {args.output_file}") + else: + print(success_msg) + + return 0 + + except Exception as e: + logging.error(f"Validation failed: {str(e)}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index e2e093e6..5e5f5fcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ hedpy = "hed.cli.cli:main" # Legacy commands (deprecated - use 'hedpy' instead) validate_bids = "hed.scripts.validate_bids:main" validate_hed_string = "hed.scripts.validate_hed_string:main" +validate_hed_sidecar = "hed.scripts.validate_hed_sidecar:main" hed_extract_bids_sidecar = "hed.scripts.hed_extract_bids_sidecar:main" hed_validate_schemas = "hed.scripts.validate_schemas:main" hed_update_schemas = "hed.scripts.hed_convert_schema:main" diff --git a/tests/scripts/test_validate_hed_sidecar.py b/tests/scripts/test_validate_hed_sidecar.py new file mode 100644 index 00000000..0b26f5dd --- /dev/null +++ b/tests/scripts/test_validate_hed_sidecar.py @@ -0,0 +1,120 @@ +"""Tests for validate_hed_sidecar script.""" + +import os +import io +import json +import unittest +import tempfile +from unittest.mock import patch +from hed.scripts.validate_hed_sidecar import main + + +class TestValidateHedSidecar(unittest.TestCase): + """Test validate_hed_sidecar script functionality.""" + + def setUp(self): + """Set up test fixtures.""" + # Create a temporary valid sidecar file + self.valid_sidecar_content = { + "event_type": { + "HED": { + "show_face": "Sensory-event, Visual-presentation, Face", + "press_button": "Agent-action, Participant-response, Press", + } + } + } + self.valid_sidecar_file = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") + json.dump(self.valid_sidecar_content, self.valid_sidecar_file) + self.valid_sidecar_file.close() + + # Create a temporary invalid sidecar file + self.invalid_sidecar_content = {"event_type": {"HED": {"show_face": "InvalidTag"}}} + self.invalid_sidecar_file = tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") + json.dump(self.invalid_sidecar_content, self.invalid_sidecar_file) + self.invalid_sidecar_file.close() + + def tearDown(self): + """Clean up test fixtures.""" + if os.path.exists(self.valid_sidecar_file.name): + os.remove(self.valid_sidecar_file.name) + if os.path.exists(self.invalid_sidecar_file.name): + os.remove(self.invalid_sidecar_file.name) + + def test_valid_sidecar(self): + """Test validation of a valid HED sidecar.""" + arg_list = [self.valid_sidecar_file.name, "-sv", "8.3.0", "--no-log"] + + with patch("sys.stdout", new=io.StringIO()) as mock_stdout: + result = main(arg_list) + output = mock_stdout.getvalue() + + self.assertEqual(result, 0, "Valid sidecar should return 0") + self.assertIn("valid", output.lower()) + + def test_invalid_sidecar(self): + """Test validation of an invalid HED sidecar.""" + arg_list = [self.invalid_sidecar_file.name, "-sv", "8.3.0", "--no-log"] + + with patch("sys.stdout", new=io.StringIO()) as mock_stdout: + result = main(arg_list) + output = mock_stdout.getvalue() + + self.assertEqual(result, 1, "Invalid sidecar should return 1") + self.assertIn("error", output.lower()) + + def test_json_output(self): + """Test JSON output format.""" + arg_list = [self.invalid_sidecar_file.name, "-sv", "8.3.0", "-f", "json", "--no-log"] + + with patch("sys.stdout", new=io.StringIO()) as mock_stdout: + result = main(arg_list) + output = mock_stdout.getvalue() + + self.assertEqual(result, 1) + # Should be valid JSON + try: + json.loads(output) + except json.JSONDecodeError: + self.fail("Output should be valid JSON") + + def test_output_file(self): + """Test writing output to a file.""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".txt") as f: + output_file = f.name + + try: + arg_list = [self.valid_sidecar_file.name, "-sv", "8.3.0", "-o", output_file, "--no-log"] + result = main(arg_list) + + self.assertEqual(result, 0) + self.assertTrue(os.path.exists(output_file)) + + with open(output_file, "r") as f: + content = f.read() + self.assertIn("valid", content.lower()) + finally: + if os.path.exists(output_file): + os.remove(output_file) + + def test_check_for_warnings(self): + """Test --check-for-warnings flag.""" + arg_list = [self.valid_sidecar_file.name, "-sv", "8.3.0", "-w", "--no-log"] + + with patch("sys.stdout", new=io.StringIO()): + result = main(arg_list) + # Just checking that it runs without crashing, as the valid sidecar might not have warnings + self.assertEqual(result, 0) + + def test_missing_file(self): + """Test handling of missing file.""" + arg_list = ["non_existent_file.json", "-sv", "8.3.0", "--no-log"] + + with patch("sys.stdout", new=io.StringIO()): + # The script catches exceptions and logs error, returns 1 + result = main(arg_list) + + self.assertEqual(result, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli_parameter_parity.py b/tests/test_cli_parameter_parity.py index dcb723e7..8040e31b 100644 --- a/tests/test_cli_parameter_parity.py +++ b/tests/test_cli_parameter_parity.py @@ -10,6 +10,7 @@ from hed.scripts.hed_extract_bids_sidecar import get_parser as get_extract_sidecar_parser from hed.scripts.extract_tabular_summary import get_parser as get_extract_summary_parser from hed.scripts.validate_schemas import get_parser as get_validate_schemas_parser +from hed.scripts.validate_hed_sidecar import get_parser as get_validate_sidecar_parser class TestCLIParameterParity(unittest.TestCase): @@ -229,8 +230,8 @@ def test_schema_validate_parameters(self): for orig_flag in original_flags: self.assertIn(orig_flag, cli_flags, f"Flag '{orig_flag}' from original parser not found in CLI") - def test_validate_hed_string_parameters(self): - """Test validate hed-string CLI parameters match validate_hed_string.py parser.""" + def test_validate_string_parameters(self): + """Test validate string CLI parameters match validate_hed_string.py parser.""" # Get original parser original_parser = get_validate_hed_string_parser() self._get_parser_options(original_parser) @@ -238,9 +239,9 @@ def test_validate_hed_string_parameters(self): # Get CLI command validate_group = cli.commands.get("validate") self.assertIsNotNone(validate_group, "validate command group not found") - cli_command = validate_group.commands.get("hed-string") + cli_command = validate_group.commands.get("string") - self.assertIsNotNone(cli_command, "validate hed-string command not found in CLI") + self.assertIsNotNone(cli_command, "validate string command not found in CLI") cli_opts = self._get_click_options(cli_command) # Check positional arguments (should have hed_string) @@ -263,6 +264,40 @@ def test_validate_hed_string_parameters(self): for flag in required_flags: self.assertIn(flag, cli_flags, f"Flag '{flag}' not found in CLI") + def test_validate_sidecar_parameters(self): + """Test validate sidecar CLI parameters match validate_hed_sidecar.py parser.""" + # Get original parser + original_parser = get_validate_sidecar_parser() + self._get_parser_options(original_parser) + + # Get CLI command + validate_group = cli.commands.get("validate") + self.assertIsNotNone(validate_group, "validate command group not found") + cli_command = validate_group.commands.get("sidecar") + + self.assertIsNotNone(cli_command, "validate sidecar command not found in CLI") + cli_opts = self._get_click_options(cli_command) + + # Check positional arguments (should have sidecar_file) + self.assertEqual( + len(cli_opts["positional"]), 1, f"Should have 1 positional argument, got {len(cli_opts['positional'])}" + ) + self.assertEqual(cli_opts["positional"][0], "sidecar_file", "Positional should be sidecar_file") + + # Check that key optional parameters exist (definitions is NOT in sidecar validation) + required_params = ["schema_version", "format", "output_file", "log_level", "log_file"] + cli_dests = set(cli_opts["optional"].keys()) + + for param in required_params: + self.assertIn(param, cli_dests, f"Parameter '{param}' not found in CLI") + + # Check flags + required_flags = {"check_for_warnings", "log_quiet", "no_log", "verbose"} + cli_flags = {flag[0] for flag in cli_opts["flags"]} + + for flag in required_flags: + self.assertIn(flag, cli_flags, f"Flag '{flag}' not found in CLI") + def test_schema_add_ids_parameters(self): """Test schema add-ids uses positional arguments.""" schema_group = cli.commands.get("schema")