from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait from io import BytesIO from pathlib import Path from typing import Any import boto3 import pandas as pd from botocore.config import Config from tqdm import tqdm from utils.logger import setup_logger logger = setup_logger() SRC_ROOT = Path("/workspaces/home/epc_data") TMP_ROOT = Path("/tmp/epc_postcodes") S3_BUCKET = "retrofit-data-dev" S3_PREFIX = "historical_epc" # This scripts assume you downloading the zip, unzip it, and running it locally def sanitise(pc: pd.Series) -> pd.Series: return pc.astype("string").str.upper().str.replace(" ", "", regex=False) def shard_la(la_dir: Path) -> None: certs = pd.read_csv(la_dir / "certificates.csv", low_memory=False) certs["POSTCODE_CLEAN"] = sanitise(certs["POSTCODE"]) before = len(certs) certs = certs.dropna(subset=["POSTCODE_CLEAN"]) certs = certs[certs["POSTCODE_CLEAN"] != ""] dropped = before - len(certs) if dropped: logger.warning(f"{la_dir.name}: dropped {dropped} rows with empty postcode") for pc, group in certs.groupby("POSTCODE_CLEAN", sort=False): out = TMP_ROOT / f"{pc}.csv" group.drop(columns=["POSTCODE_CLEAN"]).to_csv( out, mode="a", header=not out.exists(), index=False ) def list_existing_keys(s3: Any) -> set[str]: existing: set[str] = set() paginator = s3.get_paginator("list_objects_v2") pages = paginator.paginate(Bucket=S3_BUCKET, Prefix=f"{S3_PREFIX}/") for page in tqdm(pages, desc="list s3"): for obj in page.get("Contents", []): existing.add(obj["Key"]) logger.info(f"Found {len(existing)} existing objects under {S3_PREFIX}/") return existing def upload_postcode(path: Path, s3: Any) -> None: df = pd.read_csv(path, low_memory=False).drop_duplicates() dupes = df["LMK_KEY"].value_counts() bad = dupes[dupes > 1] if not bad.empty: raise ValueError( f"Postcode {path.stem}: LMK_KEY appears with conflicting cert data: " f"{bad.index.tolist()[:5]}" ) buf = BytesIO() df.to_csv(buf, index=False, compression="gzip") s3.put_object( Bucket=S3_BUCKET, Key=f"{S3_PREFIX}/{path.stem}/data.csv.gz", Body=buf.getvalue(), ContentType="text/csv", ContentEncoding="gzip", ) def main(): TMP_ROOT.mkdir(parents=True, exist_ok=True) la_dirs = sorted( p for p in SRC_ROOT.iterdir() if p.is_dir() and p.name.startswith("domestic-") ) logger.info(f"Sharding {len(la_dirs)} LA folders -> {TMP_ROOT}") for la in tqdm(la_dirs, desc="shard"): shard_la(la) s3 = boto3.client( "s3", config=Config( max_pool_connections=512, retries={"max_attempts": 5, "mode": "standard"} ), ) pc_files = sorted(TMP_ROOT.glob("*.csv")) logger.info(f"Found {len(pc_files)} local shards") existing = list_existing_keys(s3) todo = [p for p in pc_files if f"{S3_PREFIX}/{p.stem}/data.csv.gz" not in existing] skipped = len(pc_files) - len(todo) logger.info( f"Uploading {len(todo)} shards (skipping {skipped} already in S3) -> " f"s3://{S3_BUCKET}/{S3_PREFIX}/" ) workers = 256 todo_iter = iter(todo) inflight: dict[Any, Path] = {} pbar = tqdm(total=len(todo), desc="upload") with ThreadPoolExecutor(max_workers=workers) as pool: for _ in range(workers * 2): pc = next(todo_iter, None) if pc is None: break inflight[pool.submit(upload_postcode, pc, s3)] = pc while inflight: done, _ = wait(inflight.keys(), return_when=FIRST_COMPLETED) for fut in done: pc = inflight.pop(fut) try: fut.result() except Exception as e: logger.error(f"{pc.name}: {e}") raise pbar.update(1) nxt = next(todo_iter, None) if nxt is not None: inflight[pool.submit(upload_postcode, nxt, s3)] = nxt pbar.close() if __name__ == "__main__": main()