fw-gear-session-validator 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """The fw_gear_{{gear_package}} package."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ try:
6
+ __version__ = version(__package__)
7
+ except: # noqa: E722
8
+ pass
@@ -0,0 +1,322 @@
1
+ """
2
+ Simple JSONSchema Error Handler
3
+
4
+ A lightweight approach that extracts key error information
5
+
6
+ This approach:
7
+ 1. Extracts validator type
8
+ 2. Extracts schema path
9
+ 3. Extracts instance path
10
+ 4. Cleans error.message by removing instance representation
11
+ 5. Extracts description from schema by walking up schema path
12
+
13
+ """
14
+
15
+ from typing import Any, Dict, Optional, Sequence, Tuple
16
+
17
+ from jsonschema.exceptions import ValidationError
18
+
19
+
20
+ def _resolve_path(root: Any, path: Sequence[Any]) -> Any:
21
+ """Resolve a path within a nested data structure.
22
+
23
+ Args:
24
+ root: The root data structure (dict/list)
25
+ path: Sequence of keys/indices to traverse
26
+
27
+ Returns:
28
+ The value at the specified path
29
+ """
30
+ node = root
31
+ for key in path:
32
+ node = node[key]
33
+ return node
34
+
35
+
36
+ def nearest_description(
37
+ schema_root: dict, abs_schema_path: Sequence[Any]
38
+ ) -> Tuple[Optional[str], Optional[Sequence[Any]]]:
39
+ """Find the nearest description by walking up the schema path.
40
+
41
+ JSONSchema validators may fail at a node that doesn't have a description,
42
+ but a parent node (e.g., an allOf element) often does. This function
43
+ walks up from the failing node to find the nearest description.
44
+
45
+ Args:
46
+ schema_root: The root schema dictionary
47
+ abs_schema_path: The absolute schema path from the validation error
48
+
49
+ Returns:
50
+ Tuple of (description string or None, path where found or None)
51
+ """
52
+ # Start at the failing node and walk up to the root
53
+ for i in range(len(abs_schema_path), -1, -1):
54
+ sub_path = abs_schema_path[:i]
55
+ try:
56
+ node = _resolve_path(schema_root, sub_path)
57
+ except (KeyError, IndexError, TypeError):
58
+ continue
59
+
60
+ if isinstance(node, dict):
61
+ desc = node.get("description")
62
+ if isinstance(desc, str) and desc.strip():
63
+ return desc, sub_path
64
+
65
+ return None, None
66
+
67
+
68
+ def create_simple_error_message(
69
+ error: ValidationError, schema_root: Optional[dict] = None
70
+ ) -> Dict[str, Any]:
71
+ """
72
+ Create a simple, clean error message from ValidationError.
73
+
74
+ This extracts the essential information and cleans the error message
75
+ by removing the instance representation that causes 1MB messages.
76
+
77
+ Args:
78
+ error: ValidationError from jsonschema
79
+ schema_root: The root schema dictionary (optional, for description extraction)
80
+
81
+ Returns:
82
+ Dict with keys:
83
+ - error_message: Cleaned error message without instance repr
84
+ - instance_path: Path in the data where error occurred
85
+ - label: Human-facing label (falls back to $ref)
86
+ - description: Human-readable description from schema (if found)
87
+ - schema_definition: Tail of validator_value[$ref] if present
88
+ - schema_path: Path in the schema where error occurred
89
+ - validator_type: Dict with 'type' and 'details' (details vary by type)
90
+ - validator_value: The constraint value from schema
91
+ """
92
+ # 1. Extract validator type
93
+ validator_type_str = error.validator
94
+
95
+ # 2. Extract schema path (where in schema the error is defined)
96
+ schema_path = list(error.absolute_schema_path)
97
+ schema_path_str = ".".join(str(p) for p in schema_path) if schema_path else "root"
98
+
99
+ # 3. Extract instance path (where in data the error occurred)
100
+ instance_path = list(error.absolute_path)
101
+ instance_path_str = (
102
+ ".".join(str(p) for p in instance_path) if instance_path else "root"
103
+ )
104
+
105
+ # 4. Get error message
106
+ original_message = error.message
107
+
108
+ # 5. Clean the message by removing instance representation
109
+ cleaned_message = clean_error_message(original_message, error.instance)
110
+
111
+ # 6. Extract validator value
112
+ validator_value = error.validator_value
113
+
114
+ # 7. Extract $ref and schema_definition if present
115
+ # NOTE: schema_definition is only populated when validator_value contains a $ref.
116
+ # For direct constraints (e.g., maximum: -9, pattern: "^[A-Z]+$"), schema_definition
117
+ # will remain None since they don't reference a definition.
118
+ schema_definition = None
119
+ label = None
120
+ if isinstance(validator_value, dict) and "$ref" in validator_value:
121
+ ref_value = validator_value["$ref"]
122
+ # Extract definition name from $ref path (e.g., "#/$defs/T1_acquisition" -> "T1_acquisition")
123
+ if "/" in ref_value:
124
+ schema_definition = ref_value.split("/")[-1]
125
+ else:
126
+ schema_definition = ref_value
127
+ label = schema_definition # Use definition as label
128
+
129
+ # 8. Generate label if not already set
130
+ if label is None:
131
+ label = _generate_label(validator_type_str, validator_value, instance_path)
132
+
133
+ # 9. Build validator_type with details
134
+ validator_type_obj = {
135
+ "type": validator_type_str,
136
+ "details": _extract_validator_details(error),
137
+ }
138
+
139
+ # 10. Extract description from schema by walking up the schema path
140
+ description = None
141
+ if schema_root is not None:
142
+ description, _ = nearest_description(schema_root, schema_path)
143
+
144
+ # 11. Build result
145
+ result = {
146
+ "error_message": cleaned_message,
147
+ "instance_path": instance_path_str,
148
+ "label": label,
149
+ "schema_path": schema_path_str,
150
+ "validator_type": validator_type_obj,
151
+ "validator_value": validator_value,
152
+ }
153
+
154
+ # Add schema_definition if present
155
+ if schema_definition is not None:
156
+ result["schema_definition"] = schema_definition
157
+
158
+ # Add description if found
159
+ if description is not None:
160
+ result["description"] = description
161
+
162
+ return result
163
+
164
+
165
+ def clean_error_message(message: str, instance: Any) -> str:
166
+ """
167
+ Clean error message by removing the instance representation.
168
+
169
+ JSONSchema error messages often follow patterns like:
170
+ "<instance repr> <description>"
171
+ "None of <instance repr> are valid under the given schema"
172
+
173
+ This function removes the instance repr part.
174
+
175
+ Args:
176
+ message: Original error message from jsonschema
177
+ instance: The instance value that failed validation
178
+
179
+ Returns:
180
+ Cleaned error message
181
+ """
182
+ # NEW SIMPLIFIED APPROACH: Remove instance repr from message
183
+ instance_repr = repr(instance)
184
+
185
+ # Check if instance repr is in the message
186
+ if instance_repr in message:
187
+ # Replace the instance repr with a placeholder
188
+ if isinstance(instance, list):
189
+ placeholder = f"[array of {len(instance)} items]"
190
+ elif isinstance(instance, dict):
191
+ placeholder = f"{{object with {len(instance)} keys}}"
192
+ else:
193
+ placeholder = f"<{type(instance).__name__}>"
194
+
195
+ message = message.replace(instance_repr, placeholder)
196
+
197
+ return message
198
+
199
+
200
+ def _extract_validator_details(error: ValidationError) -> Dict[str, Any]: # noqa: PLR0912
201
+ """
202
+ Extract validator-specific details using a hybrid approach:
203
+ - Custom logic for validators that need special handling
204
+ - Dynamic extraction of all constraint keywords from schema
205
+
206
+ Args:
207
+ error: ValidationError from jsonschema
208
+
209
+ Returns:
210
+ Dict with details extracted from error and schema
211
+ """
212
+ validator_type = error.validator
213
+ validator_value = error.validator_value
214
+ instance = error.instance
215
+ schema = error.schema
216
+
217
+ details = {}
218
+
219
+ # Step 1: Custom handling for validators that need special logic
220
+ if validator_type == "required":
221
+ # Calculate which specific fields are missing
222
+ if isinstance(validator_value, list) and isinstance(instance, dict):
223
+ missing = [field for field in validator_value if field not in instance]
224
+ details["missing"] = missing
225
+
226
+ elif validator_type == "type":
227
+ # Show expected vs found type
228
+ details["expected"] = validator_value
229
+ details["found"] = type(instance).__name__
230
+
231
+ elif validator_type == "pattern":
232
+ # Show pattern and preview of actual value
233
+ details["pattern"] = validator_value
234
+ if isinstance(instance, str):
235
+ details["preview"] = instance[:50] # First 50 chars
236
+
237
+ elif validator_type in ("anyOf", "oneOf", "allOf"):
238
+ # Count number of alternatives
239
+ if isinstance(validator_value, list):
240
+ details["alternatives"] = len(validator_value)
241
+
242
+ elif validator_type == "enum":
243
+ # Show allowed values
244
+ details["allowed_values"] = validator_value
245
+ details["found"] = instance
246
+
247
+ elif validator_type == "const":
248
+ # Show expected constant value
249
+ details["expected"] = validator_value
250
+ details["found"] = instance
251
+
252
+ # Step 2: Add instance size/length if applicable
253
+ if isinstance(instance, (list, dict, str)):
254
+ if "found" not in details: # Don't overwrite if already set
255
+ details["found"] = len(instance)
256
+
257
+ # Step 3: DYNAMIC EXTRACTION - Get all constraint keywords from schema
258
+ # Exclude structural/metadata keys that don't represent constraints
259
+ metadata_keys = {
260
+ "$schema",
261
+ "$id",
262
+ "$ref",
263
+ "$defs",
264
+ "definitions",
265
+ "title",
266
+ "description",
267
+ "examples",
268
+ "default",
269
+ "comment",
270
+ "type",
271
+ "properties",
272
+ "items",
273
+ "additionalProperties",
274
+ "patternProperties",
275
+ "additionalItems",
276
+ "required",
277
+ }
278
+
279
+ if isinstance(schema, dict):
280
+ for key, value in schema.items():
281
+ # Only add if:
282
+ # 1. Not a metadata/structural key
283
+ # 2. Not already in details (don't overwrite custom logic)
284
+ # 3. Not the current validator's keyword (avoid duplication with validator_value)
285
+ if (
286
+ key not in metadata_keys
287
+ and key not in details
288
+ and key != validator_type
289
+ ):
290
+ details[key] = value
291
+
292
+ return details
293
+
294
+
295
+ def _generate_label(
296
+ validator_type: str, validator_value: Any, instance_path: list
297
+ ) -> str:
298
+ """
299
+ Generate a human-facing label for the error.
300
+
301
+ Args:
302
+ validator_type: Type of validator that failed
303
+ validator_value: The constraint value
304
+ instance_path: Path to the instance that failed
305
+
306
+ Returns:
307
+ Human-readable label string
308
+ """
309
+ # Use the last element of instance_path if available
310
+ if instance_path:
311
+ return str(instance_path[-1])
312
+
313
+ # Fallback to validator type
314
+ if validator_type == "required":
315
+ if isinstance(validator_value, list) and validator_value:
316
+ return f"missing_{validator_value[0]}"
317
+ elif validator_type == "type":
318
+ return "type_mismatch"
319
+ elif validator_type == "contains":
320
+ return "contains_validation"
321
+
322
+ return validator_type
@@ -0,0 +1,104 @@
1
+ """Main module."""
2
+
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Tuple
7
+
8
+ import flywheel
9
+ from flywheel import Client as FWClient
10
+
11
+ from fw_gear_session_validator.parser import RunOptions
12
+ from fw_gear_session_validator.pdf_report import generate_pdf_report
13
+ from fw_gear_session_validator.session import build_session_dict
14
+ from fw_gear_session_validator.utils import (
15
+ check_for_qc_tags,
16
+ handle_existing_qc_tags,
17
+ save_report,
18
+ tag_session_qc_result,
19
+ )
20
+ from fw_gear_session_validator.validation import validate_json, validate_schema
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+
25
+ def run(
26
+ destination: dict,
27
+ output_dir: Path,
28
+ schema_path: Path,
29
+ client: FWClient,
30
+ options: RunOptions,
31
+ ) -> Tuple[int, list[dict], flywheel.Session]:
32
+ """Run the session validation.
33
+
34
+ Args:
35
+ destination: The destination container information.
36
+ output_dir: The output directory for saving reports.
37
+ schema_path: Path to the validation schema file.
38
+ client: The Flywheel client to interact with the API.
39
+ options: RunOptions with configurations and optional PDF links.
40
+
41
+ Returns:
42
+ Tuple
43
+ e_code (int): Exit code of the run.
44
+ validation_report (list[dict]): The validation report.
45
+ session_ (flywheel.Session): The Flywheel session object.
46
+
47
+ """
48
+
49
+ # Grab the destination container and parent to make sure we are at the
50
+ # session level
51
+ run_level = destination.parent["type"]
52
+ if run_level != "session":
53
+ raise RuntimeError(
54
+ f"Cannot run at {run_level} level. Please run at session level."
55
+ )
56
+
57
+ session_id = destination.parent["id"]
58
+ session_ = client.get_session(session_id)
59
+ log.info(f"Checking if session {session_.label} needs validation.")
60
+
61
+ # Check for existing session-validator tags
62
+ qc_tags = check_for_qc_tags(session_)
63
+ # If session is already validated, log and exit
64
+ if not handle_existing_qc_tags(session_, qc_tags, options.force_rerun):
65
+ return None, None, None
66
+
67
+ # Check if schema is valid
68
+ log.debug(f"Loading schema from {schema_path}")
69
+ with open(schema_path, "r") as schema_file:
70
+ schema = json.load(schema_file)
71
+ log.info("Validating schema...")
72
+ if not validate_schema(schema):
73
+ raise ValueError("Invalid JSON schema. Please check the schema file.")
74
+
75
+ log.info(f"Validating session {session_.label}.")
76
+
77
+ # Build session dictionary for validation
78
+ log.debug("Building session dictionary.")
79
+ session_dict = build_session_dict(session_)
80
+
81
+ validation_report, validation_report_meta = validate_json(session_dict, schema)
82
+
83
+ if options.save_validation_report:
84
+ log.info("Validation complete. Saving JSON report.")
85
+ save_report(output_dir, validation_report)
86
+ else:
87
+ log.info("Validation complete. Not saving JSON report as per configuration.")
88
+
89
+ if options.save_pdf_report:
90
+ log.info("Generating PDF report.")
91
+ generate_pdf_report(
92
+ output_dir, session_.label, validation_report, options.pdf_links
93
+ )
94
+ else:
95
+ log.info("Not generating PDF report as per configuration.")
96
+
97
+ # Tag session as PASS or FAIL
98
+ log.info("Tagging session with QC result.")
99
+ tag_session_qc_result(session_, passed=not len(validation_report))
100
+
101
+ # Return error code
102
+ # Return validation_report and session container for creating
103
+ # qc metadata object in run.py
104
+ return 0, validation_report_meta, session_
@@ -0,0 +1,138 @@
1
+ """Parser module to parse gear config.json."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Optional, Tuple
6
+
7
+ from fw_gear import GearContext as GearToolkitContext
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class PdfLinks:
12
+ """PDF report link metadata."""
13
+
14
+ session_path: str
15
+ job_url: str
16
+ session_url: str
17
+ schema_url: str
18
+ schema_filename: str
19
+ schema_version: int
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class RunOptions:
24
+ """Configuration options."""
25
+
26
+ save_validation_report: bool = False
27
+ save_pdf_report: bool = False
28
+ force_rerun: bool = False
29
+ pdf_links: Optional[PdfLinks] = None
30
+
31
+ def validate(self) -> None:
32
+ """Validate options."""
33
+ if self.save_pdf_report and self.pdf_links is None:
34
+ raise ValueError("save_pdf_report=True requires pdf_links.")
35
+
36
+
37
+ def build_pdf_links(gear_context: GearToolkitContext, destination: dict) -> PdfLinks:
38
+ """Build all PDF report link metadata.
39
+
40
+ Constructs session path, job URL, session URL, and schema info for PDF reports.
41
+
42
+ Args:
43
+ gear_context: The gear context containing client and config info.
44
+ destination: The destination analysis container.
45
+
46
+ Returns:
47
+ PdfLinks dataclass with all link metadata.
48
+ """
49
+ client = gear_context.client
50
+ session_id = destination.parent["id"]
51
+ session = client.get_session(session_id)
52
+
53
+ # Extract site URL (shared across all URL builders)
54
+ api_host = client._fw.api_client.configuration.host
55
+ site_url = api_host.rsplit("/api", 1)[0]
56
+
57
+ # Build session path: fw://group/project/subject/session
58
+ group = session.parents.group
59
+ project = client.get_project(session.parents.project)
60
+ subject = client.get_subject(session.parents.subject)
61
+ session_path = f"fw://{group}/{project.label}/{subject.label}/{session.label}"
62
+
63
+ # Build job URL
64
+ job_id = gear_context.config.job.get("id")
65
+ job_url = f"{site_url}/#/jobs/{job_id}" if job_id else ""
66
+
67
+ # Build session URL
68
+ session_url = (
69
+ f"{site_url}/#/projects/{session.parents.project}/sessions/{session_id}"
70
+ )
71
+
72
+ # Build schema info
73
+ schema_url, schema_filename, schema_version = "", "", 0
74
+ schema_input = gear_context.config.get_input("validation_schema")
75
+ if schema_input:
76
+ container_type = schema_input.get("hierarchy", {}).get("type", "")
77
+ container_id = schema_input.get("hierarchy", {}).get("id", "")
78
+ schema_filename = schema_input.get("location", {}).get("name", "")
79
+ file_id = schema_input.get("object", {}).get("file_id", "")
80
+
81
+ if file_id and container_id:
82
+ try:
83
+ file_info = client.get_file(file_id)
84
+ schema_version = getattr(file_info, "version", 0)
85
+ except Exception:
86
+ pass # Version is optional
87
+
88
+ if container_type and container_id:
89
+ schema_url = f"{site_url}/#/{container_type}s/{container_id}"
90
+
91
+ return PdfLinks(
92
+ session_path=session_path,
93
+ job_url=job_url,
94
+ session_url=session_url,
95
+ schema_url=schema_url,
96
+ schema_filename=schema_filename,
97
+ schema_version=schema_version,
98
+ )
99
+
100
+
101
+ def parse_config(
102
+ gear_context: GearToolkitContext,
103
+ ) -> Tuple[dict, Path, Path, RunOptions]:
104
+ """Parse context config.json object.
105
+
106
+ Args:
107
+ gear_context (GearToolkitContext): The gear context containing configuration.
108
+
109
+ Returns:
110
+ tuple:
111
+ destination: The destination container information.
112
+ output_dir: The output directory for saving reports.
113
+ schema_path: Path to the validation schema file.
114
+ options: RunOptions with flags and optional PDF links.
115
+ """
116
+ save_validation_report = gear_context.config.opts.get(
117
+ "save_validation_report", False
118
+ )
119
+ force_rerun = gear_context.config.opts.get("force_rerun", False)
120
+ save_pdf_report = gear_context.config.opts.get("save_pdf_report", False)
121
+ schema_path = gear_context.config.get_input_path("validation_schema")
122
+ if not schema_path:
123
+ raise ValueError("Validation schema is required.")
124
+
125
+ destination = gear_context.client.get_analysis(
126
+ gear_context.config.destination["id"]
127
+ )
128
+
129
+ pdf_links = build_pdf_links(gear_context, destination) if save_pdf_report else None
130
+
131
+ options = RunOptions(
132
+ save_validation_report=save_validation_report,
133
+ save_pdf_report=save_pdf_report,
134
+ force_rerun=force_rerun,
135
+ pdf_links=pdf_links,
136
+ )
137
+
138
+ return destination, Path(gear_context.output_dir), Path(schema_path), options