ai_lls_lib
AI LLS Library - Core business logic for Landline Scrubber.
This library provides phone verification and DNC checking capabilities.
Version 2.0.0 establishes clean semantic versioning baseline. All version management now controlled by Python Semantic Release.
Dependencies optimized for Lambda deployment (removed unused pandas/pyarrow).
1""" 2AI LLS Library - Core business logic for Landline Scrubber. 3 4This library provides phone verification and DNC checking capabilities. 5 6Version 2.0.0 establishes clean semantic versioning baseline. 7All version management now controlled by Python Semantic Release. 8 9Dependencies optimized for Lambda deployment (removed unused pandas/pyarrow). 10""" 11 12from ai_lls_lib.apikeys import ( 13 KeyNotFoundError, 14 LimitExceededError, 15 ManagedApiKeyService, 16 RevokedKeyError, 17) 18from ai_lls_lib.common import DecimalEncoder, extract_area_code 19from ai_lls_lib.core.cache import DynamoDBCache 20from ai_lls_lib.core.models import ( 21 BulkJob, 22 BulkJobStatus, 23 JobStatus, 24 LineType, 25 PhoneVerification, 26 VerificationResult, 27 VerificationSource, 28) 29from ai_lls_lib.core.processor import BulkProcessor 30from ai_lls_lib.core.verifier import PhoneVerifier 31from ai_lls_lib.files import FileService 32from ai_lls_lib.key_management import ( 33 compute_key_hash, 34 generate_key_id, 35 generate_managed_key, 36 validate_expiration_days, 37) 38from ai_lls_lib.providers.exceptions import ProviderError 39 40__version__ = "3.16.0" 41 42__all__ = [ 43 "PhoneVerification", 44 "BulkJob", 45 "BulkJobStatus", 46 "LineType", 47 "VerificationResult", 48 "VerificationSource", 49 "JobStatus", 50 "PhoneVerifier", 51 "BulkProcessor", 52 "DynamoDBCache", 53 "compute_key_hash", 54 "generate_key_id", 55 "generate_managed_key", 56 "validate_expiration_days", 57 "DecimalEncoder", 58 "extract_area_code", 59 "ManagedApiKeyService", 60 "KeyNotFoundError", 61 "RevokedKeyError", 62 "LimitExceededError", 63 "ProviderError", 64 "FileService", 65]
47class PhoneVerification(BaseModel): 48 """Result of phone number verification""" 49 50 phone_number: str = Field(..., description="E.164 formatted phone number") 51 line_type: LineType = Field(..., description="Type of phone line") 52 dnc: bool = Field(..., description="Whether number is on DNC list") 53 known_litigator: bool = Field(False, description="Whether number belongs to a known litigator") 54 cached: bool = Field(..., description="Whether result came from cache") 55 verified_at: datetime = Field(..., description="When verification occurred") 56 source: VerificationSource = Field(..., description="Source of verification data") 57 58 class Config: 59 json_encoders = {datetime: lambda v: v.isoformat()}
Result of phone number verification
62class BulkJob(BaseModel): 63 """Bulk processing job metadata""" 64 65 job_id: str = Field(..., description="Unique job identifier") 66 status: JobStatus = Field(..., description="Current job status")
Bulk processing job metadata
69class BulkJobStatus(BulkJob): 70 """Extended bulk job status with progress info""" 71 72 total_rows: int | None = Field(None, description="Total rows to process") 73 processed_rows: int | None = Field(None, description="Rows processed so far") 74 result_url: str | None = Field(None, description="S3 URL of results") 75 created_at: datetime = Field(..., description="Job creation time") 76 completed_at: datetime | None = Field(None, description="Job completion time") 77 error: str | None = Field(None, description="Error message if failed")
Extended bulk job status with progress info
13class LineType(StrEnum): 14 """Phone line type enumeration""" 15 16 MOBILE = "mobile" 17 LANDLINE = "landline" 18 VOIP = "voip" 19 UNKNOWN = "unknown"
Phone line type enumeration
39class VerificationResult(NamedTuple): 40 """Raw verification result returned by a provider""" 41 42 line_type: LineType 43 dnc: bool 44 known_litigator: bool = False
Raw verification result returned by a provider
Create new instance of VerificationResult(line_type, dnc, known_litigator)
22class VerificationSource(StrEnum): 23 """Source of verification data""" 24 25 API = "api" 26 CACHE = "cache" 27 BULK_IMPORT = "bulk_import"
Source of verification data
30class JobStatus(StrEnum): 31 """Bulk job status enumeration""" 32 33 PENDING = "pending" 34 PROCESSING = "processing" 35 COMPLETED = "completed" 36 FAILED = "failed"
Bulk job status enumeration
18class PhoneVerifier: 19 """Verifies phone numbers for line type and DNC status""" 20 21 def __init__( 22 self, cache: DynamoDBCache | None = None, provider: VerificationProvider | None = None 23 ): 24 """ 25 Initialize phone verifier. 26 27 Args: 28 cache: Optional DynamoDB cache for storing results 29 provider: Verification provider (defaults to ExternalAPIProvider) 30 """ 31 self.cache = cache 32 self.provider = provider or ExternalAPIProvider() 33 logger.debug("PhoneVerifier initialized") 34 35 def normalize_phone(self, phone: str) -> str: 36 """Normalize phone to E.164 format""" 37 try: 38 # Parse with US as default country 39 parsed = phonenumbers.parse(phone, "US") 40 if not phonenumbers.is_valid_number(parsed): 41 raise ValueError(f"Invalid phone number: {phone}") 42 43 # Format as E.164 44 return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) 45 except Exception as e: 46 logger.error(f"Phone normalization failed: {str(e)}") 47 raise ValueError(f"Invalid phone format: {phone}") from e 48 49 def verify(self, phone: str) -> PhoneVerification: 50 """Verify phone number for line type and DNC status""" 51 normalized = self.normalize_phone(phone) 52 53 # Check cache first if available 54 if self.cache: 55 cached = self.cache.get(normalized) 56 if cached: 57 return cached 58 59 # Use provider to verify 60 provider_result = self.provider.verify_phone(normalized) 61 62 result = PhoneVerification( 63 phone_number=normalized, 64 line_type=provider_result.line_type, 65 dnc=provider_result.dnc, 66 known_litigator=provider_result.known_litigator, 67 cached=False, 68 verified_at=datetime.now(UTC), 69 source=VerificationSource.API, 70 ) 71 72 # Store in cache if available 73 if self.cache: 74 try: 75 self.cache.set(normalized, result) 76 except Exception as e: 77 logger.warning(f"Failed to cache result: {e}") 78 # Continue without caching - don't fail the verification 79 80 return result 81 82 def _check_line_type(self, phone: str) -> LineType: 83 """ 84 Check line type (for backwards compatibility with CLI). 85 Delegates to provider. 86 """ 87 return self.provider.verify_phone(phone).line_type 88 89 def _check_dnc(self, phone: str) -> bool: 90 """ 91 Check DNC status (for backwards compatibility with CLI). 92 Delegates to provider. 93 """ 94 return self.provider.verify_phone(phone).dnc
Verifies phone numbers for line type and DNC status
21 def __init__( 22 self, cache: DynamoDBCache | None = None, provider: VerificationProvider | None = None 23 ): 24 """ 25 Initialize phone verifier. 26 27 Args: 28 cache: Optional DynamoDB cache for storing results 29 provider: Verification provider (defaults to ExternalAPIProvider) 30 """ 31 self.cache = cache 32 self.provider = provider or ExternalAPIProvider() 33 logger.debug("PhoneVerifier initialized")
Initialize phone verifier.
Args: cache: Optional DynamoDB cache for storing results provider: Verification provider (defaults to ExternalAPIProvider)
35 def normalize_phone(self, phone: str) -> str: 36 """Normalize phone to E.164 format""" 37 try: 38 # Parse with US as default country 39 parsed = phonenumbers.parse(phone, "US") 40 if not phonenumbers.is_valid_number(parsed): 41 raise ValueError(f"Invalid phone number: {phone}") 42 43 # Format as E.164 44 return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164) 45 except Exception as e: 46 logger.error(f"Phone normalization failed: {str(e)}") 47 raise ValueError(f"Invalid phone format: {phone}") from e
Normalize phone to E.164 format
49 def verify(self, phone: str) -> PhoneVerification: 50 """Verify phone number for line type and DNC status""" 51 normalized = self.normalize_phone(phone) 52 53 # Check cache first if available 54 if self.cache: 55 cached = self.cache.get(normalized) 56 if cached: 57 return cached 58 59 # Use provider to verify 60 provider_result = self.provider.verify_phone(normalized) 61 62 result = PhoneVerification( 63 phone_number=normalized, 64 line_type=provider_result.line_type, 65 dnc=provider_result.dnc, 66 known_litigator=provider_result.known_litigator, 67 cached=False, 68 verified_at=datetime.now(UTC), 69 source=VerificationSource.API, 70 ) 71 72 # Store in cache if available 73 if self.cache: 74 try: 75 self.cache.set(normalized, result) 76 except Exception as e: 77 logger.warning(f"Failed to cache result: {e}") 78 # Continue without caching - don't fail the verification 79 80 return result
Verify phone number for line type and DNC status
92class BulkProcessor: 93 """Process CSV files for bulk phone verification""" 94 95 def __init__( 96 self, 97 verifier: PhoneVerifier, 98 max_failure_rate: float = 0.2, 99 max_consecutive_failures: int = 20, 100 max_workers: int | None = None, 101 ): 102 """ 103 Args: 104 verifier: PhoneVerifier used for each row. Its provider and cache must be 105 thread-safe when max_workers > 1 (the shipped ones are). 106 max_failure_rate: abort the job (ProviderFailureError) when the share of rows the 107 provider never answered for exceeds this, measured over attempted rows 108 max_consecutive_failures: abort immediately after this many provider failures 109 in a row, so a total outage does not burn a call per row 110 max_workers: bounded thread pool size for provider calls; defaults from 111 LLS_BULK_MAX_WORKERS (10). 1 runs rows serially on the calling thread. 112 Per-row blocking HTTP at ~150 ms gives ~7 rows/s serial; 10 workers 113 raise that roughly tenfold, which is what makes 20k-row files fit a 114 Lambda budget. Input order is preserved in the results. 115 """ 116 self.verifier = verifier 117 self.max_failure_rate = max_failure_rate 118 self.max_consecutive_failures = max_consecutive_failures 119 if max_workers is None: 120 raw = os.environ.get("LLS_BULK_MAX_WORKERS", "") 121 max_workers = int(raw) if raw else 10 122 self.max_workers = max(1, max_workers) 123 124 def _record_row_failure( 125 self, exc: Exception, row_num: int, phone: str, report: ProcessingReport, consecutive: int 126 ) -> int: 127 """Classify a per-row exception into the report and return the new consecutive count. 128 129 Raises ProviderFailureError as soon as consecutive provider failures hit the limit. 130 """ 131 if isinstance(exc, ProviderError): 132 report.provider_failures += 1 133 report.failed_phones.add(phone) 134 logger.error(f"Provider failure at row {row_num}: {exc}") 135 consecutive += 1 136 elif isinstance(exc, ValueError): 137 report.invalid_rows += 1 138 logger.warning(f"Invalid phone at row {row_num}: {exc}") 139 return 0 140 else: 141 report.unexpected_failures += 1 142 report.failed_phones.add(phone) 143 logger.error(f"Verification failed at row {row_num}: {exc}") 144 consecutive += 1 145 146 if consecutive >= self.max_consecutive_failures: 147 raise ProviderFailureError( 148 f"Aborting: provider failed {consecutive} rows in a row (last: {exc})", 149 failures=report.failures, 150 attempted=report.attempted, 151 ) 152 return consecutive 153 154 def _check_failure_rate(self, report: ProcessingReport) -> None: 155 """Raise ProviderFailureError if too many rows were never verified.""" 156 if report.failures and report.failure_rate > self.max_failure_rate: 157 raise ProviderFailureError( 158 f"Provider failed {report.failures} of {report.attempted} rows " 159 f"({report.failure_rate:.0%}); job cannot be reported as verified", 160 failures=report.failures, 161 attempted=report.attempted, 162 ) 163 164 def process_csv(self, csv_text: str, phone_column: str = "phone") -> list[PhoneVerification]: 165 """ 166 Process CSV text content. 167 Returns list of verification results. 168 169 Raises ProviderFailureError when the provider failed for too many rows; see 170 process_csv_report for the per-kind counts. 171 """ 172 return self.process_csv_report(csv_text, phone_column).results 173 174 def process_csv_report(self, csv_text: str, phone_column: str = "phone") -> ProcessingReport: 175 """ 176 Process CSV text content and return a ProcessingReport with results and counts. 177 178 Raises ProviderFailureError when the provider failed for more than max_failure_rate 179 of attempted rows, or for max_consecutive_failures rows in a row. 180 """ 181 report = ProcessingReport() 182 results = report.results 183 consecutive = 0 184 185 try: 186 # Strip UTF-8 BOM if present (Excel on Windows adds this) 187 csv_text = csv_text.lstrip("\ufeff") 188 189 # Use StringIO to parse CSV text 190 csv_file = StringIO(csv_text) 191 reader = _dict_reader(csv_file) 192 193 # Find phone column (case-insensitive) 194 headers = reader.fieldnames or [] 195 phone_col = self._find_phone_column(headers, phone_column) 196 197 if not phone_col: 198 raise ValueError(f"Phone column '{phone_column}' not found in CSV") 199 200 logger.info( 201 f"Starting CSV processing using phone column '{phone_col}' " 202 f"with {self.max_workers} worker(s)" 203 ) 204 205 # Collect the work list first so the pool can be fed in input order 206 work: list[tuple[int, str]] = [] 207 for row_num, row in enumerate(reader, start=2): # Start at 2 (header is 1) 208 report.total_rows += 1 209 phone = row.get(phone_col, "").strip() 210 if not phone: 211 report.empty_rows += 1 212 logger.warning(f"Empty phone at row {row_num}") 213 continue 214 work.append((row_num, phone)) 215 216 # Each outcome is either a PhoneVerification or the exception for that row. 217 # Ordered iteration keeps results in input order regardless of completion order. 218 for row_num, phone, outcome in self._verify_all(work): 219 if isinstance(outcome, PhoneVerification): 220 results.append(outcome) 221 consecutive = 0 222 if len(results) % 100 == 0: 223 logger.info(f"Processed {len(results)} phones (at row {row_num})") 224 else: 225 consecutive = self._record_row_failure( 226 outcome, row_num, phone, report, consecutive 227 ) 228 229 self._check_failure_rate(report) 230 logger.info( 231 f"Completed processing: {report.verified} verified, " 232 f"{report.invalid_rows} invalid, {report.failures} not verified" 233 ) 234 235 except Exception as e: 236 logger.error(f"CSV processing failed: {str(e)}") 237 raise 238 239 return report 240 241 def _verify_one(self, phone: str) -> PhoneVerification | Exception: 242 """Verify a single phone, returning the exception instead of raising.""" 243 try: 244 return self.verifier.verify(phone) 245 except Exception as e: # classified by the caller 246 return e 247 248 def _verify_all( 249 self, work: list[tuple[int, str]] 250 ) -> Iterator[tuple[int, str, PhoneVerification | Exception]]: 251 """Yield (row_num, phone, outcome) in input order, using the bounded worker pool. 252 253 Serial when max_workers is 1. Otherwise rows are submitted in order and outcomes 254 yielded in order; if the consumer stops early (a ProviderFailureError from the 255 failure classifier), the remaining queued rows are cancelled instead of run. 256 """ 257 if self.max_workers == 1 or len(work) <= 1: 258 for row_num, phone in work: 259 yield row_num, phone, self._verify_one(phone) 260 return 261 262 workers = min(self.max_workers, len(work)) 263 executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bulk-verify") 264 try: 265 futures = [executor.submit(self._verify_one, phone) for _, phone in work] 266 for (row_num, phone), future in zip(work, futures, strict=True): 267 yield row_num, phone, future.result() 268 finally: 269 executor.shutdown(wait=True, cancel_futures=True) 270 271 def _find_phone_column(self, headers: list[str] | Sequence[str], preferred: str) -> str | None: 272 """Find phone column in headers (case-insensitive)""" 273 # First try exact match 274 for header in headers: 275 if header.lower() == preferred.lower(): 276 return header 277 278 # Common phone column names 279 phone_patterns = [ 280 "phone", 281 "phone_number", 282 "phonenumber", 283 "mobile", 284 "cell", 285 "telephone", 286 "tel", 287 "number", 288 "contact", 289 ] 290 291 for header in headers: 292 header_lower = header.lower() 293 for pattern in phone_patterns: 294 if pattern in header_lower: 295 logger.info(f"Using column '{header}' as phone column") 296 return header 297 298 return None 299 300 def generate_results_csv( 301 self, 302 original_csv_text: str, 303 results: list[PhoneVerification], 304 failed_phones: set[str] | None = None, 305 ) -> str: 306 """ 307 Generate CSV with original data plus verification results. 308 Adds columns: line_type, dnc, known_litigator 309 Rows listed in failed_phones (raw phone values the provider never answered for) 310 are marked verification_failed rather than unknown. 311 Returns CSV text string. 312 """ 313 failed_phones = failed_phones or set() 314 # Create lookup dict 315 results_map = {r.phone_number: r for r in results} 316 317 # Parse original CSV (strip UTF-8 BOM if present) 318 original_csv_text = original_csv_text.lstrip("\ufeff") 319 input_file = StringIO(original_csv_text) 320 reader = _dict_reader(input_file) 321 headers = list(reader.fieldnames or []) 322 323 # Add new columns 324 output_headers = headers + ["line_type", "dnc", "known_litigator"] 325 326 # Create output CSV in memory 327 output = StringIO() 328 writer = csv.DictWriter(output, fieldnames=output_headers) 329 writer.writeheader() 330 331 phone_col = self._find_phone_column(headers, "phone") 332 folded_rows = 0 333 334 for row in reader: 335 # Read the lookup key before folding so surplus never alters it 336 phone = row.get(phone_col, "").strip() 337 if _fold_surplus(row, headers): 338 folded_rows += 1 339 340 # Try to normalize for lookup 341 try: 342 normalized = self.verifier.normalize_phone(phone) 343 if phone in failed_phones: 344 row["line_type"] = VERIFICATION_FAILED 345 row["dnc"] = "" 346 row["known_litigator"] = "" 347 elif normalized in results_map: 348 result = results_map[normalized] 349 row["line_type"] = result.line_type.value 350 row["dnc"] = "true" if result.dnc else "false" 351 row["known_litigator"] = "true" if result.known_litigator else "false" 352 else: 353 row["line_type"] = "unknown" 354 row["dnc"] = "" 355 row["known_litigator"] = "" 356 except Exception: 357 row["line_type"] = "invalid" 358 row["dnc"] = "" 359 row["known_litigator"] = "" 360 361 writer.writerow(row) 362 363 if folded_rows: 364 logger.warning(f"Folded surplus fields into last column on {folded_rows} ragged rows") 365 366 # Return CSV text 367 return output.getvalue() 368 369 def process_csv_stream( 370 self, lines: Iterable[str], phone_column: str = "phone", batch_size: int = 100 371 ) -> Iterator[list[PhoneVerification]]: 372 """ 373 Process CSV lines as a stream, yielding batches of results. 374 Memory-efficient for large files. 375 376 Args: 377 lines: Iterator of CSV lines (including header) 378 phone_column: Column name containing phone numbers 379 batch_size: Number of results to accumulate before yielding 380 381 Yields: 382 Batches of PhoneVerification results 383 """ 384 lines_list = list(lines) # Need to iterate twice - once for headers, once for data 385 386 if not lines_list: 387 logger.error("Empty CSV stream") 388 return 389 390 # Parse header (strip UTF-8 BOM if present) 391 header_line = lines_list[0].lstrip("\ufeff") 392 reader = _dict_reader(StringIO(header_line)) 393 headers = reader.fieldnames or [] 394 phone_col = self._find_phone_column(headers, phone_column) 395 396 if not phone_col: 397 raise ValueError(f"Phone column '{phone_column}' not found in CSV") 398 399 batch = [] 400 row_num = 2 # Start at 2 (header is 1) 401 total_processed = 0 402 report = ProcessingReport() 403 consecutive = 0 404 405 # Process data lines 406 for line in lines_list[1:]: 407 if not line.strip(): 408 continue 409 410 phone = "" 411 try: 412 # Parse single line 413 row = next(_dict_reader(StringIO(line), fieldnames=headers)) 414 phone = row.get(phone_col, "").strip() 415 416 if not phone: 417 logger.warning(f"Empty phone at row {row_num}") 418 row_num += 1 419 continue 420 421 # Verify phone 422 result = self.verifier.verify(phone) 423 batch.append(result) 424 total_processed += 1 425 consecutive = 0 426 427 # Yield batch when full 428 if len(batch) >= batch_size: 429 logger.info( 430 f"Processed batch of {len(batch)} phones (total: {total_processed}, at row {row_num})" 431 ) 432 yield batch 433 batch = [] 434 435 except Exception as e: 436 consecutive = self._record_row_failure(e, row_num, phone, report, consecutive) 437 finally: 438 row_num += 1 439 440 # Yield remaining results 441 if batch: 442 logger.info(f"Processed final batch of {len(batch)} phones (total: {total_processed})") 443 yield batch 444 445 # Results were yielded, not kept; the report carries counts only 446 report_attempted = total_processed + report.failures + report.invalid_rows 447 if report.failures and report.failures / report_attempted > self.max_failure_rate: 448 raise ProviderFailureError( 449 f"Provider failed {report.failures} of {report_attempted} rows; " 450 "job cannot be reported as verified", 451 failures=report.failures, 452 attempted=report_attempted, 453 ) 454 455 logger.info(f"Stream processing completed. Total processed: {total_processed}") 456 457 def generate_results_csv_stream( 458 self, 459 original_lines: Iterable[str], 460 results_stream: Iterator[list[PhoneVerification]], 461 phone_column: str = "phone", 462 failed_phones: set[str] | None = None, 463 ) -> Iterator[str]: 464 """ 465 Generate CSV results as a stream, line by line. 466 Memory-efficient for large files. 467 468 Args: 469 original_lines: Iterator of original CSV lines 470 results_stream: Iterator of batched PhoneVerification results 471 phone_column: Column name containing phone numbers 472 failed_phones: raw phone values the provider never answered for; marked 473 verification_failed rather than unknown 474 475 Yields: 476 CSV lines with verification results added 477 """ 478 failed_phones = failed_phones or set() 479 lines_iter = iter(original_lines) 480 481 # Read and yield modified header 482 try: 483 header_line = next(lines_iter).lstrip("\ufeff") 484 reader = _dict_reader(StringIO(header_line)) 485 headers = list(reader.fieldnames or []) 486 487 # Add new columns 488 output_headers = headers + ["line_type", "dnc", "known_litigator"] 489 yield ",".join(output_headers) + "\n" 490 491 phone_col = self._find_phone_column(headers, phone_column) 492 493 except StopIteration: 494 return 495 496 # Build results lookup from stream 497 results_map = {} 498 for batch in results_stream: 499 for result in batch: 500 results_map[result.phone_number] = result 501 502 # Reset lines iterator 503 lines_iter = iter(original_lines) 504 next(lines_iter) # Skip header 505 506 # Process and yield data lines 507 for line in lines_iter: 508 if not line.strip(): 509 continue 510 511 row = next(_dict_reader(StringIO(line), fieldnames=headers)) 512 # Read the lookup key before folding so surplus never alters it 513 phone = row.get(phone_col, "").strip() 514 _fold_surplus(row, headers) 515 516 # Add verification results 517 try: 518 normalized = self.verifier.normalize_phone(phone) 519 if phone in failed_phones: 520 row["line_type"] = VERIFICATION_FAILED 521 row["dnc"] = "" 522 row["known_litigator"] = "" 523 elif normalized in results_map: 524 result = results_map[normalized] 525 row["line_type"] = result.line_type.value 526 row["dnc"] = "true" if result.dnc else "false" 527 row["known_litigator"] = "true" if result.known_litigator else "false" 528 else: 529 row["line_type"] = "unknown" 530 row["dnc"] = "" 531 row["known_litigator"] = "" 532 except Exception: 533 row["line_type"] = "invalid" 534 row["dnc"] = "" 535 row["known_litigator"] = "" 536 537 # Write row 538 output = StringIO() 539 writer = csv.DictWriter(output, fieldnames=output_headers) 540 writer.writerow(row) 541 yield output.getvalue()
Process CSV files for bulk phone verification
95 def __init__( 96 self, 97 verifier: PhoneVerifier, 98 max_failure_rate: float = 0.2, 99 max_consecutive_failures: int = 20, 100 max_workers: int | None = None, 101 ): 102 """ 103 Args: 104 verifier: PhoneVerifier used for each row. Its provider and cache must be 105 thread-safe when max_workers > 1 (the shipped ones are). 106 max_failure_rate: abort the job (ProviderFailureError) when the share of rows the 107 provider never answered for exceeds this, measured over attempted rows 108 max_consecutive_failures: abort immediately after this many provider failures 109 in a row, so a total outage does not burn a call per row 110 max_workers: bounded thread pool size for provider calls; defaults from 111 LLS_BULK_MAX_WORKERS (10). 1 runs rows serially on the calling thread. 112 Per-row blocking HTTP at ~150 ms gives ~7 rows/s serial; 10 workers 113 raise that roughly tenfold, which is what makes 20k-row files fit a 114 Lambda budget. Input order is preserved in the results. 115 """ 116 self.verifier = verifier 117 self.max_failure_rate = max_failure_rate 118 self.max_consecutive_failures = max_consecutive_failures 119 if max_workers is None: 120 raw = os.environ.get("LLS_BULK_MAX_WORKERS", "") 121 max_workers = int(raw) if raw else 10 122 self.max_workers = max(1, max_workers)
Args: verifier: PhoneVerifier used for each row. Its provider and cache must be thread-safe when max_workers > 1 (the shipped ones are). max_failure_rate: abort the job (ProviderFailureError) when the share of rows the provider never answered for exceeds this, measured over attempted rows max_consecutive_failures: abort immediately after this many provider failures in a row, so a total outage does not burn a call per row max_workers: bounded thread pool size for provider calls; defaults from LLS_BULK_MAX_WORKERS (10). 1 runs rows serially on the calling thread. Per-row blocking HTTP at ~150 ms gives ~7 rows/s serial; 10 workers raise that roughly tenfold, which is what makes 20k-row files fit a Lambda budget. Input order is preserved in the results.
164 def process_csv(self, csv_text: str, phone_column: str = "phone") -> list[PhoneVerification]: 165 """ 166 Process CSV text content. 167 Returns list of verification results. 168 169 Raises ProviderFailureError when the provider failed for too many rows; see 170 process_csv_report for the per-kind counts. 171 """ 172 return self.process_csv_report(csv_text, phone_column).results
Process CSV text content. Returns list of verification results.
Raises ProviderFailureError when the provider failed for too many rows; see process_csv_report for the per-kind counts.
174 def process_csv_report(self, csv_text: str, phone_column: str = "phone") -> ProcessingReport: 175 """ 176 Process CSV text content and return a ProcessingReport with results and counts. 177 178 Raises ProviderFailureError when the provider failed for more than max_failure_rate 179 of attempted rows, or for max_consecutive_failures rows in a row. 180 """ 181 report = ProcessingReport() 182 results = report.results 183 consecutive = 0 184 185 try: 186 # Strip UTF-8 BOM if present (Excel on Windows adds this) 187 csv_text = csv_text.lstrip("\ufeff") 188 189 # Use StringIO to parse CSV text 190 csv_file = StringIO(csv_text) 191 reader = _dict_reader(csv_file) 192 193 # Find phone column (case-insensitive) 194 headers = reader.fieldnames or [] 195 phone_col = self._find_phone_column(headers, phone_column) 196 197 if not phone_col: 198 raise ValueError(f"Phone column '{phone_column}' not found in CSV") 199 200 logger.info( 201 f"Starting CSV processing using phone column '{phone_col}' " 202 f"with {self.max_workers} worker(s)" 203 ) 204 205 # Collect the work list first so the pool can be fed in input order 206 work: list[tuple[int, str]] = [] 207 for row_num, row in enumerate(reader, start=2): # Start at 2 (header is 1) 208 report.total_rows += 1 209 phone = row.get(phone_col, "").strip() 210 if not phone: 211 report.empty_rows += 1 212 logger.warning(f"Empty phone at row {row_num}") 213 continue 214 work.append((row_num, phone)) 215 216 # Each outcome is either a PhoneVerification or the exception for that row. 217 # Ordered iteration keeps results in input order regardless of completion order. 218 for row_num, phone, outcome in self._verify_all(work): 219 if isinstance(outcome, PhoneVerification): 220 results.append(outcome) 221 consecutive = 0 222 if len(results) % 100 == 0: 223 logger.info(f"Processed {len(results)} phones (at row {row_num})") 224 else: 225 consecutive = self._record_row_failure( 226 outcome, row_num, phone, report, consecutive 227 ) 228 229 self._check_failure_rate(report) 230 logger.info( 231 f"Completed processing: {report.verified} verified, " 232 f"{report.invalid_rows} invalid, {report.failures} not verified" 233 ) 234 235 except Exception as e: 236 logger.error(f"CSV processing failed: {str(e)}") 237 raise 238 239 return report
Process CSV text content and return a ProcessingReport with results and counts.
Raises ProviderFailureError when the provider failed for more than max_failure_rate of attempted rows, or for max_consecutive_failures rows in a row.
300 def generate_results_csv( 301 self, 302 original_csv_text: str, 303 results: list[PhoneVerification], 304 failed_phones: set[str] | None = None, 305 ) -> str: 306 """ 307 Generate CSV with original data plus verification results. 308 Adds columns: line_type, dnc, known_litigator 309 Rows listed in failed_phones (raw phone values the provider never answered for) 310 are marked verification_failed rather than unknown. 311 Returns CSV text string. 312 """ 313 failed_phones = failed_phones or set() 314 # Create lookup dict 315 results_map = {r.phone_number: r for r in results} 316 317 # Parse original CSV (strip UTF-8 BOM if present) 318 original_csv_text = original_csv_text.lstrip("\ufeff") 319 input_file = StringIO(original_csv_text) 320 reader = _dict_reader(input_file) 321 headers = list(reader.fieldnames or []) 322 323 # Add new columns 324 output_headers = headers + ["line_type", "dnc", "known_litigator"] 325 326 # Create output CSV in memory 327 output = StringIO() 328 writer = csv.DictWriter(output, fieldnames=output_headers) 329 writer.writeheader() 330 331 phone_col = self._find_phone_column(headers, "phone") 332 folded_rows = 0 333 334 for row in reader: 335 # Read the lookup key before folding so surplus never alters it 336 phone = row.get(phone_col, "").strip() 337 if _fold_surplus(row, headers): 338 folded_rows += 1 339 340 # Try to normalize for lookup 341 try: 342 normalized = self.verifier.normalize_phone(phone) 343 if phone in failed_phones: 344 row["line_type"] = VERIFICATION_FAILED 345 row["dnc"] = "" 346 row["known_litigator"] = "" 347 elif normalized in results_map: 348 result = results_map[normalized] 349 row["line_type"] = result.line_type.value 350 row["dnc"] = "true" if result.dnc else "false" 351 row["known_litigator"] = "true" if result.known_litigator else "false" 352 else: 353 row["line_type"] = "unknown" 354 row["dnc"] = "" 355 row["known_litigator"] = "" 356 except Exception: 357 row["line_type"] = "invalid" 358 row["dnc"] = "" 359 row["known_litigator"] = "" 360 361 writer.writerow(row) 362 363 if folded_rows: 364 logger.warning(f"Folded surplus fields into last column on {folded_rows} ragged rows") 365 366 # Return CSV text 367 return output.getvalue()
Generate CSV with original data plus verification results. Adds columns: line_type, dnc, known_litigator Rows listed in failed_phones (raw phone values the provider never answered for) are marked verification_failed rather than unknown. Returns CSV text string.
369 def process_csv_stream( 370 self, lines: Iterable[str], phone_column: str = "phone", batch_size: int = 100 371 ) -> Iterator[list[PhoneVerification]]: 372 """ 373 Process CSV lines as a stream, yielding batches of results. 374 Memory-efficient for large files. 375 376 Args: 377 lines: Iterator of CSV lines (including header) 378 phone_column: Column name containing phone numbers 379 batch_size: Number of results to accumulate before yielding 380 381 Yields: 382 Batches of PhoneVerification results 383 """ 384 lines_list = list(lines) # Need to iterate twice - once for headers, once for data 385 386 if not lines_list: 387 logger.error("Empty CSV stream") 388 return 389 390 # Parse header (strip UTF-8 BOM if present) 391 header_line = lines_list[0].lstrip("\ufeff") 392 reader = _dict_reader(StringIO(header_line)) 393 headers = reader.fieldnames or [] 394 phone_col = self._find_phone_column(headers, phone_column) 395 396 if not phone_col: 397 raise ValueError(f"Phone column '{phone_column}' not found in CSV") 398 399 batch = [] 400 row_num = 2 # Start at 2 (header is 1) 401 total_processed = 0 402 report = ProcessingReport() 403 consecutive = 0 404 405 # Process data lines 406 for line in lines_list[1:]: 407 if not line.strip(): 408 continue 409 410 phone = "" 411 try: 412 # Parse single line 413 row = next(_dict_reader(StringIO(line), fieldnames=headers)) 414 phone = row.get(phone_col, "").strip() 415 416 if not phone: 417 logger.warning(f"Empty phone at row {row_num}") 418 row_num += 1 419 continue 420 421 # Verify phone 422 result = self.verifier.verify(phone) 423 batch.append(result) 424 total_processed += 1 425 consecutive = 0 426 427 # Yield batch when full 428 if len(batch) >= batch_size: 429 logger.info( 430 f"Processed batch of {len(batch)} phones (total: {total_processed}, at row {row_num})" 431 ) 432 yield batch 433 batch = [] 434 435 except Exception as e: 436 consecutive = self._record_row_failure(e, row_num, phone, report, consecutive) 437 finally: 438 row_num += 1 439 440 # Yield remaining results 441 if batch: 442 logger.info(f"Processed final batch of {len(batch)} phones (total: {total_processed})") 443 yield batch 444 445 # Results were yielded, not kept; the report carries counts only 446 report_attempted = total_processed + report.failures + report.invalid_rows 447 if report.failures and report.failures / report_attempted > self.max_failure_rate: 448 raise ProviderFailureError( 449 f"Provider failed {report.failures} of {report_attempted} rows; " 450 "job cannot be reported as verified", 451 failures=report.failures, 452 attempted=report_attempted, 453 ) 454 455 logger.info(f"Stream processing completed. Total processed: {total_processed}")
Process CSV lines as a stream, yielding batches of results. Memory-efficient for large files.
Args: lines: Iterator of CSV lines (including header) phone_column: Column name containing phone numbers batch_size: Number of results to accumulate before yielding
Yields: Batches of PhoneVerification results
457 def generate_results_csv_stream( 458 self, 459 original_lines: Iterable[str], 460 results_stream: Iterator[list[PhoneVerification]], 461 phone_column: str = "phone", 462 failed_phones: set[str] | None = None, 463 ) -> Iterator[str]: 464 """ 465 Generate CSV results as a stream, line by line. 466 Memory-efficient for large files. 467 468 Args: 469 original_lines: Iterator of original CSV lines 470 results_stream: Iterator of batched PhoneVerification results 471 phone_column: Column name containing phone numbers 472 failed_phones: raw phone values the provider never answered for; marked 473 verification_failed rather than unknown 474 475 Yields: 476 CSV lines with verification results added 477 """ 478 failed_phones = failed_phones or set() 479 lines_iter = iter(original_lines) 480 481 # Read and yield modified header 482 try: 483 header_line = next(lines_iter).lstrip("\ufeff") 484 reader = _dict_reader(StringIO(header_line)) 485 headers = list(reader.fieldnames or []) 486 487 # Add new columns 488 output_headers = headers + ["line_type", "dnc", "known_litigator"] 489 yield ",".join(output_headers) + "\n" 490 491 phone_col = self._find_phone_column(headers, phone_column) 492 493 except StopIteration: 494 return 495 496 # Build results lookup from stream 497 results_map = {} 498 for batch in results_stream: 499 for result in batch: 500 results_map[result.phone_number] = result 501 502 # Reset lines iterator 503 lines_iter = iter(original_lines) 504 next(lines_iter) # Skip header 505 506 # Process and yield data lines 507 for line in lines_iter: 508 if not line.strip(): 509 continue 510 511 row = next(_dict_reader(StringIO(line), fieldnames=headers)) 512 # Read the lookup key before folding so surplus never alters it 513 phone = row.get(phone_col, "").strip() 514 _fold_surplus(row, headers) 515 516 # Add verification results 517 try: 518 normalized = self.verifier.normalize_phone(phone) 519 if phone in failed_phones: 520 row["line_type"] = VERIFICATION_FAILED 521 row["dnc"] = "" 522 row["known_litigator"] = "" 523 elif normalized in results_map: 524 result = results_map[normalized] 525 row["line_type"] = result.line_type.value 526 row["dnc"] = "true" if result.dnc else "false" 527 row["known_litigator"] = "true" if result.known_litigator else "false" 528 else: 529 row["line_type"] = "unknown" 530 row["dnc"] = "" 531 row["known_litigator"] = "" 532 except Exception: 533 row["line_type"] = "invalid" 534 row["dnc"] = "" 535 row["known_litigator"] = "" 536 537 # Write row 538 output = StringIO() 539 writer = csv.DictWriter(output, fieldnames=output_headers) 540 writer.writerow(row) 541 yield output.getvalue()
Generate CSV results as a stream, line by line. Memory-efficient for large files.
Args: original_lines: Iterator of original CSV lines results_stream: Iterator of batched PhoneVerification results phone_column: Column name containing phone numbers failed_phones: raw phone values the provider never answered for; marked verification_failed rather than unknown
Yields: CSV lines with verification results added
18class DynamoDBCache: 19 """Cache for phone verification results using DynamoDB with TTL""" 20 21 def __init__(self, table_name: str, ttl_days: int = 90): 22 self.table_name = table_name 23 self.ttl_days = ttl_days 24 # boto3 resources are not thread-safe. The bulk processor verifies rows from a 25 # worker pool, so each thread gets its own resource and Table, created lazily. 26 self._local = threading.local() 27 28 @property 29 def dynamodb(self) -> Any: 30 if not hasattr(self._local, "dynamodb"): 31 self._local.dynamodb = boto3.resource("dynamodb") 32 return self._local.dynamodb 33 34 @property 35 def table(self) -> Any: 36 if not hasattr(self._local, "table"): 37 self._local.table = self.dynamodb.Table(self.table_name) 38 return self._local.table 39 40 def get(self, phone_number: str) -> PhoneVerification | None: 41 """Get cached verification result""" 42 try: 43 response = self.table.get_item(Key={"phone_number": phone_number}) 44 45 if "Item" not in response: 46 logger.info(f"Cache miss for {phone_number[:6]}***") 47 return None 48 49 item: dict[str, Any] = response["Item"] 50 51 if "known_litigator" not in item: 52 # Entry predates litigator tracking - treat as a miss so the 53 # number is re-verified and the entry rewritten with the new schema 54 logger.info( 55 f"Cache entry for {phone_number[:6]}*** predates known_litigator; " 56 "treating as miss" 57 ) 58 return None 59 60 logger.info(f"Cache hit for {phone_number[:6]}***") 61 62 return PhoneVerification( 63 phone_number=str(item["phone_number"]), 64 line_type=LineType(str(item["line_type"])), 65 dnc=bool(item["dnc"]), 66 known_litigator=bool(item["known_litigator"]), 67 cached=True, 68 verified_at=datetime.fromisoformat(str(item["verified_at"])), 69 source=VerificationSource.CACHE, 70 ) 71 72 except Exception as e: 73 logger.error(f"Cache get error: {str(e)}") 74 return None 75 76 def set(self, phone_number: str, verification: PhoneVerification) -> None: 77 """Store verification result in cache""" 78 try: 79 ttl = int((datetime.now(UTC) + timedelta(days=self.ttl_days)).timestamp()) 80 81 self.table.put_item( 82 Item={ 83 "phone_number": phone_number, 84 "line_type": verification.line_type.value, 85 "dnc": verification.dnc, 86 "known_litigator": verification.known_litigator, 87 "verified_at": verification.verified_at.isoformat(), 88 "source": verification.source.value, 89 "ttl": ttl, 90 } 91 ) 92 93 logger.info(f"Cached result for {phone_number[:6]}***") 94 95 except Exception as e: 96 logger.error(f"Cache set error: {str(e)}") 97 # Don't fail the request if cache write fails 98 99 def batch_get(self, phone_numbers: list[str]) -> dict[str, PhoneVerification | None]: 100 """Get multiple cached results""" 101 results: dict[str, PhoneVerification | None] = {} 102 103 # DynamoDB batch get (max 100 items per request) 104 for i in range(0, len(phone_numbers), 100): 105 batch = phone_numbers[i : i + 100] 106 107 try: 108 response = self.dynamodb.batch_get_item( 109 RequestItems={ 110 self.table_name: {"Keys": [{"phone_number": phone} for phone in batch]} 111 } 112 ) 113 114 legacy_entries = 0 115 for item in response.get("Responses", {}).get(self.table_name, []): 116 if "known_litigator" not in item: 117 # Entry predates litigator tracking - leave as a miss 118 legacy_entries += 1 119 continue 120 phone = str(item["phone_number"]) 121 results[phone] = PhoneVerification( 122 phone_number=phone, 123 line_type=LineType(str(item["line_type"])), 124 dnc=bool(item["dnc"]), 125 known_litigator=bool(item["known_litigator"]), 126 cached=True, 127 verified_at=datetime.fromisoformat(str(item["verified_at"])), 128 source=VerificationSource.CACHE, 129 ) 130 131 if legacy_entries: 132 logger.info( 133 f"{legacy_entries} cache entries predate known_litigator; " 134 "treating as misses" 135 ) 136 137 except Exception as e: 138 logger.error(f"Batch cache get error: {str(e)}") 139 140 # Fill in None for misses 141 for phone in phone_numbers: 142 if phone not in results: 143 results[phone] = None 144 145 return results
Cache for phone verification results using DynamoDB with TTL
21 def __init__(self, table_name: str, ttl_days: int = 90): 22 self.table_name = table_name 23 self.ttl_days = ttl_days 24 # boto3 resources are not thread-safe. The bulk processor verifies rows from a 25 # worker pool, so each thread gets its own resource and Table, created lazily. 26 self._local = threading.local()
40 def get(self, phone_number: str) -> PhoneVerification | None: 41 """Get cached verification result""" 42 try: 43 response = self.table.get_item(Key={"phone_number": phone_number}) 44 45 if "Item" not in response: 46 logger.info(f"Cache miss for {phone_number[:6]}***") 47 return None 48 49 item: dict[str, Any] = response["Item"] 50 51 if "known_litigator" not in item: 52 # Entry predates litigator tracking - treat as a miss so the 53 # number is re-verified and the entry rewritten with the new schema 54 logger.info( 55 f"Cache entry for {phone_number[:6]}*** predates known_litigator; " 56 "treating as miss" 57 ) 58 return None 59 60 logger.info(f"Cache hit for {phone_number[:6]}***") 61 62 return PhoneVerification( 63 phone_number=str(item["phone_number"]), 64 line_type=LineType(str(item["line_type"])), 65 dnc=bool(item["dnc"]), 66 known_litigator=bool(item["known_litigator"]), 67 cached=True, 68 verified_at=datetime.fromisoformat(str(item["verified_at"])), 69 source=VerificationSource.CACHE, 70 ) 71 72 except Exception as e: 73 logger.error(f"Cache get error: {str(e)}") 74 return None
Get cached verification result
76 def set(self, phone_number: str, verification: PhoneVerification) -> None: 77 """Store verification result in cache""" 78 try: 79 ttl = int((datetime.now(UTC) + timedelta(days=self.ttl_days)).timestamp()) 80 81 self.table.put_item( 82 Item={ 83 "phone_number": phone_number, 84 "line_type": verification.line_type.value, 85 "dnc": verification.dnc, 86 "known_litigator": verification.known_litigator, 87 "verified_at": verification.verified_at.isoformat(), 88 "source": verification.source.value, 89 "ttl": ttl, 90 } 91 ) 92 93 logger.info(f"Cached result for {phone_number[:6]}***") 94 95 except Exception as e: 96 logger.error(f"Cache set error: {str(e)}") 97 # Don't fail the request if cache write fails
Store verification result in cache
99 def batch_get(self, phone_numbers: list[str]) -> dict[str, PhoneVerification | None]: 100 """Get multiple cached results""" 101 results: dict[str, PhoneVerification | None] = {} 102 103 # DynamoDB batch get (max 100 items per request) 104 for i in range(0, len(phone_numbers), 100): 105 batch = phone_numbers[i : i + 100] 106 107 try: 108 response = self.dynamodb.batch_get_item( 109 RequestItems={ 110 self.table_name: {"Keys": [{"phone_number": phone} for phone in batch]} 111 } 112 ) 113 114 legacy_entries = 0 115 for item in response.get("Responses", {}).get(self.table_name, []): 116 if "known_litigator" not in item: 117 # Entry predates litigator tracking - leave as a miss 118 legacy_entries += 1 119 continue 120 phone = str(item["phone_number"]) 121 results[phone] = PhoneVerification( 122 phone_number=phone, 123 line_type=LineType(str(item["line_type"])), 124 dnc=bool(item["dnc"]), 125 known_litigator=bool(item["known_litigator"]), 126 cached=True, 127 verified_at=datetime.fromisoformat(str(item["verified_at"])), 128 source=VerificationSource.CACHE, 129 ) 130 131 if legacy_entries: 132 logger.info( 133 f"{legacy_entries} cache entries predate known_litigator; " 134 "treating as misses" 135 ) 136 137 except Exception as e: 138 logger.error(f"Batch cache get error: {str(e)}") 139 140 # Fill in None for misses 141 for phone in phone_numbers: 142 if phone not in results: 143 results[phone] = None 144 145 return results
Get multiple cached results
40def compute_key_hash(key: str) -> str: 41 """Compute the SHA-256 hash of an API key. 42 43 Args: 44 key: The full API key string. 45 46 Returns: 47 Hex digest of the SHA-256 hash. 48 """ 49 return hashlib.sha256(key.encode()).hexdigest()
Compute the SHA-256 hash of an API key.
Args: key: The full API key string.
Returns: Hex digest of the SHA-256 hash.
22def generate_key_id() -> str: 23 """Generate a unique key ID with mk_ prefix. 24 25 Returns a key ID in the format ``mk_<24 hex chars>`` using 96-bit 26 entropy via :func:`secrets.token_hex`. 27 """ 28 return f"{KEY_ID_PREFIX}{secrets.token_hex(12)}"
Generate a unique key ID with mk_ prefix.
Returns a key ID in the format mk_<24 hex chars> using 96-bit
entropy via secrets.token_hex().
31def generate_managed_key() -> str: 32 """Generate a new managed API key. 33 34 Returns a key in the format ``lls_mk_<40 hex chars>`` using 160-bit 35 entropy via :func:`secrets.token_hex`. 36 """ 37 return f"{MANAGED_KEY_PREFIX}{secrets.token_hex(20)}"
Generate a new managed API key.
Returns a key in the format lls_mk_<40 hex chars> using 160-bit
entropy via secrets.token_hex().
52def validate_expiration_days(days: int) -> bool: 53 """Validate that an expiration period is within the allowed range. 54 55 Args: 56 days: Number of days until key expiration. 57 58 Returns: 59 ``True`` if *days* is between 1 and 730 inclusive, ``False`` otherwise. 60 """ 61 return MIN_EXPIRATION_DAYS <= days <= MAX_EXPIRATION_DAYS
Validate that an expiration period is within the allowed range.
Args: days: Number of days until key expiration.
Returns:
True if days is between 1 and 730 inclusive, False otherwise.
8class DecimalEncoder(json.JSONEncoder): 9 """JSON encoder that handles DynamoDB Decimal types.""" 10 11 def default(self, obj: object) -> object: 12 if isinstance(obj, Decimal): 13 return int(obj) if obj % 1 == 0 else float(obj) 14 return super().default(obj)
JSON encoder that handles DynamoDB Decimal types.
11 def default(self, obj: object) -> object: 12 if isinstance(obj, Decimal): 13 return int(obj) if obj % 1 == 0 else float(obj) 14 return super().default(obj)
Implement this method in a subclass such that it returns
a serializable object for o, or calls the base implementation
(to raise a TypeError).
For example, to support arbitrary iterators, you could implement default like this::
def default(self, o):
try:
iterable = iter(o)
except TypeError:
pass
else:
return list(iterable)
# Let the base class default method raise the TypeError
return super().default(o)
17def extract_area_code(phone: str) -> str: 18 """Extract 3-digit area code from a phone number string. 19 20 Handles E.164 format (+1XXXXXXXXXX) and raw digits. 21 Returns 'unknown' if fewer than 3 digits. 22 """ 23 digits = "".join(c for c in phone if c.isdigit()) 24 if digits.startswith("1") and len(digits) >= 4: 25 return digits[1:4] 26 if len(digits) >= 3: 27 return digits[:3] 28 return "unknown"
Extract 3-digit area code from a phone number string.
Handles E.164 format (+1XXXXXXXXXX) and raw digits. Returns 'unknown' if fewer than 3 digits.
46class ManagedApiKeyService: 47 """Manages user API keys with CRUD operations in DynamoDB. 48 49 DynamoDB table schema: 50 - Hash key: user_id (S) 51 - Range key: key_id (S) 52 """ 53 54 table: "Table | None" 55 56 def __init__(self, table_name: str | None = None): 57 """Initialize with DynamoDB table.""" 58 if not HAS_BOTO3 or not boto3: 59 raise RuntimeError("boto3 is required for ManagedApiKeyService") 60 61 self.dynamodb = boto3.resource("dynamodb") 62 self.table_name = table_name if table_name else os.environ["MANAGED_API_KEYS_TABLE"] 63 64 try: 65 self.table = self.dynamodb.Table(self.table_name) 66 except Exception as e: 67 logger.error(f"Failed to connect to DynamoDB table {self.table_name}: {e}") 68 self.table = None 69 70 def _get_key(self, user_id: str, key_id: str) -> dict[str, Any]: 71 """Fetch a key item, raising if not found or revoked.""" 72 if not self.table: 73 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 74 75 response = self.table.get_item(Key={"user_id": user_id, "key_id": key_id}) 76 item = response.get("Item") 77 if not item: 78 raise KeyNotFoundError(f"Key {key_id} not found for user {user_id}") 79 if item.get("status") == "revoked": 80 raise RevokedKeyError(f"Key {key_id} is revoked") 81 return item 82 83 def list_keys(self, user_id: str) -> list[dict[str, Any]]: 84 """List all API keys for a user, sorted by created_at descending. 85 86 Returns projected fields only (excludes key_hash). 87 """ 88 if not self.table: 89 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 90 91 response = self.table.query( 92 KeyConditionExpression="user_id = :uid", 93 ExpressionAttributeValues={":uid": user_id}, 94 ) 95 items = response.get("Items", []) 96 97 result = [] 98 for item in items: 99 result.append( 100 { 101 "key_id": item["key_id"], 102 "key_last4": item.get("key_last4", ""), 103 "label": item.get("label", ""), 104 "status": item.get("status", "active"), 105 "created_at": item.get("created_at", ""), 106 "expires_at": item.get("expires_at"), 107 "last_used_at": item.get("last_used_at"), 108 } 109 ) 110 111 result.sort(key=lambda x: str(x.get("created_at", "")), reverse=True) 112 return result 113 114 def create_key(self, user_id: str, label: str, expires_in_days: int = 365) -> dict[str, Any]: 115 """Create a new managed API key. 116 117 Returns the key_id and plaintext key (only time key is returned). 118 """ 119 if not self.table: 120 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 121 122 # Validate label 123 label = label.strip() 124 if not label or len(label) > MAX_LABEL_LENGTH: 125 raise ValueError(f"Label must be 1-{MAX_LABEL_LENGTH} characters, got {len(label)}") 126 127 # Validate expiration 128 if not validate_expiration_days(expires_in_days): 129 raise ValueError(f"Expiration must be 1-730 days, got {expires_in_days}") 130 131 # Check active key count 132 existing = self.list_keys(user_id) 133 active_count = sum(1 for k in existing if k["status"] != "revoked") 134 if active_count >= MAX_ACTIVE_KEYS: 135 raise LimitExceededError(f"Maximum of {MAX_ACTIVE_KEYS} active keys reached") 136 137 # Generate key 138 key_id = generate_key_id() 139 plaintext_key = generate_managed_key() 140 key_hash = compute_key_hash(plaintext_key) 141 now = datetime.now(UTC).isoformat() 142 expires_at = (datetime.now(UTC) + timedelta(days=expires_in_days)).isoformat() 143 144 self.table.put_item( 145 Item={ 146 "user_id": user_id, 147 "key_id": key_id, 148 "key_hash": key_hash, 149 "key_last4": plaintext_key[-4:], 150 "label": label, 151 "status": "active", 152 "created_at": now, 153 "expires_at": expires_at, 154 "last_used_at": None, 155 "usage_count": 0, 156 } 157 ) 158 159 logger.info(f"Created managed key {key_id} for user {user_id}") 160 return { 161 "key_id": key_id, 162 "api_key": plaintext_key, 163 "label": label, 164 "expires_at": expires_at, 165 } 166 167 def update_key( 168 self, 169 user_id: str, 170 key_id: str, 171 label: str | None = None, 172 expires_in_days: int | None = None, 173 ) -> dict[str, Any]: 174 """Update key label and/or expiration.""" 175 if label is None and expires_in_days is None: 176 raise ValueError("At least one of label or expires_in_days must be provided") 177 178 # This will raise KeyNotFoundError or RevokedKeyError 179 self._get_key(user_id, key_id) 180 181 update_parts = ["SET updated_at = :now"] 182 expr_values: dict[str, Any] = {":now": datetime.now(UTC).isoformat()} 183 184 if label is not None: 185 label = label.strip() 186 if not label or len(label) > MAX_LABEL_LENGTH: 187 raise ValueError(f"Label must be 1-{MAX_LABEL_LENGTH} characters") 188 update_parts.append("label = :label") 189 expr_values[":label"] = label 190 191 if expires_in_days is not None: 192 if not validate_expiration_days(expires_in_days): 193 raise ValueError(f"Expiration must be 1-730 days, got {expires_in_days}") 194 expires_at = (datetime.now(UTC) + timedelta(days=expires_in_days)).isoformat() 195 update_parts.append("expires_at = :expires_at") 196 expr_values[":expires_at"] = expires_at 197 198 update_expr = update_parts[0] 199 if len(update_parts) > 1: 200 update_expr += ", " + ", ".join(update_parts[1:]) 201 202 if not self.table: 203 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 204 205 self.table.update_item( 206 Key={"user_id": user_id, "key_id": key_id}, 207 UpdateExpression=update_expr, 208 ExpressionAttributeValues=expr_values, 209 ) 210 211 logger.info(f"Updated managed key {key_id} for user {user_id}") 212 return {"message": "Key updated"} 213 214 def rotate_key(self, user_id: str, key_id: str) -> dict[str, Any]: 215 """Generate a new key value while keeping the same key_id. 216 217 Returns the new plaintext key (only time it's returned). 218 """ 219 # This will raise KeyNotFoundError or RevokedKeyError 220 self._get_key(user_id, key_id) 221 222 plaintext_key = generate_managed_key() 223 key_hash = compute_key_hash(plaintext_key) 224 now = datetime.now(UTC).isoformat() 225 226 if not self.table: 227 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 228 229 self.table.update_item( 230 Key={"user_id": user_id, "key_id": key_id}, 231 UpdateExpression=("SET key_hash = :hash, key_last4 = :last4, updated_at = :now"), 232 ExpressionAttributeValues={ 233 ":hash": key_hash, 234 ":last4": plaintext_key[-4:], 235 ":now": now, 236 }, 237 ) 238 239 logger.info(f"Rotated managed key {key_id} for user {user_id}") 240 return { 241 "key_id": key_id, 242 "api_key": plaintext_key, 243 "label": "", 244 "expires_at": "", 245 } 246 247 def revoke_key(self, user_id: str, key_id: str) -> None: 248 """Mark a key as revoked with TTL for automatic cleanup.""" 249 if not self.table: 250 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 251 252 # Check key exists (but allow revoking already-revoked keys) 253 response = self.table.get_item(Key={"user_id": user_id, "key_id": key_id}) 254 if not response.get("Item"): 255 raise KeyNotFoundError(f"Key {key_id} not found for user {user_id}") 256 257 now = datetime.now(UTC) 258 ttl = int((now + timedelta(days=REVOKE_TTL_DAYS)).timestamp()) 259 260 self.table.update_item( 261 Key={"user_id": user_id, "key_id": key_id}, 262 UpdateExpression=("SET #s = :revoked, revoked_at = :now, #ttl = :ttl"), 263 ExpressionAttributeNames={"#s": "status", "#ttl": "ttl"}, 264 ExpressionAttributeValues={ 265 ":revoked": "revoked", 266 ":now": now.isoformat(), 267 ":ttl": ttl, 268 }, 269 ) 270 271 logger.info(f"Revoked managed key {key_id} for user {user_id}")
Manages user API keys with CRUD operations in DynamoDB.
DynamoDB table schema: - Hash key: user_id (S) - Range key: key_id (S)
56 def __init__(self, table_name: str | None = None): 57 """Initialize with DynamoDB table.""" 58 if not HAS_BOTO3 or not boto3: 59 raise RuntimeError("boto3 is required for ManagedApiKeyService") 60 61 self.dynamodb = boto3.resource("dynamodb") 62 self.table_name = table_name if table_name else os.environ["MANAGED_API_KEYS_TABLE"] 63 64 try: 65 self.table = self.dynamodb.Table(self.table_name) 66 except Exception as e: 67 logger.error(f"Failed to connect to DynamoDB table {self.table_name}: {e}") 68 self.table = None
Initialize with DynamoDB table.
83 def list_keys(self, user_id: str) -> list[dict[str, Any]]: 84 """List all API keys for a user, sorted by created_at descending. 85 86 Returns projected fields only (excludes key_hash). 87 """ 88 if not self.table: 89 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 90 91 response = self.table.query( 92 KeyConditionExpression="user_id = :uid", 93 ExpressionAttributeValues={":uid": user_id}, 94 ) 95 items = response.get("Items", []) 96 97 result = [] 98 for item in items: 99 result.append( 100 { 101 "key_id": item["key_id"], 102 "key_last4": item.get("key_last4", ""), 103 "label": item.get("label", ""), 104 "status": item.get("status", "active"), 105 "created_at": item.get("created_at", ""), 106 "expires_at": item.get("expires_at"), 107 "last_used_at": item.get("last_used_at"), 108 } 109 ) 110 111 result.sort(key=lambda x: str(x.get("created_at", "")), reverse=True) 112 return result
List all API keys for a user, sorted by created_at descending.
Returns projected fields only (excludes key_hash).
114 def create_key(self, user_id: str, label: str, expires_in_days: int = 365) -> dict[str, Any]: 115 """Create a new managed API key. 116 117 Returns the key_id and plaintext key (only time key is returned). 118 """ 119 if not self.table: 120 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 121 122 # Validate label 123 label = label.strip() 124 if not label or len(label) > MAX_LABEL_LENGTH: 125 raise ValueError(f"Label must be 1-{MAX_LABEL_LENGTH} characters, got {len(label)}") 126 127 # Validate expiration 128 if not validate_expiration_days(expires_in_days): 129 raise ValueError(f"Expiration must be 1-730 days, got {expires_in_days}") 130 131 # Check active key count 132 existing = self.list_keys(user_id) 133 active_count = sum(1 for k in existing if k["status"] != "revoked") 134 if active_count >= MAX_ACTIVE_KEYS: 135 raise LimitExceededError(f"Maximum of {MAX_ACTIVE_KEYS} active keys reached") 136 137 # Generate key 138 key_id = generate_key_id() 139 plaintext_key = generate_managed_key() 140 key_hash = compute_key_hash(plaintext_key) 141 now = datetime.now(UTC).isoformat() 142 expires_at = (datetime.now(UTC) + timedelta(days=expires_in_days)).isoformat() 143 144 self.table.put_item( 145 Item={ 146 "user_id": user_id, 147 "key_id": key_id, 148 "key_hash": key_hash, 149 "key_last4": plaintext_key[-4:], 150 "label": label, 151 "status": "active", 152 "created_at": now, 153 "expires_at": expires_at, 154 "last_used_at": None, 155 "usage_count": 0, 156 } 157 ) 158 159 logger.info(f"Created managed key {key_id} for user {user_id}") 160 return { 161 "key_id": key_id, 162 "api_key": plaintext_key, 163 "label": label, 164 "expires_at": expires_at, 165 }
Create a new managed API key.
Returns the key_id and plaintext key (only time key is returned).
167 def update_key( 168 self, 169 user_id: str, 170 key_id: str, 171 label: str | None = None, 172 expires_in_days: int | None = None, 173 ) -> dict[str, Any]: 174 """Update key label and/or expiration.""" 175 if label is None and expires_in_days is None: 176 raise ValueError("At least one of label or expires_in_days must be provided") 177 178 # This will raise KeyNotFoundError or RevokedKeyError 179 self._get_key(user_id, key_id) 180 181 update_parts = ["SET updated_at = :now"] 182 expr_values: dict[str, Any] = {":now": datetime.now(UTC).isoformat()} 183 184 if label is not None: 185 label = label.strip() 186 if not label or len(label) > MAX_LABEL_LENGTH: 187 raise ValueError(f"Label must be 1-{MAX_LABEL_LENGTH} characters") 188 update_parts.append("label = :label") 189 expr_values[":label"] = label 190 191 if expires_in_days is not None: 192 if not validate_expiration_days(expires_in_days): 193 raise ValueError(f"Expiration must be 1-730 days, got {expires_in_days}") 194 expires_at = (datetime.now(UTC) + timedelta(days=expires_in_days)).isoformat() 195 update_parts.append("expires_at = :expires_at") 196 expr_values[":expires_at"] = expires_at 197 198 update_expr = update_parts[0] 199 if len(update_parts) > 1: 200 update_expr += ", " + ", ".join(update_parts[1:]) 201 202 if not self.table: 203 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 204 205 self.table.update_item( 206 Key={"user_id": user_id, "key_id": key_id}, 207 UpdateExpression=update_expr, 208 ExpressionAttributeValues=expr_values, 209 ) 210 211 logger.info(f"Updated managed key {key_id} for user {user_id}") 212 return {"message": "Key updated"}
Update key label and/or expiration.
214 def rotate_key(self, user_id: str, key_id: str) -> dict[str, Any]: 215 """Generate a new key value while keeping the same key_id. 216 217 Returns the new plaintext key (only time it's returned). 218 """ 219 # This will raise KeyNotFoundError or RevokedKeyError 220 self._get_key(user_id, key_id) 221 222 plaintext_key = generate_managed_key() 223 key_hash = compute_key_hash(plaintext_key) 224 now = datetime.now(UTC).isoformat() 225 226 if not self.table: 227 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 228 229 self.table.update_item( 230 Key={"user_id": user_id, "key_id": key_id}, 231 UpdateExpression=("SET key_hash = :hash, key_last4 = :last4, updated_at = :now"), 232 ExpressionAttributeValues={ 233 ":hash": key_hash, 234 ":last4": plaintext_key[-4:], 235 ":now": now, 236 }, 237 ) 238 239 logger.info(f"Rotated managed key {key_id} for user {user_id}") 240 return { 241 "key_id": key_id, 242 "api_key": plaintext_key, 243 "label": "", 244 "expires_at": "", 245 }
Generate a new key value while keeping the same key_id.
Returns the new plaintext key (only time it's returned).
247 def revoke_key(self, user_id: str, key_id: str) -> None: 248 """Mark a key as revoked with TTL for automatic cleanup.""" 249 if not self.table: 250 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 251 252 # Check key exists (but allow revoking already-revoked keys) 253 response = self.table.get_item(Key={"user_id": user_id, "key_id": key_id}) 254 if not response.get("Item"): 255 raise KeyNotFoundError(f"Key {key_id} not found for user {user_id}") 256 257 now = datetime.now(UTC) 258 ttl = int((now + timedelta(days=REVOKE_TTL_DAYS)).timestamp()) 259 260 self.table.update_item( 261 Key={"user_id": user_id, "key_id": key_id}, 262 UpdateExpression=("SET #s = :revoked, revoked_at = :now, #ttl = :ttl"), 263 ExpressionAttributeNames={"#s": "status", "#ttl": "ttl"}, 264 ExpressionAttributeValues={ 265 ":revoked": "revoked", 266 ":now": now.isoformat(), 267 ":ttl": ttl, 268 }, 269 ) 270 271 logger.info(f"Revoked managed key {key_id} for user {user_id}")
Mark a key as revoked with TTL for automatic cleanup.
Raised when a managed API key is not found.
Raised when attempting to modify a revoked key.
Raised when the active key limit is reached.
5class ProviderError(ValueError): 6 """Raised when an upstream verification provider fails. 7 8 Extends ValueError for backward compatibility with existing 9 error handlers. Carries an optional HTTP status code from the 10 upstream response so callers can distinguish client errors from 11 server/network failures. 12 """ 13 14 def __init__(self, message: str, status_code: int | None = None): 15 super().__init__(message) 16 self.status_code = status_code
Raised when an upstream verification provider fails.
Extends ValueError for backward compatibility with existing error handlers. Carries an optional HTTP status code from the upstream response so callers can distinguish client errors from server/network failures.
88class FileService: 89 """Manages file metadata listing with filtering and pagination. 90 91 DynamoDB table schema: 92 - Hash key: file_id (S) 93 - GSI: user-index on user_id (HASH) / created_at (RANGE) 94 """ 95 96 table: "Table | None" 97 98 def __init__(self, table_name: str | None = None): 99 """Initialize with DynamoDB table.""" 100 if not HAS_BOTO3 or not boto3: 101 raise RuntimeError("boto3 is required for FileService") 102 103 self.dynamodb = boto3.resource("dynamodb") 104 self.table_name = table_name if table_name else os.environ["FILES_TABLE"] 105 106 try: 107 self.table = self.dynamodb.Table(self.table_name) 108 except Exception as e: 109 logger.error(f"Failed to connect to DynamoDB table {self.table_name}: {e}") 110 self.table = None 111 112 def _query_all_user_files(self, user_id: str) -> list[dict[str, Any]]: 113 """Query all files for a user via the user-index GSI.""" 114 if not self.table: 115 raise RuntimeError(f"DynamoDB table {self.table_name} not accessible") 116 117 items: list[dict[str, Any]] = [] 118 kwargs: dict[str, Any] = { 119 "IndexName": "user-index", 120 "KeyConditionExpression": "user_id = :uid", 121 "ExpressionAttributeValues": {":uid": user_id}, 122 } 123 124 while True: 125 response = self.table.query(**kwargs) 126 items.extend(response.get("Items", [])) 127 last_key = response.get("LastEvaluatedKey") 128 if not last_key: 129 break 130 kwargs["ExclusiveStartKey"] = last_key 131 132 return items 133 134 def list_user_files( 135 self, 136 user_id: str, 137 page: int = 1, 138 limit: int = 20, 139 search: str = "", 140 sort_by: str = "created_at", 141 sort_order: str = "desc", 142 status_filter: str = "", 143 ) -> dict[str, Any]: 144 """List files for a user with filtering, sorting, and pagination. 145 146 Args: 147 user_id: User identifier. 148 page: Page number (1-indexed). 149 limit: Items per page. 150 search: Case-insensitive substring match on filename. 151 sort_by: Column to sort by (from ALLOWED_SORT_COLUMNS). 152 sort_order: 'asc' or 'desc'. 153 status_filter: Filter by file status. 154 155 Returns: 156 Dict with files list and pagination metadata. 157 """ 158 items = self._query_all_user_files(user_id) 159 160 # Apply status filter 161 if status_filter and status_filter in ALLOWED_STATUSES: 162 items = [i for i in items if i.get("status") == status_filter] 163 164 # Apply search filter 165 if search: 166 search_lower = search.lower() 167 items = [i for i in items if search_lower in _get_filename(i).lower()] 168 169 # Sort 170 sort_key = SORT_KEY_MAP.get(sort_by, SORT_KEY_MAP["created_at"]) 171 reverse = sort_order != "asc" 172 items.sort(key=sort_key, reverse=reverse) 173 174 # Pagination 175 total_count = len(items) 176 total_pages = max(1, math.ceil(total_count / limit)) 177 start = (page - 1) * limit 178 end = start + limit 179 page_items = items[start:end] 180 181 return { 182 "files": [_map_file_fields(item) for item in page_items], 183 "page": page, 184 "per_page": limit, 185 "has_more": end < total_count, 186 "total_pages": total_pages, 187 "total_files_count": total_count, 188 } 189 190 def list_user_files_admin( 191 self, 192 user_id: str, 193 page: int = 1, 194 limit: int = 20, 195 ) -> dict[str, Any]: 196 """Admin view of user files with raw S3 keys. 197 198 Returns raw S3 keys for the handler to generate presigned URLs. 199 """ 200 items = self._query_all_user_files(user_id) 201 202 # Sort newest first 203 items.sort(key=lambda i: i.get("created_at", ""), reverse=True) 204 205 total = len(items) 206 start = (page - 1) * limit 207 end = start + limit 208 page_items = items[start:end] 209 210 return { 211 "files": [_map_admin_fields(item) for item in page_items], 212 "total": total, 213 "page": page, 214 "limit": limit, 215 }
Manages file metadata listing with filtering and pagination.
DynamoDB table schema: - Hash key: file_id (S) - GSI: user-index on user_id (HASH) / created_at (RANGE)
98 def __init__(self, table_name: str | None = None): 99 """Initialize with DynamoDB table.""" 100 if not HAS_BOTO3 or not boto3: 101 raise RuntimeError("boto3 is required for FileService") 102 103 self.dynamodb = boto3.resource("dynamodb") 104 self.table_name = table_name if table_name else os.environ["FILES_TABLE"] 105 106 try: 107 self.table = self.dynamodb.Table(self.table_name) 108 except Exception as e: 109 logger.error(f"Failed to connect to DynamoDB table {self.table_name}: {e}") 110 self.table = None
Initialize with DynamoDB table.
134 def list_user_files( 135 self, 136 user_id: str, 137 page: int = 1, 138 limit: int = 20, 139 search: str = "", 140 sort_by: str = "created_at", 141 sort_order: str = "desc", 142 status_filter: str = "", 143 ) -> dict[str, Any]: 144 """List files for a user with filtering, sorting, and pagination. 145 146 Args: 147 user_id: User identifier. 148 page: Page number (1-indexed). 149 limit: Items per page. 150 search: Case-insensitive substring match on filename. 151 sort_by: Column to sort by (from ALLOWED_SORT_COLUMNS). 152 sort_order: 'asc' or 'desc'. 153 status_filter: Filter by file status. 154 155 Returns: 156 Dict with files list and pagination metadata. 157 """ 158 items = self._query_all_user_files(user_id) 159 160 # Apply status filter 161 if status_filter and status_filter in ALLOWED_STATUSES: 162 items = [i for i in items if i.get("status") == status_filter] 163 164 # Apply search filter 165 if search: 166 search_lower = search.lower() 167 items = [i for i in items if search_lower in _get_filename(i).lower()] 168 169 # Sort 170 sort_key = SORT_KEY_MAP.get(sort_by, SORT_KEY_MAP["created_at"]) 171 reverse = sort_order != "asc" 172 items.sort(key=sort_key, reverse=reverse) 173 174 # Pagination 175 total_count = len(items) 176 total_pages = max(1, math.ceil(total_count / limit)) 177 start = (page - 1) * limit 178 end = start + limit 179 page_items = items[start:end] 180 181 return { 182 "files": [_map_file_fields(item) for item in page_items], 183 "page": page, 184 "per_page": limit, 185 "has_more": end < total_count, 186 "total_pages": total_pages, 187 "total_files_count": total_count, 188 }
List files for a user with filtering, sorting, and pagination.
Args: user_id: User identifier. page: Page number (1-indexed). limit: Items per page. search: Case-insensitive substring match on filename. sort_by: Column to sort by (from ALLOWED_SORT_COLUMNS). sort_order: 'asc' or 'desc'. status_filter: Filter by file status.
Returns: Dict with files list and pagination metadata.
190 def list_user_files_admin( 191 self, 192 user_id: str, 193 page: int = 1, 194 limit: int = 20, 195 ) -> dict[str, Any]: 196 """Admin view of user files with raw S3 keys. 197 198 Returns raw S3 keys for the handler to generate presigned URLs. 199 """ 200 items = self._query_all_user_files(user_id) 201 202 # Sort newest first 203 items.sort(key=lambda i: i.get("created_at", ""), reverse=True) 204 205 total = len(items) 206 start = (page - 1) * limit 207 end = start + limit 208 page_items = items[start:end] 209 210 return { 211 "files": [_map_admin_fields(item) for item in page_items], 212 "total": total, 213 "page": page, 214 "limit": limit, 215 }
Admin view of user files with raw S3 keys.
Returns raw S3 keys for the handler to generate presigned URLs.