Initial Climate Mood Analysis project

This commit is contained in:
Justin Fisher
2026-06-11 14:50:33 -04:00
commit 18e099ce84
34 changed files with 18807 additions and 0 deletions
@@ -0,0 +1,325 @@
#!/usr/bin/env python3
"""
Download completed NSRDB county polygon archive ZIP files.
This shared engine reads JSON acknowledgements produced by the polygon request
workflow and downloads each outputs.downloadUrl. Metric-specific wrappers
supply the response and archive directories.
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any
DEFAULT_TIMEOUT = 300
DEFAULT_CHUNK_SIZE = 1024 * 1024
class ArchiveDownloadState(Enum):
"""Distinct lifecycle phases for one NSRDB archive download."""
QUEUED = "queued"
CHECKING = "checking"
DOWNLOADING = "downloading"
SKIPPED = "skipped"
PENDING = "pending"
DOWNLOADED = "downloaded"
FAILED = "failed"
class ArchiveDownloadEvent(Enum):
"""Events that may move an archive download to another lifecycle phase."""
START = "start"
EXISTING_FILE_FOUND = "existing_file_found"
DOWNLOAD_STARTED = "download_started"
ARCHIVE_NOT_READY = "archive_not_ready"
DOWNLOAD_SUCCEEDED = "download_succeeded"
DOWNLOAD_FAILED = "download_failed"
ARCHIVE_DOWNLOAD_TRANSITIONS = {
(ArchiveDownloadState.QUEUED, ArchiveDownloadEvent.START): ArchiveDownloadState.CHECKING,
(
ArchiveDownloadState.CHECKING,
ArchiveDownloadEvent.EXISTING_FILE_FOUND,
): ArchiveDownloadState.SKIPPED,
(
ArchiveDownloadState.CHECKING,
ArchiveDownloadEvent.DOWNLOAD_STARTED,
): ArchiveDownloadState.DOWNLOADING,
(
ArchiveDownloadState.DOWNLOADING,
ArchiveDownloadEvent.ARCHIVE_NOT_READY,
): ArchiveDownloadState.PENDING,
(
ArchiveDownloadState.DOWNLOADING,
ArchiveDownloadEvent.DOWNLOAD_SUCCEEDED,
): ArchiveDownloadState.DOWNLOADED,
(
ArchiveDownloadState.QUEUED,
ArchiveDownloadEvent.DOWNLOAD_FAILED,
): ArchiveDownloadState.FAILED,
(
ArchiveDownloadState.CHECKING,
ArchiveDownloadEvent.DOWNLOAD_FAILED,
): ArchiveDownloadState.FAILED,
(
ArchiveDownloadState.DOWNLOADING,
ArchiveDownloadEvent.DOWNLOAD_FAILED,
): ArchiveDownloadState.FAILED,
}
class InvalidArchiveDownloadTransition(RuntimeError):
"""Reject an event that is not valid for the current download state."""
@dataclass
class ArchiveDownloadStateMachine:
"""Track and validate the lifecycle of one archive download."""
label: str
state: ArchiveDownloadState = ArchiveDownloadState.QUEUED
history: list[ArchiveDownloadState] = field(
default_factory=lambda: [ArchiveDownloadState.QUEUED]
)
def transition(self, event: ArchiveDownloadEvent) -> ArchiveDownloadState:
"""Apply one event and return the resulting state."""
next_state = ARCHIVE_DOWNLOAD_TRANSITIONS.get((self.state, event))
if next_state is None:
raise InvalidArchiveDownloadTransition(
f"Invalid archive download transition for {self.label}: "
f"{event.value} while {self.state.value}."
)
self.state = next_state
self.history.append(next_state)
return next_state
class PolygonArchiveDownloadError(RuntimeError):
"""Store a failed archive download with useful context."""
def __init__(self, message: str, status_code: int | None = None) -> None:
super().__init__(message)
self.status_code = status_code
def is_pending_s3_archive_response(url: str, status_code: int) -> bool:
"""Return whether an S3 HTTP response likely means the archive is pending."""
if status_code != 403:
return False
host = urllib.parse.urlsplit(url).netloc.lower()
return host == "s3.amazonaws.com" or host.endswith(".amazonaws.com")
def read_response(response_path: Path) -> dict[str, Any]:
"""Read a saved NSRDB archive response JSON file."""
with response_path.open(encoding="utf-8") as handle:
response = json.load(handle)
if not isinstance(response, dict):
raise ValueError("Response JSON must contain an object.")
return response
def download_url_from_response(response: dict[str, Any]) -> str:
"""Return the download URL from a saved NSRDB archive response."""
outputs = response.get("outputs")
if not isinstance(outputs, dict):
raise ValueError("Response JSON is missing an outputs object.")
download_url = outputs.get("downloadUrl")
if not isinstance(download_url, str) or not download_url.strip():
raise ValueError("Response JSON is missing outputs.downloadUrl.")
return download_url.strip()
def output_name_for_response(response_path: Path) -> str:
"""Convert a response JSON filename into the corresponding ZIP filename."""
stem = response_path.stem
if stem.endswith("_response"):
stem = stem[: -len("_response")]
return f"{stem}.zip"
def download_file(
url: str,
output_path: Path,
timeout: int,
overwrite: bool,
machine: ArchiveDownloadStateMachine,
) -> ArchiveDownloadState:
"""Download a URL to disk and return the terminal download state."""
output_path.parent.mkdir(parents=True, exist_ok=True)
if output_path.exists() and not overwrite:
return machine.transition(ArchiveDownloadEvent.EXISTING_FILE_FOUND)
machine.transition(ArchiveDownloadEvent.DOWNLOAD_STARTED)
temp_path = output_path.with_suffix(f"{output_path.suffix}.part")
if temp_path.exists():
temp_path.unlink()
request = urllib.request.Request(url, headers={"User-Agent": "county-climate-explorer/0.1"})
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
with temp_path.open("wb") as handle:
while True:
chunk = response.read(DEFAULT_CHUNK_SIZE)
if not chunk:
break
handle.write(chunk)
except urllib.error.HTTPError as error:
if temp_path.exists():
temp_path.unlink()
if is_pending_s3_archive_response(url, error.code):
return machine.transition(ArchiveDownloadEvent.ARCHIVE_NOT_READY)
machine.transition(ArchiveDownloadEvent.DOWNLOAD_FAILED)
raise PolygonArchiveDownloadError(f"HTTP {error.code} {error.reason}", error.code) from error
except urllib.error.URLError as error:
if temp_path.exists():
temp_path.unlink()
machine.transition(ArchiveDownloadEvent.DOWNLOAD_FAILED)
raise PolygonArchiveDownloadError(str(error.reason)) from error
temp_path.replace(output_path)
return machine.transition(ArchiveDownloadEvent.DOWNLOAD_SUCCEEDED)
def response_paths(response_dir: Path, start: int, limit: int | None) -> list[Path]:
"""Return saved response JSON files in deterministic order."""
paths = sorted(response_dir.glob("*.json"))
if start < 0:
raise ValueError("--start must be 0 or greater.")
if limit is not None and limit < 1:
raise ValueError("--limit must be 1 or greater.")
if limit is None:
return paths[start:]
return paths[start : start + limit]
def run(args: argparse.Namespace) -> None:
"""Download all requested NSRDB polygon archives."""
paths = response_paths(args.response_dir, args.start, args.limit)
if not paths:
print(f"No response JSON files found in {args.response_dir}")
return
counts = {
ArchiveDownloadState.DOWNLOADED: 0,
ArchiveDownloadState.SKIPPED: 0,
ArchiveDownloadState.PENDING: 0,
ArchiveDownloadState.FAILED: 0,
}
total = len(paths)
for index, response_path in enumerate(paths, start=1):
output_path = args.output_dir / output_name_for_response(response_path)
label = response_path.name
machine = ArchiveDownloadStateMachine(label)
machine.transition(ArchiveDownloadEvent.START)
try:
response = read_response(response_path)
download_url = download_url_from_response(response)
state = download_file(
download_url,
output_path,
args.timeout,
args.overwrite,
machine,
)
except (OSError, ValueError, PolygonArchiveDownloadError) as error:
if machine.state not in {
ArchiveDownloadState.FAILED,
ArchiveDownloadState.SKIPPED,
ArchiveDownloadState.PENDING,
ArchiveDownloadState.DOWNLOADED,
}:
machine.transition(ArchiveDownloadEvent.DOWNLOAD_FAILED)
counts[machine.state] += 1
print(f"[{index}/{total}] Failed {label}: {error}")
continue
counts[state] += 1
if state is ArchiveDownloadState.SKIPPED:
print(f"[{index}/{total}] Skipped existing {output_path}")
elif state is ArchiveDownloadState.PENDING:
print(
f"[{index}/{total}] Pending {label}: archive URL returned "
"S3 HTTP 403; retry after NSRDB finishes generating it."
)
else:
print(f"[{index}/{total}] Downloaded {output_path}")
if args.delay > 0 and index < total:
time.sleep(args.delay)
print(
f"Finished: downloaded={counts[ArchiveDownloadState.DOWNLOADED]}, "
f"skipped={counts[ArchiveDownloadState.SKIPPED]}, "
f"pending={counts[ArchiveDownloadState.PENDING]}, "
f"failed={counts[ArchiveDownloadState.FAILED]}, output_dir={args.output_dir}"
)
def parse_args(
argv: list[str] | None = None,
description: str | None = None,
) -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(description=description or __doc__)
parser.add_argument("--response-dir", type=Path)
parser.add_argument("--output-dir", type=Path)
parser.add_argument("--start", type=int, default=0, help="Zero-based response file offset.")
parser.add_argument("--limit", type=int, help="Number of response files to download.")
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT, help="Download timeout in seconds.")
parser.add_argument("--delay", type=float, default=0.0, help="Seconds to wait between downloads.")
parser.add_argument("--overwrite", action="store_true", help="Download even when the ZIP already exists.")
args = parser.parse_args(argv)
missing = [
option
for option, value in {
"--response-dir": args.response_dir,
"--output-dir": args.output_dir,
}.items()
if not value
]
if missing:
parser.error(f"the following arguments are required: {', '.join(missing)}")
if args.timeout <= 0:
parser.error("--timeout must be greater than 0.")
if args.delay < 0:
parser.error("--delay must be 0 or greater.")
return args
def main(
argv: list[str] | None = None,
description: str | None = None,
) -> None:
"""Run the archive downloader."""
run(parse_args(argv, description))
if __name__ == "__main__":
main()