From c413e0e4699d144899d43a96a5aba68dbd69f6eb Mon Sep 17 00:00:00 2001 From: Xu Han Date: Thu, 18 Apr 2024 06:40:38 +0000 Subject: [PATCH] feat: update glue script supporting luhn checksum --- .../job/script/glue-job-unstructured.py | 118 ++++++++++------- .../constructs/config/job/script/glue-job.py | 119 +++++++++++------- .../config/job/script/job_extra_files.zip | Bin 21891 -> 21707 bytes 3 files changed, 147 insertions(+), 90 deletions(-) diff --git a/source/constructs/config/job/script/glue-job-unstructured.py b/source/constructs/config/job/script/glue-job-unstructured.py index 8d3626c8..a5a63129 100644 --- a/source/constructs/config/job/script/glue-job-unstructured.py +++ b/source/constructs/config/job/script/glue-job-unstructured.py @@ -1,4 +1,4 @@ -''' +""" Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,47 +12,65 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -''' +""" import os import sys -import boto3 from functools import reduce -from pyspark.context import SparkContext -from pyspark.sql import DataFrame +import boto3 import pyspark.sql.functions as sf -from awsglue.transforms import * -from awsglue.utils import getResolvedOptions from awsglue.context import GlueContext from awsglue.job import Job - -from data_source.get_tables import get_tables +from awsglue.transforms import * +from awsglue.utils import getResolvedOptions from data_source.construct_dataframe import construct_dataframe +from data_source.get_tables import get_tables +from pyspark.context import SparkContext +from pyspark.sql import DataFrame from template.template_utils import get_template from unstructured_detection.detection_utils import add_metadata, get_table_info from unstructured_detection.main_detection import detect_df - if __name__ == "__main__": """ This script is used to perform PII detection on using Glue Data Catalog. """ # Get all input arguments - s3 = boto3.client(service_name='s3') - glue = boto3.client(service_name='glue') - result_database = 'sdps_database' - result_table = 'job_detection_output_table' - - args = getResolvedOptions(sys.argv, ["AccountId", 'Region','JOB_NAME', 'DatabaseName', 'JobId', 'RunId', - 'RunDatabaseId', 'AdminBucketName', 'TemplateId', 'TemplateSnapshotNo', 'BaseTime', - 'TableBegin', 'TableEnd', 'TableName', 'IncludeKeywords', 'ExcludeKeywords']) - args['DatabaseType'] = 's3_unstructured' + s3 = boto3.client(service_name="s3") + glue = boto3.client(service_name="glue") + result_database = "sdps_database" + result_table = "job_detection_output_table" + + args = getResolvedOptions( + sys.argv, + [ + "AccountId", + "Region", + "JOB_NAME", + "DatabaseName", + "JobId", + "RunId", + "RunDatabaseId", + "AdminBucketName", + "TemplateId", + "TemplateSnapshotNo", + "BaseTime", + "TableBegin", + "TableEnd", + "TableName", + "IncludeKeywords", + "ExcludeKeywords", + ], + ) + args["DatabaseType"] = "s3_unstructured" full_database_name = f"SDPS-unstructured-{args['DatabaseName']}" output_path = f"s3://{args['AdminBucketName']}/glue-database/{result_table}/" - error_path = f"s3://{args['AdminBucketName']}/glue-database/job_detection_error_table/" + error_path = ( + f"s3://{args['AdminBucketName']}/glue-database/job_detection_error_table/" + ) # Create spark and glue context sc = SparkContext() @@ -66,7 +84,9 @@ num_crawler_tables = len(crawler_tables) # Get template from s3 and broadcast it - template = get_template(s3, args['AdminBucketName'], args['TemplateId'], args['TemplateSnapshotNo']) + template = get_template( + s3, args["AdminBucketName"], args["TemplateId"], args["TemplateSnapshotNo"] + ) broadcast_template = sc.broadcast(template) output = [] @@ -74,52 +94,60 @@ save_freq = 10 for table_index, table in enumerate(crawler_tables): try: - # call detect_table to perform PII detection + # call detect_table to perform PII detection print(f"Detecting table {table['Name']}") raw_df = construct_dataframe(glueContext, glue, table, args) # raw_df.show() - detection_result = detect_df(raw_df, glueContext, broadcast_template, table, args) + detection_result = detect_df( + raw_df, glueContext, broadcast_template, table, args + ) summarized_result = add_metadata(detection_result, table, args) summarized_result.show() output.append(summarized_result) - + except Exception as e: # Report error if failed basic_table_info = get_table_info(table, args) data = { - 'account_id': args["AccountId"], - 'region': args["Region"], - 'job_id': args['JobId'], - 'run_id': args['RunId'], - 'run_database_id': args['RunDatabaseId'], - 'database_name': args['DatabaseName'], - 'database_type': args['DatabaseType'], - 'table_name': table['Name'], - 'location': basic_table_info['location'], - 's3_location': basic_table_info['s3_location'], - 's3_bucket': basic_table_info['s3_bucket'], - 'rds_instance_id': basic_table_info['rds_instance_id'], - 'error_message': str(e) + "account_id": args["AccountId"], + "region": args["Region"], + "job_id": args["JobId"], + "run_id": args["RunId"], + "run_database_id": args["RunDatabaseId"], + "database_name": args["DatabaseName"], + "database_type": args["DatabaseType"], + "table_name": table["Name"], + "location": basic_table_info["location"], + "s3_location": basic_table_info["s3_location"], + "s3_bucket": basic_table_info["s3_bucket"], + "rds_instance_id": basic_table_info["rds_instance_id"], + "error_message": str(e), } error.append(data) - print(f'Error occured detecting table {table}') + print(f"Error occured detecting table {table}") print(e) - - if (table_index + 1) % save_freq == 0 or (table_index + 1) == num_crawler_tables: + + if (table_index + 1) % save_freq == 0 or ( + table_index + 1 + ) == num_crawler_tables: # Save detection result to s3. if output: df = reduce(DataFrame.unionAll, output) - df = df.repartition(1, 'year', 'month', 'day') + df = df.repartition(1, "year", "month", "day") # df.show() - df.write.partitionBy('year', 'month', 'day').mode('append').parquet(output_path) + df.write.partitionBy("year", "month", "day").mode("append").parquet( + output_path + ) # If error in detect_table, save to error_path if error: df = spark.createDataFrame(error) - df.withColumn('update_time', sf.from_utc_timestamp(sf.current_timestamp(), 'UTC')) + df.withColumn( + "update_time", sf.from_utc_timestamp(sf.current_timestamp(), "UTC") + ) df = df.repartition(1) - df.write.mode('append').parquet(error_path) - + df.write.mode("append").parquet(error_path) + output = [] error = [] diff --git a/source/constructs/config/job/script/glue-job.py b/source/constructs/config/job/script/glue-job.py index 7fa8d347..91a9b2ea 100644 --- a/source/constructs/config/job/script/glue-job.py +++ b/source/constructs/config/job/script/glue-job.py @@ -1,4 +1,4 @@ -''' +""" Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. Licensed under the Apache License, Version 2.0 (the "License"); @@ -12,27 +12,25 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -''' +""" import os import sys -import boto3 from functools import reduce -from pyspark.context import SparkContext -from pyspark.sql import DataFrame +import boto3 import pyspark.sql.functions as sf -from awsglue.transforms import * -from awsglue.utils import getResolvedOptions from awsglue.context import GlueContext from awsglue.job import Job - -from data_source.get_tables import get_tables +from awsglue.transforms import * +from awsglue.utils import getResolvedOptions from data_source.construct_dataframe import construct_dataframe -from template.template_utils import get_template +from data_source.get_tables import get_tables +from pyspark.context import SparkContext +from pyspark.sql import DataFrame from structured_detection.detection_utils import add_metadata, get_table_info from structured_detection.main_detection import detect_df - +from template.template_utils import get_template if __name__ == "__main__": """ @@ -40,18 +38,41 @@ """ # Get all input arguments - s3 = boto3.client(service_name='s3') - glue = boto3.client(service_name='glue') - result_database = 'sdps_database' - result_table = 'job_detection_output_table' - - args = getResolvedOptions(sys.argv, ["AccountId", 'Region', 'JOB_NAME', 'DatabaseName', 'GlueDatabaseName', - 'DatabaseType', 'Depth', 'DetectionThreshold', 'JobId', 'RunId', 'RunDatabaseId', - 'TemplateId', 'TemplateSnapshotNo', 'AdminBucketName', 'BaseTime', 'TableBegin', 'TableEnd', - 'TableName', 'IncludeKeywords', 'ExcludeKeywords']) + s3 = boto3.client(service_name="s3") + glue = boto3.client(service_name="glue") + result_database = "sdps_database" + result_table = "job_detection_output_table" + + args = getResolvedOptions( + sys.argv, + [ + "AccountId", + "Region", + "JOB_NAME", + "DatabaseName", + "GlueDatabaseName", + "DatabaseType", + "Depth", + "DetectionThreshold", + "JobId", + "RunId", + "RunDatabaseId", + "TemplateId", + "TemplateSnapshotNo", + "AdminBucketName", + "BaseTime", + "TableBegin", + "TableEnd", + "TableName", + "IncludeKeywords", + "ExcludeKeywords", + ], + ) output_path = f"s3://{args['AdminBucketName']}/glue-database/{result_table}/" - error_path = f"s3://{args['AdminBucketName']}/glue-database/job_detection_error_table/" + error_path = ( + f"s3://{args['AdminBucketName']}/glue-database/job_detection_error_table/" + ) # Create spark and glue context sc = SparkContext() @@ -65,7 +86,9 @@ num_crawler_tables = len(crawler_tables) # Get template from s3 and broadcast it - template = get_template(s3, args['AdminBucketName'], args['TemplateId'], args['TemplateSnapshotNo']) + template = get_template( + s3, args["AdminBucketName"], args["TemplateId"], args["TemplateSnapshotNo"] + ) broadcast_template = sc.broadcast(template) output = [] @@ -73,51 +96,57 @@ save_freq = 10 for table_index, table in enumerate(crawler_tables): try: - # call detect_table to perform PII detection + # call detect_table to perform PII detection print(f"Detecting table {table['Name']}") raw_df = construct_dataframe(glueContext, glue, table, args) detection_result = detect_df(raw_df, glueContext, broadcast_template, args) summarized_result = add_metadata(detection_result, table, args) summarized_result.show() output.append(summarized_result) - + except Exception as e: # Report error if failed basic_table_info = get_table_info(table, args) data = { - 'account_id': args["AccountId"], - 'region': args["Region"], - 'job_id': args['JobId'], - 'run_id': args['RunId'], - 'run_database_id': args['RunDatabaseId'], - 'database_name': args['DatabaseName'], - 'database_type': args['DatabaseType'], - 'table_name': table['Name'], - 'location': basic_table_info['location'], - 's3_location': basic_table_info['s3_location'], - 's3_bucket': basic_table_info['s3_bucket'], - 'rds_instance_id': basic_table_info['rds_instance_id'], - 'error_message': str(e) + "account_id": args["AccountId"], + "region": args["Region"], + "job_id": args["JobId"], + "run_id": args["RunId"], + "run_database_id": args["RunDatabaseId"], + "database_name": args["DatabaseName"], + "database_type": args["DatabaseType"], + "table_name": table["Name"], + "location": basic_table_info["location"], + "s3_location": basic_table_info["s3_location"], + "s3_bucket": basic_table_info["s3_bucket"], + "rds_instance_id": basic_table_info["rds_instance_id"], + "error_message": str(e), } error.append(data) - print(f'Error occured detecting table {table}') + print(f"Error occured detecting table {table}") print(e) - - if (table_index + 1) % save_freq == 0 or (table_index + 1) == num_crawler_tables: + + if (table_index + 1) % save_freq == 0 or ( + table_index + 1 + ) == num_crawler_tables: # Save detection result to s3. if output: df = reduce(DataFrame.unionAll, output) - df = df.repartition(1, 'year', 'month', 'day') + df = df.repartition(1, "year", "month", "day") # df.show() - df.write.partitionBy('year', 'month', 'day').mode('append').parquet(output_path) + df.write.partitionBy("year", "month", "day").mode("append").parquet( + output_path + ) # If error in detect_table, save to error_path if error: df = spark.createDataFrame(error) - df.withColumn('update_time', sf.from_utc_timestamp(sf.current_timestamp(), 'UTC')) + df.withColumn( + "update_time", sf.from_utc_timestamp(sf.current_timestamp(), "UTC") + ) df = df.repartition(1) - df.write.mode('append').parquet(error_path) - + df.write.mode("append").parquet(error_path) + output = [] error = [] diff --git a/source/constructs/config/job/script/job_extra_files.zip b/source/constructs/config/job/script/job_extra_files.zip index b895bc513216380daefa62b053b022f7b24ca165..cd078400ac991a4caa96fa32bd70645f90cf08b3 100644 GIT binary patch delta 10860 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 X-(WEJ!8`zfdH)*&2LQ~;Km7GSj~s(Y