Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
ea4cdd2
Fixed Code for 3 files
kartik-s21 Apr 7, 2026
91d22a7
NameError Resolved
kartik-s21 Apr 8, 2026
c3084c4
Merge branch 'master' into code_fix_unenergy
kartik-s21 Apr 10, 2026
297374c
Merge branch 'master' into code_fix_unenergy
kartik-s21 Apr 12, 2026
34bb357
Merge pull request #1 from kartik-s21/code_fix_unenergy
kartik-s21 Apr 13, 2026
ee1e0b2
Merge branch 'datacommonsorg:master' into master
kartik-s21 Apr 30, 2026
8707bba
Merge branch 'datacommonsorg:master' into master
kartik-s21 May 5, 2026
5527dcd
Merge branch 'datacommonsorg:master' into master
kartik-s21 May 5, 2026
e41a8a7
Merge branch 'datacommonsorg:master' into master
kartik-s21 May 5, 2026
7f17aa3
Resolve merge conflict in energy process script
kartik-s21 May 17, 2026
59bcad2
Merge branch 'datacommonsorg:master' into master
kartik-s21 May 17, 2026
4fc439a
Merge branch 'datacommonsorg:master' into master
kartik-s21 May 28, 2026
9e5417c
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jun 2, 2026
cb72442
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jun 4, 2026
d9bb0a6
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jun 5, 2026
2515f96
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jun 7, 2026
c099907
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jun 10, 2026
1359560
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jun 12, 2026
0dfeecc
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jun 15, 2026
b09267f
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jun 17, 2026
776feff
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jun 18, 2026
4b06212
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jun 30, 2026
8bb164a
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jul 8, 2026
7ebbdce
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jul 13, 2026
7eb092e
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jul 20, 2026
2f9a79b
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jul 20, 2026
fb658bd
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jul 22, 2026
29db8fa
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jul 24, 2026
3026f30
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jul 29, 2026
7a815af
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jul 30, 2026
cb02292
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jul 31, 2026
305fb69
Merge branch 'datacommonsorg:master' into master
kartik-s21 Jul 31, 2026
a146ef9
Merge branch 'datacommonsorg:master' into master
kartik-s21 Aug 3, 2026
c7ad25e
Merge branch 'datacommonsorg:master' into master
kartik-s21 Aug 3, 2026
2b81be4
Merge branch 'datacommonsorg:master' into master
kartik-s21 Aug 4, 2026
5a4e570
Merge branch 'datacommonsorg:master' into master
kartik-s21 Aug 6, 2026
5b6dbc1
Merge branch 'datacommonsorg:master' into master
kartik-s21 Aug 17, 2026
8a42557
Updated download logic to improve input files downloading
kartik-s21 Aug 24, 2026
5c27ba9
Address review feedback: improve bulk download failure handling and c…
kartik-s21 Aug 24, 2026
c007b01
Fix CodeQL incomplete URL substring sanitization warning in unit test
kartik-s21 Aug 24, 2026
db84009
Merge branch 'master' into fema-download-fix
balit-raibot Aug 25, 2026
5c42186
Address review feedback: path handling, atomic file staging, zero-rec…
kartik-s21 Aug 27, 2026
f225dfa
Regenerate golden files filtered by prominent statvars and top places
kartik-s21 Aug 27, 2026
ef56010
Address review feedback: 0-byte bulk download fallback, empty chunk g…
kartik-s21 Sep 1, 2026
c34602f
CRA finding added
kartik-s21 Sep 1, 2026
580045a
Address review feedback: add --output_dir flag, fix style warnings, a…
kartik-s21 Sep 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 162 additions & 49 deletions statvar_imports/fema/flood_insurance_claims/fema_download.py
Comment thread
kartik-s21 marked this conversation as resolved.
Comment thread
kartik-s21 marked this conversation as resolved.
Comment thread
kartik-s21 marked this conversation as resolved.
Comment thread
kartik-s21 marked this conversation as resolved.
Comment thread
kartik-s21 marked this conversation as resolved.
Comment thread
kartik-s21 marked this conversation as resolved.
Comment thread
kartik-s21 marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verification on Test Infrastructure Required

The test run cited in the PR description:
gs://datcom-import-test/statvar_imports/fema/flood_insurance_claims/USFEMA_FloodInsuranceClaims/2026_08_17T21_45_37_408557_07_00/

was executed on August 17, 2026, which predates the fix (commits 8a42557b through c34602f2). Inspecting import_summary.json for that run confirms:

  1. script_execution_time was 230,682 seconds (~64.1 hours) because it executed the legacy, un-optimized pagination loop.
  2. manifest.json was the old version lacking node_mcf, --existing_statvar_mcf, and --output_counters.

No runs have been executed on datcom-import-test since the bulk download fix was implemented. Please trigger a fresh run on datcom-import-test with the updated branch and confirm that script_execution_time drops to under 5 minutes and validation passes cleanly.

Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,36 @@
# limitations under the License.

import os
import sys
import shutil
import time
import requests
from absl import logging
import sys

from absl import app
from absl import flags
from absl import logging
import requests

script_dir = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.dirname(os.path.dirname(os.path.dirname(script_dir)))
if data_dir not in sys.path:
sys.path.insert(0, data_dir)

from util.download_util_script import download_file
from absl import flags

flags.DEFINE_string('api_url',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

download_data() accepts output_dir, but no --output_dir absl flag is defined under flags.DEFINE_* (unlike --temp_dir, --api_url, and --bulk_url).

Consider defining:

flags.DEFINE_string('output_dir', None, 'The directory to store output files.')

and passing _FLAGS.output_dir from main().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Defined flags.DEFINE_string('output_dir', None, 'The directory to store output files.') in fema_download.py.
  • Forwarded _FLAGS.output_dir in main(argv) to download_data().
  • Added test_main in fema_download_test.py to ensure all flags are properly parsed and forwarded.

'https://www.fema.gov/api/open/v2/FimaNfipClaims',
'The base URL of the API endpoint to download data from.')
flags.DEFINE_string(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please address the following PEP8/pylint warnings:

  • Line 18: Unused import time (W0611).
  • Line 29: Imports from package absl should be grouped and placed before first-party imports (C0412, C0411).
  • Lines 95, 137, 250, 267: Lines exceed 100 characters (C0301).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Removed the unused import time (W0611).
  • Grouped and reordered all absl imports (app, flags, logging) before first-party imports (C0411, C0412).
  • Wrapped all lines exceeding 100 characters (lines 95, 137, 250, 267) (C0301).

'bulk_url',
'https://www.fema.gov/about/reports-and-data/openfema/FimaNfipClaims.csv',
'The direct bulk download URL for the full dataset.')
flags.DEFINE_string('temp_dir', 'temp_fema_data',
'The temporary directory to store downloaded chunks.')
flags.DEFINE_string('output_dir', None,
'The directory to store output files.')
_FLAGS = flags.FLAGS

# Define the page size for each API request.
Comment thread
kartik-s21 marked this conversation as resolved.
Comment thread
kartik-s21 marked this conversation as resolved.
PAGE_SIZE = 1000
PAGE_SIZE = 10000
Comment thread
kartik-s21 marked this conversation as resolved.


def get_total_records(api_url):
Expand All @@ -51,7 +57,10 @@ def get_total_records(api_url):
api_url (str): The base URL of the API endpoint.

Returns:
int: The total number of records, or None if the request fails.
int: The total number of records.

Raises:
RuntimeError: If the request fails or the response cannot be parsed.
"""
count_url = f"{api_url}?$count=true"
logging.info("Getting total record count from: %s", count_url)
Expand All @@ -66,63 +75,128 @@ def get_total_records(api_url):
return total_count
except requests.exceptions.RequestException as e:
logging.error("Failed to get total record count: %s", e)
raise RuntimeError(
'Failed to get total record count.')
raise RuntimeError('Failed to get total record count.')
except (ValueError, KeyError, TypeError) as e:
logging.error(
"Failed to parse the total record count from the response: %s", e)
raise RuntimeError(
'Failed to parse the total record count from the response.')


def download_data(api_url: str, temp_dir: str):
def download_data(api_url: str,
temp_dir: str,
bulk_url: str = None,
output_dir: str = None):
"""
Downloads data from the FEMA API, handling pagination and file merging.
Downloads data from the FEMA API, handling direct bulk download and pagination fallback.

Args:
Comment thread
kartik-s21 marked this conversation as resolved.
api_url (str): The base URL of the API endpoint.
temp_dir (str): The path to the temporary directory for downloaded chunks.
bulk_url (str): The direct bulk download URL for the full dataset.
output_dir (str): Optional directory for output file. Defaults to
'input_file' relative to script.
"""
filename = "fema_nfip_claims.csv"

output_dir = "input_file"
if not os.path.exists(output_dir):
os.makedirs(output_dir)
filename = os.path.join(output_dir, filename)
if output_dir is None:
output_dir = os.path.join(script_dir, "input_file")
elif not os.path.isabs(output_dir):
output_dir = os.path.abspath(output_dir)

if not os.path.isabs(temp_dir):
temp_dir = os.path.join(os.path.dirname(output_dir), temp_dir)

os.makedirs(output_dir, exist_ok=True)
final_filepath = os.path.join(output_dir, filename)
Comment thread
kartik-s21 marked this conversation as resolved.

logging.set_verbosity(logging.INFO)

# Define the page size for each API request.
skip_count = 0
records_downloaded = 0
final_filepath = filename
# 1. Attempt direct bulk download first for speed and reliability
if bulk_url:
logging.info("Attempting direct bulk download from: %s", bulk_url)
try:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
os.makedirs(temp_dir, exist_ok=True)

download_success = download_file(url=bulk_url,
output_folder=temp_dir,
unzip=False,
tries=5,
delay=5,
backoff=2)
if not download_success:
raise RuntimeError("download_file returned False")
downloaded_files = [
os.path.join(temp_dir, f) for f in os.listdir(temp_dir)
if os.path.isfile(os.path.join(temp_dir, f))
]
if not downloaded_files or os.path.getsize(
downloaded_files[0]) == 0:
raise RuntimeError("Bulk download file is missing or empty.")
src_file = downloaded_files[0]

# Validate that the bulk download file is a valid CSV and not an
# HTML error/maintenance page.
with open(src_file, 'r', encoding='utf-8', errors='replace') as f:
first_line = f.readline().strip()

lower_first_line = first_line.lower()
if lower_first_line.startswith(('<html', '<!doctype')):
raise RuntimeError(
"Bulk download file appears to be an HTML page, not a CSV.")
if 'policycount' not in lower_first_line and 'dateofloss' not in lower_first_line:
raise RuntimeError(
"Bulk download file is missing expected CSV header columns.")

if os.path.exists(final_filepath):
os.remove(final_filepath)
shutil.move(src_file, final_filepath)
logging.info("Direct bulk download complete. Saved to: %s",
final_filepath)
return
except Exception as e:
logging.warning(
"Direct bulk download failed (%s). Falling back to API pagination.",
e)
finally:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)

# 2. Fallback to API pagination
# Get the total number of records from the API for a reliable failsafe.
total_records = get_total_records(api_url)
if total_records is None:
logging.fatal("Could not get the total record count. Cannot proceed.")
raise RuntimeError(
'Download failed due to could not get the total record count.')
if total_records == 0:
logging.error(
"Total records returned 0 from API metadata. Cannot proceed.")
raise RuntimeError('Download failed: API metadata reported 0 records.')

skip_count = 0
records_downloaded = 0
temp_merged_filepath = os.path.join(temp_dir, f"merged_{filename}")

try:
# Create a temporary directory for downloaded chunks.
Comment thread
kartik-s21 marked this conversation as resolved.
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
os.makedirs(temp_dir)
os.makedirs(temp_dir, exist_ok=True)

logging.info("Starting download to file: %s", final_filepath)
logging.info("Starting paginated download into temporary staging: %s",
temp_merged_filepath)

# The main download loop for pagination
while total_records == 0 or records_downloaded < total_records:
csv_url = f"{api_url}?$format=csv&$skip={skip_count}"
while records_downloaded < total_records:
csv_url = f"{api_url}?$format=csv&$top={PAGE_SIZE}&$skip={skip_count}"
logging.info("Requesting data from: %s", csv_url)

# The download utility incorrectly appends an .xlsx extension.
util_output_filename = "FimaNfipClaims.xlsx"
util_output_path = os.path.join(temp_dir, util_output_filename)

chunk_filename = f"FimaNfipClaims_{skip_count}.csv"
chunk_filepath = os.path.join(temp_dir, chunk_filename)
# Clean up any leftover chunk files before downloading the next chunk.
merged_filename = os.path.basename(temp_merged_filepath)
for f in os.listdir(temp_dir):
if f != merged_filename:
file_to_remove = os.path.join(temp_dir, f)
if os.path.isfile(file_to_remove):
os.remove(file_to_remove)

download_success = download_file(url=csv_url,
output_folder=temp_dir,
Expand All @@ -131,34 +205,57 @@ def download_data(api_url: str, temp_dir: str):
delay=10,
backoff=2)

if not download_success or not os.path.exists(util_output_path):
logging.fatal(
"Failed to download chunk or file not found. Exiting.")
break
chunk_files = [
os.path.join(temp_dir, f)
for f in os.listdir(temp_dir)
if f != merged_filename and os.path.isfile(os.path.join(temp_dir, f))
]

if not download_success or not chunk_files:
logging.error(
"Failed to download chunk at skip=%s or file not found. Exiting.",
skip_count)
Comment thread
kartik-s21 marked this conversation as resolved.
raise RuntimeError(
f"Failed to download chunk at skip={skip_count}.")

os.rename(util_output_path, chunk_filepath)
chunk_filepath = chunk_files[0]

# The file is a plain text CSV, but we read it in binary mode ('rb')
# to handle potential issues with different line endings (e.g., '\r\n')
# and ensure the bytes are written exactly as they were read.
# Read in binary mode to handle byte-accurate line endings.
with open(chunk_filepath, 'rb') as f_chunk:
content = f_chunk.read()

with open(final_filepath, 'ab') as f_final:
os.remove(chunk_filepath)

with open(temp_merged_filepath, 'ab') as f_temp:
if skip_count == 0:
f_final.write(content)
f_temp.write(content)
if not content.endswith(b'\n'):
f_temp.write(b'\n')
else:
split_content = content.split(b'\n', 1)
if len(split_content) > 1:
content_without_header = split_content[1]
Comment thread
kartik-s21 marked this conversation as resolved.
f_final.write(b'\n' + content_without_header)

num_records_in_chunk = len(content.split(b'\n')) - 1
if content_without_header:
f_temp.write(content_without_header)
if not content_without_header.endswith(b'\n'):
f_temp.write(b'\n')

lines = content.strip(b'\r\n').split(b'\n')
num_records_in_chunk = max(0,
len(lines) -
1) if lines and lines[0] else 0
records_downloaded += num_records_in_chunk

logging.info("Downloaded %s of %s records.", records_downloaded,
total_records)

if num_records_in_chunk == 0:
logging.warning(
"Received empty chunk at skip=%s before reaching "
"total_records (%s). Exiting loop.", skip_count,
total_records)
break

if num_records_in_chunk < PAGE_SIZE:
logging.info(
"Reached the end of the dataset. All records have been downloaded."
Expand All @@ -167,12 +264,27 @@ def download_data(api_url: str, temp_dir: str):

skip_count += PAGE_SIZE

if records_downloaded < total_records:
logging.error(
"Download incomplete: only %s of %s records downloaded.",
records_downloaded, total_records)
raise RuntimeError(
f"Download incomplete: only {records_downloaded} of "
f"{total_records} records downloaded."
)

# Atomically replace final file only on full completion
if os.path.exists(final_filepath):
os.remove(final_filepath)
shutil.move(temp_merged_filepath, final_filepath)

logging.info(
"Total download complete. All available records saved to: %s",
final_filepath)
"Total download complete. All %s available records saved to: %s",
records_downloaded, final_filepath)

except IOError as e:
logging.error("An error occurred while writing the file: %s", e)
raise
finally:
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
Expand All @@ -185,7 +297,8 @@ def main(argv):
Args:
argv: List of command line arguments, as provided by absl.
"""
download_data(_FLAGS.api_url, _FLAGS.temp_dir)
download_data(_FLAGS.api_url, _FLAGS.temp_dir, _FLAGS.bulk_url,
_FLAGS.output_dir)


if __name__ == "__main__":
Expand Down
Loading
Loading