Skip to content

API Reference Index

API documentation for the signalJourney libraries.

API Reference

This section provides the API documentation for the signaljourney_validator package.

signaljourney_validator

ValidationErrorDetail dataclass

Represents a detailed validation error.

__post_init__()

Generate suggestions based on error type after initialization.

SignalJourneyValidationError

Bases: Exception

Custom exception for validation errors.

Validator

Validates a signalJourney JSON file or dictionary against the schema. Optionally performs BIDS context validation.

__init__(schema=None)

Initializes the Validator.

Parameters:

Name Type Description Default
schema Optional[Union[Path, str, JsonDict]]

Path to the schema file, the schema dictionary, or None to use the default schema. External file $refs will be automatically inlined during initialization.

None

validate(data, raise_exceptions=True, bids_context=None)

Validates the given data against the loaded signalJourney schema.

Parameters:

Name Type Description Default
data Union[Path, str, JsonDict]

Path to the JSON file, the JSON string, a dictionary representing the JSON data.

required
raise_exceptions bool

If True (default), raises SignalJourneyValidationError on the first failure. If False, returns a list of all validation errors found.

True
bids_context Optional[Path]

Optional Path to the BIDS dataset root directory. If provided, enables BIDS context validation checks (e.g., file existence relative to the root).

None

Returns:

Type Description
List[ValidationErrorDetail]

A list of ValidationErrorDetail objects if raise_exceptions is False

List[ValidationErrorDetail]

and validation fails. Returns an empty list if validation succeeds.

Raises:

Type Description
SignalJourneyValidationError

If validation fails and raise_exceptions is True.

FileNotFoundError

If data file/path does not exist.

TypeError

If data is not a Path, string, or dictionary.

Validator Module

signaljourney_validator.validator

Validator

Validates a signalJourney JSON file or dictionary against the schema. Optionally performs BIDS context validation.

Source code in src/signaljourney_validator/validator.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
class Validator:
    """
    Validates a signalJourney JSON file or dictionary against the schema.
    Optionally performs BIDS context validation.
    """

    _schema: JsonDict
    _validator: Draft202012Validator

    def __init__(
        self,
        schema: Optional[Union[Path, str, JsonDict]] = None,
    ):
        """
        Initializes the Validator.

        Args:
            schema: Path to the schema file, the schema dictionary, or None
                    to use the default schema. External file $refs will be
                    automatically inlined during initialization.
        """
        schema_path = self._get_schema_path(schema)
        initial_schema = self._load_schema_dict(schema, schema_path)

        # For simplicity, let's assume if it's a dict, it might not have relative refs,
        # or we use the default schema path's directory as a fallback base.
        base_resolve_path = (
            schema_path.parent if schema_path else DEFAULT_SCHEMA_PATH.parent
        )

        # Inline external $refs
        print("\n--- Inlining schema refs within Validator ---")
        loaded_cache = {}
        self._schema = inline_refs(initial_schema, base_resolve_path, loaded_cache)
        print("--- Inlining complete --- \n")

        # Debug: Check if refs are actually gone (optional)
        # import pprint
        # pprint.pprint(self._schema)

        # Initialize the validator with the resolved schema.
        try:
            # Check if schema is valid before creating validator
            Draft202012Validator.check_schema(self._schema)
            self._validator = Draft202012Validator(
                schema=self._schema,
            )
        except jsonschema.SchemaError as e:
            raise SignalJourneyValidationError(f"Invalid schema provided: {e}") from e

    def _get_schema_path(
        self, schema_input: Optional[Union[Path, str, JsonDict]]
    ) -> Optional[Path]:
        """Determines the Path object if schema is given as Path or str."""
        if schema_input is None:
            return DEFAULT_SCHEMA_PATH
        if isinstance(schema_input, Path):
            return schema_input
        if isinstance(schema_input, str):
            return Path(schema_input)
        return None

    def _load_schema_dict(
        self,
        schema_input: Optional[Union[Path, str, JsonDict]],
        schema_path: Optional[Path],
    ) -> JsonDict:
        """Loads the schema into a dictionary."""
        if isinstance(schema_input, dict):
            return schema_input.copy()  # Return a copy

        # Determine path to load from
        load_path = schema_path if schema_path else DEFAULT_SCHEMA_PATH

        if not load_path or not load_path.exists():
            raise FileNotFoundError(f"Schema file not found: {load_path}")
        try:
            with open(load_path, "r", encoding="utf-8") as f:
                return json.load(f)
        except Exception as e:
            raise IOError(f"Error reading schema file {load_path}: {e}") from e

    def validate(
        self,
        data: Union[Path, str, JsonDict],
        raise_exceptions: bool = True,
        bids_context: Optional[Path] = None,
    ) -> List[ValidationErrorDetail]:
        """
        Validates the given data against the loaded signalJourney schema.

        Args:
            data: Path to the JSON file, the JSON string, a dictionary
                  representing the JSON data.
            raise_exceptions: If True (default), raises SignalJourneyValidationError
                              on the first failure. If False, returns a list of
                              all validation errors found.
            bids_context: Optional Path to the BIDS dataset root directory.
                          If provided, enables BIDS context validation checks
                          (e.g., file existence relative to the root).

        Returns:
            A list of ValidationErrorDetail objects if raise_exceptions is False
            and validation fails. Returns an empty list if validation succeeds.

        Raises:
            SignalJourneyValidationError: If validation fails and
                                      raise_exceptions is True.
            FileNotFoundError: If data file/path does not exist.
            TypeError: If data is not a Path, string, or dictionary.
        """
        instance: JsonDict
        file_path_context: Optional[Path] = None  # For BIDS checks

        # --- Load Instance ---
        if isinstance(data, (Path, str)):
            file_path = Path(data)
            file_path_context = file_path  # Store for BIDS checks
            if not file_path.exists():
                raise FileNotFoundError(f"Data file not found: {file_path}")
            try:
                with open(file_path, "r", encoding="utf-8") as f:
                    instance = json.load(f)
            except json.JSONDecodeError as e:
                raise SignalJourneyValidationError(
                    f"Error decoding data JSON from {file_path}: {e}"
                ) from e
            except Exception as e:
                raise SignalJourneyValidationError(
                    f"Error loading data file from {file_path}: {e}"
                ) from e
        elif isinstance(data, dict):
            instance = data
        else:
            raise TypeError("Data must be a Path, string, or dictionary.")

        schema_errors: List[ValidationErrorDetail] = []
        bids_errors: List[ValidationErrorDetail] = []

        # --- Schema Validation ---
        # Use the internal validator's iter_errors method
        try:
            # The validator now uses the registry passed during __init__
            errors = sorted(self._validator.iter_errors(instance), key=lambda e: e.path)
            if errors:
                for error in errors:
                    # Convert jsonschema error to our custom format
                    detail = ValidationErrorDetail(
                        message=error.message,
                        path=list(error.path),
                        schema_path=list(error.schema_path),
                        validator=error.validator,
                        validator_value=error.validator_value,
                        instance_value=error.instance,
                        # suggestion is added by ValidationErrorDetail constructor
                    )
                    schema_errors.append(detail)
        except jsonschema.RefResolutionError as e:
            # This shouldn't happen if schema is fully resolved, but handle defensively
            print(f"DEBUG: Unexpected RefResolutionError: {e}")
            failed_ref = getattr(e, "ref", "[unknown ref]")
            raise SignalJourneyValidationError(
                f"Schema validation failed: Unexpectedly could not resolve "
                f"reference '{failed_ref}'"
            ) from e
        except jsonschema.SchemaError as e:
            # Catch schema errors separately from resolution errors
            raise SignalJourneyValidationError(f"Invalid schema: {e}") from e
        except Exception as e:
            # Capture the actual exception type for better debugging
            print(
                f"DEBUG: Unexpected validation error type: "
                f"{type(e).__name__}, Error: {e}"
            )
            # Reraise or wrap depending on desired behavior
            raise SignalJourneyValidationError(
                f"An unexpected error occurred during schema validation: {e}"
            ) from e

        # --- BIDS Context Validation (Optional) ---
        if bids_context:
            bids_errors = self._validate_bids_context(
                instance, file_path_context, bids_context
            )

        all_errors = schema_errors + bids_errors

        # --- Result Handling ---
        if all_errors:
            if raise_exceptions:
                raise SignalJourneyValidationError(
                    "Validation failed.", errors=all_errors
                )
            else:
                return all_errors
        else:
            return []  # Success

    def _validate_bids_context(
        self, instance: JsonDict, file_path: Optional[Path], bids_root: Path
    ) -> List[ValidationErrorDetail]:
        """Placeholder for BIDS context validation logic."""
        errors: List[ValidationErrorDetail] = []
        print(
            f"[INFO] BIDS context validation requested for {file_path} "
            f"within {bids_root} (Not implemented)"
        )

        # TODO: Implement BIDS checks using file_path and bids_root
        # Examples:
        # - Check if file_path is correctly placed within bids_root derivatives
        # - Check naming convention against BIDS standards (might need pybids)
        # - Check if files referenced in inputSources/outputTargets exist
        #   relative to bids_root
        # - Differentiate rules for root-level vs derivative-level journey files

        # Example error:
        # if some_bids_check_fails:
        #     errors.append(ValidationErrorDetail(
        #         message="BIDS Check Failed: Reason...",
        #         path=['relevant', 'path']
        #     ))

        return errors

__init__(schema=None)

Initializes the Validator.

Parameters:

Name Type Description Default
schema Optional[Union[Path, str, JsonDict]]

Path to the schema file, the schema dictionary, or None to use the default schema. External file $refs will be automatically inlined during initialization.

None
Source code in src/signaljourney_validator/validator.py
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def __init__(
    self,
    schema: Optional[Union[Path, str, JsonDict]] = None,
):
    """
    Initializes the Validator.

    Args:
        schema: Path to the schema file, the schema dictionary, or None
                to use the default schema. External file $refs will be
                automatically inlined during initialization.
    """
    schema_path = self._get_schema_path(schema)
    initial_schema = self._load_schema_dict(schema, schema_path)

    # For simplicity, let's assume if it's a dict, it might not have relative refs,
    # or we use the default schema path's directory as a fallback base.
    base_resolve_path = (
        schema_path.parent if schema_path else DEFAULT_SCHEMA_PATH.parent
    )

    # Inline external $refs
    print("\n--- Inlining schema refs within Validator ---")
    loaded_cache = {}
    self._schema = inline_refs(initial_schema, base_resolve_path, loaded_cache)
    print("--- Inlining complete --- \n")

    # Debug: Check if refs are actually gone (optional)
    # import pprint
    # pprint.pprint(self._schema)

    # Initialize the validator with the resolved schema.
    try:
        # Check if schema is valid before creating validator
        Draft202012Validator.check_schema(self._schema)
        self._validator = Draft202012Validator(
            schema=self._schema,
        )
    except jsonschema.SchemaError as e:
        raise SignalJourneyValidationError(f"Invalid schema provided: {e}") from e

validate(data, raise_exceptions=True, bids_context=None)

Validates the given data against the loaded signalJourney schema.

Parameters:

Name Type Description Default
data Union[Path, str, JsonDict]

Path to the JSON file, the JSON string, a dictionary representing the JSON data.

required
raise_exceptions bool

If True (default), raises SignalJourneyValidationError on the first failure. If False, returns a list of all validation errors found.

True
bids_context Optional[Path]

Optional Path to the BIDS dataset root directory. If provided, enables BIDS context validation checks (e.g., file existence relative to the root).

None

Returns:

Type Description
List[ValidationErrorDetail]

A list of ValidationErrorDetail objects if raise_exceptions is False

List[ValidationErrorDetail]

and validation fails. Returns an empty list if validation succeeds.

Raises:

Type Description
SignalJourneyValidationError

If validation fails and raise_exceptions is True.

FileNotFoundError

If data file/path does not exist.

TypeError

If data is not a Path, string, or dictionary.

Source code in src/signaljourney_validator/validator.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
def validate(
    self,
    data: Union[Path, str, JsonDict],
    raise_exceptions: bool = True,
    bids_context: Optional[Path] = None,
) -> List[ValidationErrorDetail]:
    """
    Validates the given data against the loaded signalJourney schema.

    Args:
        data: Path to the JSON file, the JSON string, a dictionary
              representing the JSON data.
        raise_exceptions: If True (default), raises SignalJourneyValidationError
                          on the first failure. If False, returns a list of
                          all validation errors found.
        bids_context: Optional Path to the BIDS dataset root directory.
                      If provided, enables BIDS context validation checks
                      (e.g., file existence relative to the root).

    Returns:
        A list of ValidationErrorDetail objects if raise_exceptions is False
        and validation fails. Returns an empty list if validation succeeds.

    Raises:
        SignalJourneyValidationError: If validation fails and
                                  raise_exceptions is True.
        FileNotFoundError: If data file/path does not exist.
        TypeError: If data is not a Path, string, or dictionary.
    """
    instance: JsonDict
    file_path_context: Optional[Path] = None  # For BIDS checks

    # --- Load Instance ---
    if isinstance(data, (Path, str)):
        file_path = Path(data)
        file_path_context = file_path  # Store for BIDS checks
        if not file_path.exists():
            raise FileNotFoundError(f"Data file not found: {file_path}")
        try:
            with open(file_path, "r", encoding="utf-8") as f:
                instance = json.load(f)
        except json.JSONDecodeError as e:
            raise SignalJourneyValidationError(
                f"Error decoding data JSON from {file_path}: {e}"
            ) from e
        except Exception as e:
            raise SignalJourneyValidationError(
                f"Error loading data file from {file_path}: {e}"
            ) from e
    elif isinstance(data, dict):
        instance = data
    else:
        raise TypeError("Data must be a Path, string, or dictionary.")

    schema_errors: List[ValidationErrorDetail] = []
    bids_errors: List[ValidationErrorDetail] = []

    # --- Schema Validation ---
    # Use the internal validator's iter_errors method
    try:
        # The validator now uses the registry passed during __init__
        errors = sorted(self._validator.iter_errors(instance), key=lambda e: e.path)
        if errors:
            for error in errors:
                # Convert jsonschema error to our custom format
                detail = ValidationErrorDetail(
                    message=error.message,
                    path=list(error.path),
                    schema_path=list(error.schema_path),
                    validator=error.validator,
                    validator_value=error.validator_value,
                    instance_value=error.instance,
                    # suggestion is added by ValidationErrorDetail constructor
                )
                schema_errors.append(detail)
    except jsonschema.RefResolutionError as e:
        # This shouldn't happen if schema is fully resolved, but handle defensively
        print(f"DEBUG: Unexpected RefResolutionError: {e}")
        failed_ref = getattr(e, "ref", "[unknown ref]")
        raise SignalJourneyValidationError(
            f"Schema validation failed: Unexpectedly could not resolve "
            f"reference '{failed_ref}'"
        ) from e
    except jsonschema.SchemaError as e:
        # Catch schema errors separately from resolution errors
        raise SignalJourneyValidationError(f"Invalid schema: {e}") from e
    except Exception as e:
        # Capture the actual exception type for better debugging
        print(
            f"DEBUG: Unexpected validation error type: "
            f"{type(e).__name__}, Error: {e}"
        )
        # Reraise or wrap depending on desired behavior
        raise SignalJourneyValidationError(
            f"An unexpected error occurred during schema validation: {e}"
        ) from e

    # --- BIDS Context Validation (Optional) ---
    if bids_context:
        bids_errors = self._validate_bids_context(
            instance, file_path_context, bids_context
        )

    all_errors = schema_errors + bids_errors

    # --- Result Handling ---
    if all_errors:
        if raise_exceptions:
            raise SignalJourneyValidationError(
                "Validation failed.", errors=all_errors
            )
        else:
            return all_errors
    else:
        return []  # Success

inline_refs(schema, base_path, loaded_schemas_cache)

Recursively replace $ref keys with the content of the referenced file. Uses a cache (loaded_schemas_cache) to avoid infinite loops with circular refs and redundant file loading. Cache keys should be absolute POSIX paths of the schema files.

Source code in src/signaljourney_validator/validator.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
def inline_refs(
    schema: Union[Dict, list], base_path: Path, loaded_schemas_cache: Dict[str, Dict]
):
    """Recursively replace $ref keys with the content of the referenced file.
    Uses a cache (loaded_schemas_cache) to avoid infinite loops with circular refs
    and redundant file loading.
    Cache keys should be absolute POSIX paths of the schema files.
    """
    if isinstance(schema, dict):
        if (
            "$ref" in schema
            and isinstance(schema["$ref"], str)
            and not schema["$ref"].startswith("#")
        ):
            ref_path_str = schema["$ref"]
            # Resolve relative ref path against the current base path
            ref_path = (base_path / ref_path_str).resolve()

            # Cache key based on resolved absolute path
            cache_key = ref_path.as_posix()

            # Check cache first
            if cache_key in loaded_schemas_cache:
                # Return a copy to prevent modification issues during recursion
                return loaded_schemas_cache[cache_key].copy()

            # If not cached, load the file
            if ref_path.exists() and ref_path.is_file():
                try:
                    # print(
                    #     f"[Inline] Loading $ref: {ref_path_str} "
                    #     f"(from {base_path}) -> {ref_path}"
                    # )
                    with open(ref_path, "r", encoding="utf-8") as f:
                        ref_content = json.load(f)
                    # Store in cache BEFORE recursion to handle circular refs
                    loaded_schemas_cache[cache_key] = ref_content
                    # Recursively resolve refs *within* the loaded content
                    # Use the directory of the *referenced* file as the new base path
                    resolved_content = inline_refs(
                        ref_content, ref_path.parent, loaded_schemas_cache
                    )
                    # Update cache with the fully resolved content
                    loaded_schemas_cache[cache_key] = resolved_content
                    # Return a copy of the resolved content
                    return resolved_content.copy()
                except Exception as e:
                    print(
                        f"Warning: Failed to load or parse $ref: {ref_path_str} "
                        f"from {ref_path}. Error: {e}"
                    )
                    return schema  # Keep original $ref on error
            else:
                print(
                    f"Warning: $ref path does not exist or is not a file: "
                    f"{ref_path_str} -> {ref_path}"
                )
                return schema  # Keep original $ref if file not found
        else:
            # Recursively process other keys in the dictionary
            new_schema = {}
            for key, value in schema.items():
                new_schema[key] = inline_refs(value, base_path, loaded_schemas_cache)
            return new_schema
    elif isinstance(schema, list):
        # Recursively process items in the list
        return [inline_refs(item, base_path, loaded_schemas_cache) for item in schema]
    else:
        # Return non-dict/list items as is
        return schema

CLI Module

signaljourney_validator.cli

cli()

Signal Journey Validator CLI.

Provides tools to validate signalJourney JSON files against the official specification schema. Supports validating single files or entire directories.

Source code in src/signaljourney_validator/cli.py
24
25
26
27
28
29
30
31
32
33
34
35
36
@click.group(context_settings=dict(help_option_names=["-h", "--help"]))
@click.version_option(
    package_name="signaljourney-validator", prog_name="signaljourney-validate"
)
def cli():
    """
    Signal Journey Validator CLI.

    Provides tools to validate signalJourney JSON files against the official
    specification schema.
    Supports validating single files or entire directories.
    """
    pass

validate(path, schema, recursive, output_format, verbose, bids, bids_root)

Validate one or more signalJourney JSON files.

Checks conformance against the official signalJourney schema (or a custom schema if provided via --schema).

Examples:

Validate a single file:

signaljourney-validate path/to/sub-01_task-rest_signalJourney.json

Validate all files in a directory (non-recursively):

signaljourney-validate path/to/derivatives/pipelineX/

Validate all files recursively, outputting JSON:

signaljourney-validate -r -o json path/to/bids_dataset/

Validate with BIDS context checks:

signaljourney-validate --bids --bids-root path/to/bids_dataset \
    path/to/bids_dataset/derivatives/...
Source code in src/signaljourney_validator/cli.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
@cli.command()
@click.argument(
    "path",
    type=click.Path(exists=True, readable=True, resolve_path=True, path_type=Path),
)
@click.option(
    "--schema",
    "-s",
    type=click.Path(
        exists=True, dir_okay=False, readable=True, resolve_path=True, path_type=Path
    ),
    help="Path to a custom JSON schema file to validate against.",
)
@click.option(
    "--recursive",
    "-r",
    is_flag=True,
    default=False,
    help="Recursively search for *_signalJourney.json files in subdirectories.",
)
@click.option(
    "--output-format",
    "-o",
    type=click.Choice(["text", "json"], case_sensitive=False),
    default="text",
    help='Output format: "text" (human-readable, default) or "json" '
    "(machine-readable).",
)
@click.option(
    "--verbose",
    "-v",
    is_flag=True,
    default=False,
    help='Enable verbose output for the "text" format (shows more error details).',
)
@click.option(
    "--bids",
    is_flag=True,
    default=False,
    help="Enable BIDS context validation checks (experimental).",
)
@click.option(
    "--bids-root",
    type=click.Path(exists=True, file_okay=False, path_type=Path, resolve_path=True),
    help="Path to the BIDS dataset root directory (required if --bids is used).",
)
def validate(
    path: Path,
    schema: Path,
    recursive: bool,
    output_format: str,
    verbose: bool,
    bids: bool,
    bids_root: Path,
):
    """
    Validate one or more signalJourney JSON files.

    Checks conformance against the official signalJourney schema (or a custom schema
    if provided via --schema).

    Examples:

    Validate a single file:

        signaljourney-validate path/to/sub-01_task-rest_signalJourney.json

    Validate all files in a directory (non-recursively):

        signaljourney-validate path/to/derivatives/pipelineX/

    Validate all files recursively, outputting JSON:

        signaljourney-validate -r -o json path/to/bids_dataset/

    Validate with BIDS context checks:

        signaljourney-validate --bids --bids-root path/to/bids_dataset \\
            path/to/bids_dataset/derivatives/...
    """
    if bids and not bids_root:
        click.echo(
            "Error: --bids-root is required when using the --bids flag.", err=True
        )
        sys.exit(1)

    files_to_validate: List[Path] = []
    if path.is_file():
        # If a single file is provided, attempt to validate it if it's JSON,
        # regardless of the _signalJourney suffix.
        # Keep the suffix check for directory scanning.
        if path.name.lower().endswith(".json"):
            files_to_validate.append(path)
        elif output_format == "text":  # Only print skip message for non-JSON files
            click.echo(f"Skipping non-JSON file: {path}", err=True)
    elif path.is_dir():
        if output_format == "text":
            scan_mode = " recursively" if recursive else ""
            bids_mode = " (BIDS mode)" if bids else ""
            click.echo(f"Scanning directory: {path}{scan_mode}{bids_mode}")
        if recursive:
            # Keep the suffix check for recursive scanning
            for root, _, filenames in os.walk(path):
                for filename in filenames:
                    if filename.endswith("_signalJourney.json"):
                        files_to_validate.append(Path(root) / filename)
        else:
            # For non-recursive directory scan, find any .json file
            for item in path.iterdir():
                if item.is_file() and item.name.lower().endswith(".json"):
                    files_to_validate.append(item)
    else:
        # This error should occur regardless of format
        click.echo(
            f"Error: Input path is neither a file nor a directory: {path}", err=True
        )
        sys.exit(1)

    # Prepare results structure
    results: Dict[str, Any] = {"overall_success": True, "files": []}
    validator_instance: Optional[Validator] = None

    if not files_to_validate:
        if output_format == "text" and path.is_dir():
            # Only print if input was a directory and no files were found.
            scan_mode = " recursively" if recursive else ""
            click.echo(
                f"No *_signalJourney.json files found to validate in {path}{scan_mode}",
                err=True,
            )
        elif output_format == "json":
            # Output valid JSON even if no files processed
            print(json.dumps({"files": [], "overall_success": True}, indent=2))
        # Exit code 0 if input dir was empty, 1 otherwise (e.g., non-JSON file input)
        sys.exit(0 if path.is_dir() else 1)

    # --- Schema Loading and Resolver Setup (Load ONCE) ---
    # try:
    #     schema_to_use = schema if schema else DEFAULT_SCHEMA_PATH
    #     if not schema_to_use.exists():
    #         raise FileNotFoundError(f"Schema file not found: {schema_to_use}")
    #     with open(schema_to_use, "r", encoding="utf-8") as f:
    #         main_schema_dict = json.load(f)
    #
    #     # Setup resolver similar to conftest.py - REMOVED
    #     ...
    #
    #     resolver = jsonschema.RefResolver(...)
    # except Exception as e:
    #     click.echo(f"Error loading schema or building resolver: {e}", err=True)
    #     sys.exit(1)
    # --- End Schema Loading ---

    overall_success = True
    for filepath in files_to_validate:
        file_result: Dict[str, Any] = {
            "filepath": str(filepath),
            "status": "unknown",
            "errors": [],
        }
        if output_format == "text":
            click.echo(f"Validating: {filepath} ... ", nl=False)

        try:
            if validator_instance is None:
                # Create validator ONCE using the schema path (or None for default)
                # Validator internal __init__ now handles registry setup.
                try:
                    # Pass schema path/None
                    validator_instance = Validator(schema=schema)
                except Exception as e:
                    # Handle potential errors during Validator initialization
                    # (e.g., schema loading)
                    click.echo(f"CRITICAL ERROR initializing validator: {e}", err=True)
                    # For JSON output, log the critical error at file level
                    if output_format == "json":
                        file_result["status"] = "critical_error"
                        file_result["errors"] = [
                            {"message": f"Validator init failed: {e}"}
                        ]
                        results["files"].append(file_result)
                        results["overall_success"] = False
                        overall_success = False  # Ensure overall failure
                    # Exit or continue? Maybe continue to report errors for other files?
                    # For now, let's make it a fatal error for the specific file.
                    if output_format == "text":
                        click.echo(" CRITICAL ERROR")
                        click.echo(f"  - Initialization Failed: {e}")
                    # Skip to the next file if validator init fails
                    continue

            # Pass bids_root to validator if bids flag is set
            current_bids_context = bids_root if bids else None
            validation_errors = validator_instance.validate(
                filepath, raise_exceptions=False, bids_context=current_bids_context
            )

            if validation_errors:
                overall_success = False
                file_result["status"] = "failed"
                if output_format == "text":
                    click.secho("FAILED", fg="red")
                for error in validation_errors:
                    # Store structured error
                    error_dict = {
                        "message": error.message,
                        "path": list(error.path) if error.path else [],
                        "schema_path": list(error.schema_path)
                        if error.schema_path
                        else [],
                        "validator": error.validator,
                        "validator_value": repr(error.validator_value),  # Use repr
                        "instance_value": repr(error.instance_value),  # Use repr
                        "suggestion": error.suggestion,
                    }
                    file_result["errors"].append(error_dict)

                    # Print detailed error in text mode
                    if output_format == "text":
                        error_path_list = list(error.path) if error.path else []
                        error_path_str = (
                            "/".join(map(str, error_path_list))
                            if error_path_list
                            else "root"
                        )
                        error_msg = f"  - Error at '{error_path_str}': {error.message}"
                        if verbose:
                            # Add more details in verbose mode
                            error_msg += f" (validator: '{error.validator}')"
                            # Add more details if needed
                        if error.suggestion:
                            error_msg += f" -- Suggestion: {error.suggestion}"
                        click.echo(error_msg)
            else:
                file_result["status"] = "passed"
                if output_format == "text":
                    click.secho("PASSED", fg="green")

        except SignalJourneyValidationError as e:
            overall_success = False
            file_result["status"] = "error"
            file_result["error_message"] = str(e)
            if output_format == "text":
                click.secho("ERROR", fg="yellow")
                click.echo(f"  - Validation Error: {e}", err=True)
            # Include details from SignalJourneyValidationError if available
            detailed_errors = []
            if (
                isinstance(e, SignalJourneyValidationError)
                and hasattr(e, "errors")
                and e.errors
            ):
                detailed_errors = [
                    {
                        "message": detail.get("message", "N/A"),
                        "path": detail.get("path", []),
                        # Add other relevant fields from ValidationErrorDetail if needed
                    }
                    for detail in e.errors
                ]
                file_result["errors"] = detailed_errors  # Overwrite with details
                if output_format == "text":
                    click.echo("    Detailed Errors:", err=True)
                    for detail in detailed_errors:
                        path_str = detail.get("path", "N/A")
                        msg_str = detail.get("message", "N/A")
                        click.echo(f"    - Path: {path_str}, Msg: {msg_str}", err=True)

        except Exception as e:
            overall_success = False
            file_result["status"] = "error"
            file_result["error_message"] = f"An unexpected error occurred: {e}"
            if output_format == "text":
                click.secho("CRITICAL ERROR", fg="red", bold=True)
                click.echo(f"  - Unexpected Error: {e}", err=True)

        results["files"].append(file_result)

    results["overall_success"] = overall_success

    if output_format == "json":
        print(json.dumps(results, indent=2))
    elif output_format == "text" and verbose:
        # Add a summary line in verbose text mode
        status_msg = (
            click.style("PASSED", fg="green")
            if overall_success
            else click.style("FAILED", fg="red")
        )
        click.echo(f"\nOverall validation result: {status_msg}")

    # Exit with appropriate code
    sys.exit(0 if overall_success else 1)

Errors Module

signaljourney_validator.errors

ValidationErrorDetail dataclass

Represents a detailed validation error.

Source code in src/signaljourney_validator/errors.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
@dataclass
class ValidationErrorDetail:
    """Represents a detailed validation error."""

    message: str
    path: Sequence[JsonPath] = field(default_factory=list)
    schema_path: Sequence[JsonPath] = field(default_factory=list)
    validator: str = ""
    validator_value: Any = None
    instance_value: Any = None
    # For nested errors
    context: List["ValidationErrorDetail"] = field(default_factory=list)
    suggestion: Optional[str] = None  # Added field for suggestions

    def __post_init__(self):
        """Generate suggestions based on error type after initialization."""
        self._generate_suggestion()

    def __str__(self) -> str:
        path_str = "/".join(map(str, self.path)) if self.path else "root"
        msg = f"Error at '{path_str}': {self.message}"
        if self.suggestion:
            msg += f" -- Suggestion: {self.suggestion}"
        return msg

    def _generate_suggestion(self):
        """Internal method to populate the suggestion field based on validator type."""
        if self.validator == "required":
            # 'validator_value' usually holds the list of required properties
            missing_props = self.validator_value
            if isinstance(missing_props, list):
                props_str = "', '".join(missing_props)
                self.suggestion = (
                    f"Ensure required property or properties ('{props_str}') "
                    f"are present."
                )
            else:
                self.suggestion = (
                    "Ensure required property is present (check schema for details)."
                )

        elif self.validator == "type":
            expected_types = self.validator_value
            actual_type = type(self.instance_value).__name__
            if isinstance(expected_types, list):
                types_str = "', '".join(expected_types)
                self.suggestion = (
                    f"Change value type from '{actual_type}' to one of: '{types_str}'."
                )
            elif isinstance(expected_types, str):
                self.suggestion = (
                    f"Change value type from '{actual_type}' to '{expected_types}'."
                )
            else:
                self.suggestion = (
                    f"Check schema for expected type(s) instead of '{actual_type}'."
                )

        elif self.validator == "pattern":
            pattern = self.validator_value
            self.suggestion = (
                f"Ensure value matches the required regex pattern: '{pattern}'."
            )

        elif self.validator == "enum":
            allowed_values = self.validator_value
            if isinstance(allowed_values, list):
                suggestion_text = (
                    f"Value must be one of: {', '.join(map(repr, allowed_values))}."
                )
                # Optional: Add fuzzy matching
                if (
                    HAS_FUZZY
                    and isinstance(self.instance_value, str)
                    and self.instance_value
                ):
                    try:
                        # Filter for string choices
                        string_allowed_values = [
                            str(v) for v in allowed_values if isinstance(v, str)
                        ]
                        if string_allowed_values:
                            best_match, score = fuzzy_process.extractOne(
                                self.instance_value, string_allowed_values
                            )
                            if score > 80:  # Threshold
                                suggestion_text += f" Did you mean '{best_match}'?"
                    except Exception:
                        pass  # Ignore fuzzy matching errors
                self.suggestion = suggestion_text
            else:
                self.suggestion = (
                    "Ensure value is one of the allowed options (check schema)."
                )

        # Add suggestions for length/item constraints
        elif self.validator == "minLength":
            min_len = self.validator_value
            actual_len = (
                len(self.instance_value)
                if isinstance(self.instance_value, (str, list))
                else "N/A"
            )
            self.suggestion = (
                f"Ensure value has at least {min_len} characters/items "
                f"(currently {actual_len})."
            )

        elif self.validator == "maxLength":
            max_len = self.validator_value
            actual_len = (
                len(self.instance_value)
                if isinstance(self.instance_value, (str, list))
                else "N/A"
            )
            self.suggestion = (
                f"Ensure value has at most {max_len} characters/items "
                f"(currently {actual_len})."
            )

        elif self.validator == "minItems":
            min_num = self.validator_value
            actual_num = (
                len(self.instance_value)
                if isinstance(self.instance_value, list)
                else "N/A"
            )
            self.suggestion = (
                f"Ensure array has at least {min_num} items (currently {actual_num})."
            )

        elif self.validator == "maxItems":
            max_num = self.validator_value
            actual_num = (
                len(self.instance_value)
                if isinstance(self.instance_value, list)
                else "N/A"
            )
            self.suggestion = (
                f"Ensure array has at most {max_num} items (currently {actual_num})."
            )

        elif self.validator == "minimum":
            min_val = self.validator_value
            self.suggestion = f"Ensure value is at least {min_val}."

        elif self.validator == "maximum":
            max_val = self.validator_value
            self.suggestion = f"Ensure value is at most {max_val}."

        elif self.validator == "exclusiveMinimum":
            ex_min_val = self.validator_value
            self.suggestion = f"Ensure value is strictly greater than {ex_min_val}."

        elif self.validator == "exclusiveMaximum":
            ex_max_val = self.validator_value
            self.suggestion = f"Ensure value is strictly less than {ex_max_val}."

__post_init__()

Generate suggestions based on error type after initialization.

Source code in src/signaljourney_validator/errors.py
31
32
33
def __post_init__(self):
    """Generate suggestions based on error type after initialization."""
    self._generate_suggestion()

SignalJourneyValidationError

Bases: Exception

Custom exception for validation errors.

Source code in src/signaljourney_validator/errors.py
186
187
188
189
190
191
192
193
class SignalJourneyValidationError(Exception):
    """Custom exception for validation errors."""

    def __init__(
        self, message: str, errors: Optional[List[ValidationErrorDetail]] = None
    ):
        super().__init__(message)
        self.errors = errors or []