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.
- g3dt/__init__.py +0 -0
- g3dt/cli/__init__.py +5 -0
- g3dt/cli/_internal/__init__.py +1 -0
- g3dt/cli/_internal/dispatch.py +428 -0
- g3dt/cli/_internal/registry.py +65 -0
- g3dt/cli/_internal/resolve.py +22 -0
- g3dt/cli/_internal/runner.py +76 -0
- g3dt/cli/_internal/safety.py +110 -0
- g3dt/cli/config_cmds.py +202 -0
- g3dt/cli/delete_cmds.py +101 -0
- g3dt/cli/dict_cmds.py +102 -0
- g3dt/cli/ec2_cmds.py +114 -0
- g3dt/cli/indexd_cmds.py +57 -0
- g3dt/cli/jobs.py +83 -0
- g3dt/cli/k8s.py +54 -0
- g3dt/cli/main.py +110 -0
- g3dt/cli/metadata.py +76 -0
- g3dt/cli/synth.py +206 -0
- g3dt/config.py +393 -0
- g3dt/indexd/__init__.py +0 -0
- g3dt/indexd/indexd_registrar.py +244 -0
- g3dt/ingest/ingest.py +629 -0
- g3dt/resolver.py +163 -0
- g3dt/services/delete/delete_all_metadata_for_project.py +170 -0
- g3dt/services/delete/delete_metadata.sh +153 -0
- g3dt/services/delete/delete_metadata_by_guid.py +338 -0
- g3dt/services/dictionary/deploy_dd.sh +65 -0
- g3dt/services/dictionary/pull_dict.sh +59 -0
- g3dt/services/dictionary/upload_dictionary.py +109 -0
- g3dt/services/indexd/register_indexd.py +240 -0
- g3dt/services/k8s_ops/argocd_restart_etl.sh +140 -0
- g3dt/services/k8s_ops/argocd_restart_ms.sh +102 -0
- g3dt/services/k8s_ops/argocd_restart_schema.sh +106 -0
- g3dt/services/k8s_ops/login_to_pod.sh +110 -0
- g3dt/services/k8s_ops/restart_etl_and_ms.sh +56 -0
- g3dt/services/synthetic_data/delete_synth_metadata_sheepdog.py +183 -0
- g3dt/services/synthetic_data/full_deploy_dd_and_synth.sh +124 -0
- g3dt/services/synthetic_data/generate_synth_metadata.sh +133 -0
- g3dt/services/synthetic_data/upload_synth_metadata_sheepdog.py +165 -0
- g3dt/services/upload/metadata/upload_all_studies.sh +108 -0
- g3dt/services/upload/metadata/upload_metadata.py +152 -0
- g3dt/upload/__init__.py +1 -0
- g3dt/upload/metadata_deleter.py +265 -0
- g3dt/upload/metadata_submitter.py +1093 -0
- g3dt/upload/upload_synthdata_s3.py +164 -0
- g3dt/utils/athena_utils.py +834 -0
- g3dt/utils/dbt_utils.py +66 -0
- g3dt/utils/release_writer.py +188 -0
- g3dt/validate/validate.py +609 -0
- gen3_dataops_toolkit-2.0.0.dist-info/METADATA +125 -0
- gen3_dataops_toolkit-2.0.0.dist-info/RECORD +53 -0
- gen3_dataops_toolkit-2.0.0.dist-info/WHEEL +4 -0
- 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
|