Source code for operators.s3_shp_to_athena

from airflow.models import BaseOperator
from airflow.utils.decorators import apply_defaults

from config import Config


[docs] class S3ShapefileToAthenaOperator(BaseOperator): """ To convert a shapefile in S3 to Athena table Ensure that all the relevant files are in the same directory. (e.g. .shp, .shx, .dbf, .prj, .cpg) Default EPSG is 4326 :s3_shp_path: the path to the shapefile in S3 :type s3_shp_path: str :output_table_name: the name to use for the output table :type output_table_name: str :output_path: the path to the output table in S3 :type output_path: str """ ui_color = "#A6E6A6" @apply_defaults def __init__( self, s3_shp_path, output_table_name, output_path, s3_shp_layer=None, *args, **kwargs, ): super(S3ShapefileToAthenaOperator, self).__init__( *args, **kwargs, ) self.s3_shp_path = s3_shp_path self.output_table_name = output_table_name self.output_path = output_path self.s3_shp_layer = s3_shp_layer def _resolve_zip_shp_path(self, s3_path: str) -> str: """ If the path is a .zip file with a nested directory structure, find the .shp file inside and return the proper path for GDAL/Fiona. """ if not s3_path.lower().endswith(".zip"): return s3_path import io # Lazy load: standard library import zipfile # Lazy load: standard library import boto3 # Lazy load: heavy AWS SDK path_without_prefix = s3_path.replace("s3://", "") bucket = path_without_prefix.split("/")[0] key = "/".join(path_without_prefix.split("/")[1:]) s3_client = boto3.client("s3") zip_obj = s3_client.get_object(Bucket=bucket, Key=key) zip_content = io.BytesIO(zip_obj["Body"].read()) with zipfile.ZipFile(zip_content, "r") as zf: shp_files = [f for f in zf.namelist() if f.lower().endswith(".shp")] if not shp_files: raise ValueError(f"No .shp file found in zip: {s3_path}") if len(shp_files) > 1: self.log.warning( f"Multiple .shp files found in zip, using first: {shp_files}" ) shp_inside = shp_files[0] if "/" in shp_inside: resolved_path = f"/vsizip/vsis3/{bucket}/{key}/{shp_inside}" self.log.info(f"Resolved nested zip path: {resolved_path}") return resolved_path return s3_path
[docs] def execute( self: object, context: dict, ) -> None: try: import awswrangler as wr import geopandas as gpd import pandas as pd self.log.info(f"Source filepath: {self.s3_shp_path}") self.log.info(f"Shapefile layer name: {self.s3_shp_layer}") self.log.info(f"Output table name: {self.output_table_name}") self.log.info(f"Destination filepath: {self.output_path}") read_path = self._resolve_zip_shp_path(self.s3_shp_path) # Read the shapefile if not self.s3_shp_layer: gdf = gpd.read_file(read_path) else: gdf = gpd.read_file(read_path, layer=self.s3_shp_layer) # project to 4326 gdf.to_crs( crs=Config.CRS_GPS, inplace=True, ) # Create a new column with WKT strings gdf["geometry_wkt"] = gdf.geometry.apply( lambda geom: geom.wkt if geom is not None else None ) # Convert to regular DataFrame gdf = pd.DataFrame(gdf) # Drop geometry column and rename gdf = gdf.drop(columns=["geometry"]).rename( columns={"geometry_wkt": "geometry"} ) response = wr.s3.to_parquet( df=gdf, path=self.output_path, dataset=True, database=Config.DATA_LAKE_DB, table=self.output_table_name, mode="overwrite", index=False, dtype={col: "string" for col in gdf.columns}, ) self.log.info(f"Saved to S3: {response['paths']}") self.log.info( f"Saved table to Athena as: {Config.DATA_LAKE_DB}.{self.output_table_name}" ) self.log.info(f"Partitions, if any: {response['partitions_values']}") except Exception as e: self.log.error(f"Error: {e}") raise e