gen3-dataops-toolkit 2.0.0__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.
Files changed (53) hide show
  1. g3dt/__init__.py +0 -0
  2. g3dt/cli/__init__.py +5 -0
  3. g3dt/cli/_internal/__init__.py +1 -0
  4. g3dt/cli/_internal/dispatch.py +428 -0
  5. g3dt/cli/_internal/registry.py +65 -0
  6. g3dt/cli/_internal/resolve.py +22 -0
  7. g3dt/cli/_internal/runner.py +76 -0
  8. g3dt/cli/_internal/safety.py +110 -0
  9. g3dt/cli/config_cmds.py +202 -0
  10. g3dt/cli/delete_cmds.py +101 -0
  11. g3dt/cli/dict_cmds.py +102 -0
  12. g3dt/cli/ec2_cmds.py +114 -0
  13. g3dt/cli/indexd_cmds.py +57 -0
  14. g3dt/cli/jobs.py +83 -0
  15. g3dt/cli/k8s.py +54 -0
  16. g3dt/cli/main.py +110 -0
  17. g3dt/cli/metadata.py +76 -0
  18. g3dt/cli/synth.py +206 -0
  19. g3dt/config.py +393 -0
  20. g3dt/indexd/__init__.py +0 -0
  21. g3dt/indexd/indexd_registrar.py +244 -0
  22. g3dt/ingest/ingest.py +629 -0
  23. g3dt/resolver.py +163 -0
  24. g3dt/services/delete/delete_all_metadata_for_project.py +170 -0
  25. g3dt/services/delete/delete_metadata.sh +153 -0
  26. g3dt/services/delete/delete_metadata_by_guid.py +338 -0
  27. g3dt/services/dictionary/deploy_dd.sh +65 -0
  28. g3dt/services/dictionary/pull_dict.sh +59 -0
  29. g3dt/services/dictionary/upload_dictionary.py +109 -0
  30. g3dt/services/indexd/register_indexd.py +240 -0
  31. g3dt/services/k8s_ops/argocd_restart_etl.sh +140 -0
  32. g3dt/services/k8s_ops/argocd_restart_ms.sh +102 -0
  33. g3dt/services/k8s_ops/argocd_restart_schema.sh +106 -0
  34. g3dt/services/k8s_ops/login_to_pod.sh +110 -0
  35. g3dt/services/k8s_ops/restart_etl_and_ms.sh +56 -0
  36. g3dt/services/synthetic_data/delete_synth_metadata_sheepdog.py +183 -0
  37. g3dt/services/synthetic_data/full_deploy_dd_and_synth.sh +124 -0
  38. g3dt/services/synthetic_data/generate_synth_metadata.sh +133 -0
  39. g3dt/services/synthetic_data/upload_synth_metadata_sheepdog.py +165 -0
  40. g3dt/services/upload/metadata/upload_all_studies.sh +108 -0
  41. g3dt/services/upload/metadata/upload_metadata.py +152 -0
  42. g3dt/upload/__init__.py +1 -0
  43. g3dt/upload/metadata_deleter.py +265 -0
  44. g3dt/upload/metadata_submitter.py +1093 -0
  45. g3dt/upload/upload_synthdata_s3.py +164 -0
  46. g3dt/utils/athena_utils.py +834 -0
  47. g3dt/utils/dbt_utils.py +66 -0
  48. g3dt/utils/release_writer.py +188 -0
  49. g3dt/validate/validate.py +609 -0
  50. gen3_dataops_toolkit-2.0.0.dist-info/METADATA +125 -0
  51. gen3_dataops_toolkit-2.0.0.dist-info/RECORD +53 -0
  52. gen3_dataops_toolkit-2.0.0.dist-info/WHEEL +4 -0
  53. gen3_dataops_toolkit-2.0.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,609 @@
1
+ import gen3_validator
2
+ import os
3
+ import tempfile
4
+ import json
5
+ import boto3
6
+ import pandas as pd
7
+ import logging
8
+ import awswrangler as wr
9
+ import io
10
+ from botocore.exceptions import ClientError
11
+ from g3dt.utils.athena_utils import write_iceberg_to_db
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ def parse_s3_uri(s3_uri: str):
16
+ """
17
+ Parse an Amazon S3 URI into a bucket and key.
18
+
19
+ :param s3_uri: The S3 URI to parse. Example: 's3://my-bucket/my-folder/mykey.json'
20
+ :type s3_uri: str
21
+
22
+ :returns: Tuple containing the S3 bucket name and key.
23
+ :rtype: tuple[str, str]
24
+
25
+ :raises ValueError: If the URI does not start with 's3://' or is malformed.
26
+
27
+ .. note::
28
+ The returned key will include all path elements after the bucket, including folders.
29
+
30
+ **Example**
31
+
32
+ >>> parse_s3_uri('s3://my-bucket/data/file.csv')
33
+ ('my-bucket', 'data/file.csv')
34
+ """
35
+ if not s3_uri.startswith("s3://"):
36
+ logger.error("s3_uri must start with 's3://'")
37
+ raise ValueError("s3_uri must start with 's3://'")
38
+ _, _, bucket_and_key = s3_uri.partition("s3://")
39
+ bucket, _, key = bucket_and_key.partition("/")
40
+ if not bucket or not key:
41
+ logger.error("Malformed s3_uri: must be of the form 's3://bucket/key'")
42
+ raise ValueError("Malformed s3_uri: must be of the form 's3://bucket/key'")
43
+ return bucket, key
44
+
45
+ def get_s3_client():
46
+ """
47
+ Create and return a boto3 client for interacting with Amazon S3.
48
+
49
+ :returns: boto3 S3 client object for making S3 API requests.
50
+ :rtype: boto3.client
51
+
52
+ :raises Exception: If the boto3 client could not be created.
53
+
54
+ .. note::
55
+ In larger codebases, you might want to cache and reuse S3 clients
56
+ to maximize performance and conserve resources.
57
+ """
58
+ try:
59
+ return boto3.client('s3')
60
+ except Exception as e:
61
+ logger.error(f"Failed to create boto3 S3 client: {e}")
62
+ raise
63
+
64
+ def read_json_from_s3(s3_uri: str):
65
+ """
66
+ Download and parse a JSON file from S3.
67
+
68
+ :param s3_uri: Full S3 URI to the JSON file. Example: 's3://bucket/file.json'
69
+ :type s3_uri: str
70
+
71
+ :returns: Dictionary representation of the parsed JSON file.
72
+ :rtype: dict
73
+
74
+ :raises ClientError: If AWS S3 reports an error reading the object.
75
+ :raises Exception: If JSON parsing fails or any other exception occurs.
76
+
77
+ **Example**
78
+
79
+ >>> data = read_json_from_s3('s3://acdc-datasets/study.json')
80
+ >>> print(data['title'])
81
+ """
82
+ try:
83
+ bucket, key = parse_s3_uri(s3_uri)
84
+ s3 = get_s3_client()
85
+ obj = s3.get_object(Bucket=bucket, Key=key)
86
+ return json.load(obj['Body'])
87
+ except ClientError as ce:
88
+ logger.error(f"ClientError reading JSON from {s3_uri}: {ce}")
89
+ raise
90
+ except Exception as e:
91
+ logger.error(f"Failed to read JSON from {s3_uri}: {e}")
92
+ raise
93
+
94
+ def write_bytes_to_s3(bytes_data, s3_uri: str):
95
+ """
96
+ Write bytes (or a UTF-8 string) to a file in S3.
97
+
98
+ :param bytes_data: The data to write. Can be a str (encoded as UTF-8) or bytes.
99
+ :type bytes_data: Union[str, bytes]
100
+ :param s3_uri: S3 URI to write the data to, e.g. 's3://bucket/path/file.txt'
101
+ :type s3_uri: str
102
+
103
+ :raises Exception: If writing fails.
104
+
105
+ **Example**
106
+
107
+ >>> write_bytes_to_s3('example content', 's3://bucket/out.txt')
108
+ """
109
+ try:
110
+ bucket, key = parse_s3_uri(s3_uri)
111
+ s3 = get_s3_client()
112
+ s3.put_object(Bucket=bucket, Key=key, Body=bytes_data)
113
+ logger.info(f"Successfully wrote to {s3_uri}")
114
+ except Exception as e:
115
+ logger.error(f"Failed to write bytes to {s3_uri}: {e}")
116
+ raise
117
+
118
+ def download_s3_file(s3_uri: str, local_path: str):
119
+ """
120
+ Download a file from S3 and save it to the local filesystem.
121
+
122
+ :param s3_uri: Full S3 URI to file to download.
123
+ :type s3_uri: str
124
+ :param local_path: Absolute or relative path where file will be saved locally.
125
+ :type local_path: str
126
+
127
+ :raises ClientError: If the file cannot be retrieved from S3.
128
+ :raises Exception: For filesystem write errors and other issues.
129
+
130
+ **Example**
131
+
132
+ >>> download_s3_file('s3://bucket/data.csv', '/tmp/my_local_copy.csv')
133
+ """
134
+ bucket, key = parse_s3_uri(s3_uri)
135
+ s3 = get_s3_client()
136
+ try:
137
+ logger.info(f"Downloading {s3_uri} to {local_path}")
138
+ s3.download_file(bucket, key, local_path)
139
+ logger.debug(f"Downloaded: {local_path}")
140
+ except ClientError as ce:
141
+ logger.error(f"ClientError while downloading {s3_uri} to {local_path}: {ce}")
142
+ raise
143
+ except Exception as e:
144
+ logger.error(f"Failed to download {s3_uri} to {local_path}: {e}")
145
+ raise
146
+
147
+ def list_s3_objects(s3_uri: str):
148
+ """
149
+ List all objects/files under a given S3 prefix.
150
+
151
+ :param s3_uri: S3 URI for either a "directory" or a prefix to search.
152
+ Must start with 's3://'.
153
+ Example: 's3://my-bucket/folder/subfolder/'
154
+ :type s3_uri: str
155
+
156
+ :yields: Tuples of (bucket, key) for each object/file found.
157
+ :rtype: Iterator[tuple[str, str]]
158
+
159
+ :raises ClientError: If listing S3 objects fails.
160
+
161
+ **Example**
162
+
163
+ >>> for bucket, key in list_s3_objects('s3://bucket/validation/'):
164
+ ... print(key)
165
+ """
166
+ try:
167
+ bucket, prefix = parse_s3_uri(s3_uri)
168
+ if prefix and not prefix.endswith("/"):
169
+ prefix += "/"
170
+ s3 = get_s3_client()
171
+ paginator = s3.get_paginator('list_objects_v2')
172
+ for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
173
+ contents = page.get("Contents", [])
174
+ for obj in contents:
175
+ yield bucket, obj["Key"]
176
+ except ClientError as ce:
177
+ logger.error(f"ClientError listing objects for {s3_uri}: {ce}")
178
+ raise
179
+ except Exception as e:
180
+ logger.error(f"Error listing objects for {s3_uri}: {e}")
181
+ raise
182
+
183
+ def load_schema_from_s3_uri(s3_uri: str) -> dict:
184
+ """
185
+ Load a Gen3 JSON schema from S3 as a Python dictionary.
186
+
187
+ :param s3_uri: The full S3 URI to the schema JSON file.
188
+ :type s3_uri: str
189
+
190
+ :returns: Dictionary representation of the schema.
191
+ :rtype: dict
192
+
193
+ :raises Exception: If there are errors reading or parsing the file.
194
+
195
+ **Example**
196
+
197
+ >>> schema = load_schema_from_s3_uri('s3://bucket/schemas/project_schema.json')
198
+ """
199
+ logger.info(f"Loading schema from S3 URI: {s3_uri}")
200
+ try:
201
+ schema = read_json_from_s3(s3_uri)
202
+ logger.info(f"Schema loaded successfully from {s3_uri}")
203
+ return schema
204
+ except Exception as e:
205
+ logger.error(f"Could not load schema from {s3_uri}: {e}")
206
+ raise
207
+
208
+ def write_schema_to_temp_file(schema: dict, filename: str = "schema.json") -> str:
209
+ """
210
+ Write a schema dictionary to a temp file (as formatted JSON).
211
+
212
+ :param schema: The schema data, typically loaded from S3.
213
+ :type schema: dict
214
+ :param filename: The filename to use within the temporary directory. Default: "schema.json".
215
+ :type filename: str, optional
216
+
217
+ :returns: The full absolute file path to the written file.
218
+ :rtype: str
219
+
220
+ :raises Exception: If directory creation or file writing fails.
221
+
222
+ **Example**
223
+
224
+ >>> path = write_schema_to_temp_file(schema_dict)
225
+ >>> print(open(path).read())
226
+ """
227
+ try:
228
+ temp_dir = os.path.join("/tmp", next(tempfile._get_candidate_names()))
229
+ os.makedirs(temp_dir, exist_ok=True)
230
+ schema_path = os.path.join(temp_dir, filename)
231
+ logger.info(f"Writing schema to temp file: {schema_path}")
232
+ with open(schema_path, "w", encoding="utf-8") as f:
233
+ json.dump(schema, f, indent=2)
234
+ logger.info(f"Schema written to: {schema_path}")
235
+ return os.path.abspath(schema_path)
236
+ except Exception as e:
237
+ logger.error(f"Failed to write schema to temp file: {e}")
238
+ raise
239
+
240
+ def create_metadata_table(s3_uri: str) -> list[dict]:
241
+ """
242
+ Scan an S3 location for JSON files matching a specific directory structure
243
+ and assemble metadata for each found file.
244
+
245
+ :param s3_uri: S3 prefix to search for validation result files.
246
+ Should point to root folder for validation result hierarchy.
247
+ :type s3_uri: str
248
+
249
+ :returns: A list of metadata dicts, one per .json file found under the prefix.
250
+ Each dict contains: study_id, validation_id, table_name, snapshot_id,
251
+ file_name, and s3_uri.
252
+ :rtype: list[dict]
253
+
254
+ :raises Exception: If S3 listing/parsing fails.
255
+
256
+ .. note::
257
+ Only .json files matching expected S3 path pattern will be included.
258
+ Ignores unrelated files.
259
+
260
+ **Example**
261
+
262
+ >>> table = create_metadata_table('s3://bucket/validation/')
263
+ >>> print(table[0]['s3_uri'])
264
+ """
265
+ import re
266
+ logger.info(f"Creating metadata table from S3 URI: {s3_uri}")
267
+ result = []
268
+ regex = re.compile(
269
+ r"study_id=([^/]+)/validation_id=([^/]+)/table_name=([^/]+)/snapshot_id=([^/]+)/([^/]+\.json)$"
270
+ )
271
+ try:
272
+ for bucket, key in list_s3_objects(s3_uri):
273
+ if not key.endswith(".json"):
274
+ continue
275
+ match = regex.search(key)
276
+ if match:
277
+ study_id, validation_id, table_name, snapshot_id, file_name = match.groups()
278
+ entry = {
279
+ "study_id": study_id,
280
+ "validation_id": validation_id,
281
+ "table_name": table_name,
282
+ "snapshot_id": snapshot_id,
283
+ "file_name": file_name,
284
+ "s3_uri": f"s3://{bucket}/{key}"
285
+ }
286
+ logger.debug(f"Matched file entry: {entry}")
287
+ result.append(entry)
288
+ logger.info(f"Finished building metadata table. {len(result)} records found.")
289
+ return result
290
+ except Exception as e:
291
+ logger.error(f"Error occurred while creating metadata table from {s3_uri}: {e}")
292
+ raise
293
+
294
+ def get_latest_validation_for_study(metadata_table: pd.DataFrame, study_id: str):
295
+ """
296
+ Find the most recent validation (by validation_id) for a given study within a metadata table.
297
+
298
+ :param metadata_table: DataFrame containing at least columns 'study_id' and 'validation_id'.
299
+ :type metadata_table: pandas.DataFrame
300
+ :param study_id: The study identifier to filter for.
301
+ :type study_id: str
302
+
303
+ :returns: Tuple of (DataFrame of latest validation's rows, validation_id as string).
304
+ Both empty DataFrame and None if study is not present.
305
+ :rtype: tuple[pandas.DataFrame, Optional[str]]
306
+
307
+ :raises Exception: If filtering or max computation fails.
308
+
309
+ **Example**
310
+
311
+ >>> df, valid_id = get_latest_validation_for_study(table, "ausdiab")
312
+ >>> print(valid_id)
313
+ """
314
+ logger.info(f"Selecting latest validation for study_id: {study_id}")
315
+ try:
316
+ if 'study_id' not in metadata_table.columns:
317
+ logger.warning("No 'study_id' column in metadata_table.")
318
+ return pd.DataFrame(), None
319
+ filtered_table = metadata_table[metadata_table['study_id'] == study_id]
320
+ if not filtered_table.empty:
321
+ latest_validation_id = filtered_table['validation_id'].max()
322
+ logger.info(f"Latest validation_id for study {study_id}: {latest_validation_id}")
323
+ result_table = filtered_table[filtered_table['validation_id'] == latest_validation_id]
324
+ else:
325
+ logger.warning(f"No entries found for study_id {study_id}")
326
+ result_table = pd.DataFrame()
327
+ latest_validation_id = None
328
+ logger.debug(f"{len(result_table)} records returned for latest validation_id.")
329
+ return result_table, latest_validation_id
330
+ except Exception as e:
331
+ logger.error(f"Error attempting to get latest validation for study {study_id}: {e}")
332
+ raise
333
+
334
+ def download_s3_files_to_temp_dir(s3_uris: list[str], temp_dir_name: str = "to_validate"):
335
+ """
336
+ Download a set of S3 files to a new temporary directory.
337
+
338
+ :param s3_uris: List of S3 URIs to download.
339
+ :type s3_uris: list[str]
340
+ :param temp_dir_name: Name of the subdirectory to use inside the temp dir.
341
+ :type temp_dir_name: str, optional (default: "to_validate")
342
+
343
+ :returns: (target_temp_dir, downloaded_files)
344
+ - target_temp_dir: Path to directory containing files
345
+ - downloaded_files: List of absolute paths of successfully downloaded files.
346
+ :rtype: tuple[str, list[str]]
347
+
348
+ :raises Exception: If temporary folder creation or downloads fail.
349
+
350
+ .. note::
351
+ Each file is downloaded under a sanitized file name (see logic in code).
352
+ Skips files if download fails.
353
+
354
+ **Example**
355
+
356
+ >>> d, files = download_s3_files_to_temp_dir(['s3://bucket/a.json'], 'data')
357
+ >>> print(files)
358
+ """
359
+ logger.info(f"Downloading {len(s3_uris)} S3 files to temp dir named '{temp_dir_name}'")
360
+ try:
361
+ base_tempdir = tempfile.mkdtemp()
362
+ target_temp_dir = os.path.join(base_tempdir, temp_dir_name)
363
+ os.makedirs(target_temp_dir, exist_ok=True)
364
+ logger.debug(f"Target temp dir for downloads: {target_temp_dir}")
365
+ downloaded_files = []
366
+ for s3_uri in s3_uris:
367
+ if not s3_uri.startswith("s3://"):
368
+ logger.warning(f"Skipping invalid s3_uri (not an s3 path): {s3_uri}")
369
+ continue
370
+ _, _, path = s3_uri.partition("s3://")
371
+ file_name = os.path.basename(path.split("/", 1)[1] if "/" in path else path)
372
+ parts = file_name.split("_")
373
+ if len(parts) > 2:
374
+ final_file_name = "_".join(parts[2:])
375
+ else:
376
+ final_file_name = file_name
377
+ dest_path = os.path.join(target_temp_dir, final_file_name)
378
+ try:
379
+ download_s3_file(s3_uri, dest_path)
380
+ downloaded_files.append(dest_path)
381
+ except Exception as e:
382
+ logger.warning(f"Skipping failed download for {s3_uri}: {e}")
383
+ continue
384
+ logger.info(f"Total files downloaded: {len(downloaded_files)}")
385
+ return target_temp_dir, downloaded_files
386
+ except Exception as e:
387
+ logger.error(f"Failed to download S3 files to temp dir: {e}")
388
+ raise
389
+
390
+ def write_df_to_s3(df: pd.DataFrame, s3_uri: str, filename: str, sep: str = ",", index: bool = False):
391
+ """
392
+ Write a pandas DataFrame to S3 as a CSV file.
393
+
394
+ :param df: DataFrame to serialize.
395
+ :type df: pandas.DataFrame
396
+ :param s3_uri: The S3 prefix/folder to write the file to.
397
+ :type s3_uri: str
398
+ :param filename: The desired file's name within the S3 location.
399
+ :type filename: str
400
+ :param sep: Separator used in CSV. Default is ','.
401
+ :type sep: str, optional
402
+ :param index: Whether to include index in CSV file. Default is False.
403
+ :type index: bool, optional
404
+
405
+ :raises Exception: On DataFrame serialization or upload errors.
406
+
407
+ **Example**
408
+
409
+ >>> write_df_to_s3(df, 's3://bucket/validation/', 'my_results.csv')
410
+ """
411
+ logger.info(f"Writing DataFrame to S3: {s3_uri}, filename: {filename}")
412
+ try:
413
+ if s3_uri.endswith("/"):
414
+ s3_uri = s3_uri[:-1]
415
+ s3_path = f"{s3_uri}/{filename}"
416
+ csv_buffer = io.StringIO()
417
+ df.to_csv(csv_buffer, sep=sep, index=index)
418
+ write_bytes_to_s3(csv_buffer.getvalue(), s3_path)
419
+ except Exception as e:
420
+ logger.error(f"Failed to write DataFrame to S3 ({s3_uri}, {filename}): {e}")
421
+ raise
422
+
423
+
424
+
425
+
426
+
427
+ def count_unlinked_foreign_keys(linkage_results_dict: dict) -> int:
428
+ """
429
+ Count the number of unlinked foreign keys in a linkage results dictionary.
430
+
431
+ The input should be the results dictionary produced by
432
+ `gen3_validator.Linkage().validate_links()`.
433
+
434
+ :param linkage_results_dict: Dictionary mapping foreign key fields to lists of unlinked key values.
435
+ :type linkage_results_dict: dict
436
+
437
+ :returns: Total count of unlinked foreign key values across all fields.
438
+ :rtype: int
439
+ """
440
+ invalid_keys = 0
441
+ for k, v in linkage_results_dict.items():
442
+ if len(v) > 0:
443
+ invalid_keys += len(v)
444
+ return invalid_keys
445
+
446
+ def truncate_linkage_results(linkage_results_dict: dict) -> dict:
447
+ """
448
+ Truncate linkage results dictionary to only include fields with unlinked foreign keys.
449
+
450
+ The input should be the results dictionary produced by
451
+ `gen3_validator.Linkage().validate_links()`.
452
+
453
+ :param linkage_results_dict: Dictionary mapping foreign key fields to lists of unlinked key values.
454
+ :type linkage_results_dict: dict
455
+
456
+ :returns: Dictionary with only fields with unlinked foreign keys.
457
+ :rtype: dict
458
+ """
459
+ for k, v in linkage_results_dict.items():
460
+ if len(v) > 50:
461
+ v = v[:50]
462
+ v.append("WARN: too many unlinked keys, only showing first 50")
463
+ return linkage_results_dict
464
+
465
+
466
+ def validate_pipeline(
467
+ study_id: str,
468
+ schema_s3_uri: str,
469
+ validation_s3_uri: str,
470
+ write_back_root: str,
471
+ glue_database: str,
472
+ athena_s3_output: str,
473
+ workgroup: str = "primary",
474
+ root_node: str = "project",
475
+ ) -> None:
476
+ """
477
+ Orchestrate the validation workflow for a study: load + resolve schema, find the latest
478
+ validation artefacts, validate, and persist results to S3 and Athena/Glue.
479
+
480
+ Args:
481
+ study_id: Study identifier (e.g. "ausdiab").
482
+ schema_s3_uri: Full S3 URI to the JSON schema file.
483
+ validation_s3_uri: S3 prefix containing validation result files (.json).
484
+ write_back_root: S3 prefix to write generated artefacts/reports to.
485
+ glue_database: Glue database to register tables into.
486
+ athena_s3_output: S3 URI for Athena query results and temporary staging.
487
+ workgroup: Athena workgroup to use. Defaults to "primary".
488
+ root_node: Root node in the schema graph for link validation.
489
+
490
+ Raises:
491
+ RuntimeError: When expected inputs are missing or a pipeline step fails.
492
+ """
493
+ # NOTE: root_node is currently unused in the original implementation.
494
+ # Keeping it in the signature for forward compatibility.
495
+
496
+ try:
497
+ schema = load_schema_from_s3_uri(schema_s3_uri)
498
+ schema_path = write_schema_to_temp_file(schema)
499
+ logger.info("Schema loaded and written to temp file: %s", schema_path)
500
+ except Exception as e:
501
+ logger.exception("Schema loading/writing failed.")
502
+ raise RuntimeError("Schema loading/writing failed.") from e
503
+
504
+ try:
505
+ logger.info("Instantiating schema resolver with schema path: %s", schema_path)
506
+ resolver = gen3_validator.ResolveSchema(schema_path=schema_path)
507
+ resolver.resolve_schema()
508
+ logger.info("Schema resolved.")
509
+ except Exception as e:
510
+ logger.exception("Failed to instantiate or resolve schema.")
511
+ raise RuntimeError("Schema resolution failed.") from e
512
+
513
+ try:
514
+ metadata_table = pd.DataFrame(create_metadata_table(validation_s3_uri))
515
+ logger.info("Metadata table created from S3 (%s rows).", len(metadata_table))
516
+ except Exception as e:
517
+ logger.exception("Failed to create metadata table.")
518
+ raise RuntimeError("Metadata table creation failed.") from e
519
+
520
+ try:
521
+ latest_metadata, latest_validation_id = get_latest_validation_for_study(metadata_table, study_id)
522
+ if latest_metadata is None or latest_metadata.empty:
523
+ raise RuntimeError(f"No latest validation files found for study '{study_id}'.")
524
+
525
+ latest_validation_s3_uris = latest_metadata["s3_uri"].tolist()
526
+ logger.info("Latest validation id: %s", latest_validation_id)
527
+ logger.info("Latest validation file count: %s", len(latest_validation_s3_uris))
528
+
529
+ downloaded_files_dir, _downloaded_files = download_s3_files_to_temp_dir(latest_validation_s3_uris)
530
+ files_in_dir = os.listdir(downloaded_files_dir)
531
+ logger.info("Downloaded files dir: %s", downloaded_files_dir)
532
+ for name in files_in_dir:
533
+ logger.info(" - %s", name)
534
+ except Exception as e:
535
+ logger.exception("Failed to retrieve/download latest validation files.")
536
+ raise RuntimeError("Retrieval/download of validation files failed.") from e
537
+
538
+ try:
539
+ logger.info("Reading JSON validation files from: %s", downloaded_files_dir)
540
+
541
+ data_by_file = {}
542
+ for name in files_in_dir:
543
+ if not name.endswith(".json"):
544
+ continue
545
+ full_path = os.path.join(downloaded_files_dir, name)
546
+ with open(full_path, "r") as fh:
547
+ data_by_file[name] = json.load(fh)
548
+
549
+ logger.info("Validating %s JSON file(s).", len(data_by_file))
550
+
551
+ results: list[dict] = []
552
+ for filename, obj in data_by_file.items():
553
+ logger.info("Validating %s", filename)
554
+ results.extend(gen3_validator.validate.validate_list_dict(obj, resolver.schema_resolved))
555
+
556
+ logger.info("Getting Schema Version")
557
+ schema_version = resolver.get_schema_version(schema = schema)
558
+
559
+ full_validation_results_df = pd.DataFrame(results)
560
+ full_validation_results_df["validation_id"] = latest_validation_id
561
+ full_validation_results_df["study_id"] = study_id
562
+ full_validation_results_df["schema_version"] = schema_version
563
+
564
+ full_validation_results_df = full_validation_results_df[[
565
+ "validation_id",
566
+ "index",
567
+ "node",
568
+ "study_id",
569
+ "validation_result",
570
+ "invalid_key",
571
+ "schema_path",
572
+ "validator",
573
+ "validator_value",
574
+ "validation_error",
575
+ "schema_version"
576
+ ]]
577
+
578
+ logger.info("Validation completed (%s rows).", len(full_validation_results_df))
579
+ except Exception as e:
580
+ logger.exception("Validation failed.")
581
+ raise RuntimeError("Validation failed.") from e
582
+
583
+ write_back_s3_uri = (
584
+ f"{write_back_root.rstrip('/')}/study_id={study_id}/validation_id={latest_validation_id}/"
585
+ )
586
+
587
+ try:
588
+ logger.info("Writing CSV results to S3: %s", write_back_s3_uri)
589
+ write_df_to_s3(full_validation_results_df, write_back_s3_uri, "full_validation_results.csv")
590
+ except Exception as e:
591
+ logger.exception("Failed to write CSV validation results to S3.")
592
+ raise RuntimeError("Writing CSV results failed.") from e
593
+
594
+ try:
595
+ logger.info(
596
+ "Writing Parquet to Glue DB '%s', table '%s'.",
597
+ glue_database,
598
+ "full_validation_results",
599
+ )
600
+ write_iceberg_to_db(
601
+ df=full_validation_results_df,
602
+ database=glue_database,
603
+ table="full_validation_results",
604
+ athena_s3_output=athena_s3_output,
605
+ workgroup=workgroup,
606
+ )
607
+ except Exception as e:
608
+ logger.exception("Failed to write validation results to Parquet/Glue.")
609
+ raise RuntimeError("Writing Parquet/Glue results failed.") from e