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]
class PhoneVerification(pydantic.main.BaseModel):
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

phone_number: str = PydanticUndefined

E.164 formatted phone number

line_type: LineType = PydanticUndefined

Type of phone line

dnc: bool = PydanticUndefined

Whether number is on DNC list

known_litigator: bool = False

Whether number belongs to a known litigator

cached: bool = PydanticUndefined

Whether result came from cache

verified_at: datetime.datetime = PydanticUndefined

When verification occurred

source: VerificationSource = PydanticUndefined

Source of verification data

class PhoneVerification.Config:
58    class Config:
59        json_encoders = {datetime: lambda v: v.isoformat()}
json_encoders = {<class 'datetime.datetime'>: <function PhoneVerification.Config.<lambda>>}
class BulkJob(pydantic.main.BaseModel):
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

job_id: str = PydanticUndefined

Unique job identifier

status: JobStatus = PydanticUndefined

Current job status

class BulkJobStatus(ai_lls_lib.BulkJob):
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

total_rows: int | None = None

Total rows to process

processed_rows: int | None = None

Rows processed so far

result_url: str | None = None

S3 URL of results

created_at: datetime.datetime = PydanticUndefined

Job creation time

completed_at: datetime.datetime | None = None

Job completion time

error: str | None = None

Error message if failed

class LineType(enum.StrEnum):
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

MOBILE = <LineType.MOBILE: 'mobile'>
LANDLINE = <LineType.LANDLINE: 'landline'>
VOIP = <LineType.VOIP: 'voip'>
UNKNOWN = <LineType.UNKNOWN: 'unknown'>
class VerificationResult(typing.NamedTuple):
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

VerificationResult( line_type: LineType, dnc: bool, known_litigator: bool = False)

Create new instance of VerificationResult(line_type, dnc, known_litigator)

line_type: LineType

Alias for field number 0

dnc: bool

Alias for field number 1

known_litigator: bool

Alias for field number 2

class VerificationSource(enum.StrEnum):
22class VerificationSource(StrEnum):
23    """Source of verification data"""
24
25    API = "api"
26    CACHE = "cache"
27    BULK_IMPORT = "bulk_import"

Source of verification data

API = <VerificationSource.API: 'api'>
CACHE = <VerificationSource.CACHE: 'cache'>
BULK_IMPORT = <VerificationSource.BULK_IMPORT: 'bulk_import'>
class JobStatus(enum.StrEnum):
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

PENDING = <JobStatus.PENDING: 'pending'>
PROCESSING = <JobStatus.PROCESSING: 'processing'>
COMPLETED = <JobStatus.COMPLETED: 'completed'>
FAILED = <JobStatus.FAILED: 'failed'>
class PhoneVerifier:
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

PhoneVerifier( cache: DynamoDBCache | None = None, provider: ai_lls_lib.providers.base.VerificationProvider | None = None)
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)

cache
provider
def normalize_phone(self, phone: str) -> str:
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

def verify(self, phone: str) -> PhoneVerification:
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

class BulkProcessor:
 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

BulkProcessor( verifier: PhoneVerifier, max_failure_rate: float = 0.2, max_consecutive_failures: int = 20, max_workers: int | None = None)
 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.

verifier
max_failure_rate
max_consecutive_failures
max_workers
def process_csv( self, csv_text: str, phone_column: str = 'phone') -> list[PhoneVerification]:
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.

def process_csv_report( self, csv_text: str, phone_column: str = 'phone') -> ai_lls_lib.core.processor.ProcessingReport:
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.

def generate_results_csv( self, original_csv_text: str, results: list[PhoneVerification], failed_phones: set[str] | None = None) -> str:
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.

def process_csv_stream( self, lines: Iterable[str], phone_column: str = 'phone', batch_size: int = 100) -> Iterator[list[PhoneVerification]]:
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

def generate_results_csv_stream( self, original_lines: Iterable[str], results_stream: Iterator[list[PhoneVerification]], phone_column: str = 'phone', failed_phones: set[str] | None = None) -> Iterator[str]:
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

class DynamoDBCache:
 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

DynamoDBCache(table_name: str, ttl_days: int = 90)
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()
table_name
ttl_days
dynamodb: Any
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
table: Any
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
def get( self, phone_number: str) -> PhoneVerification | None:
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

def set( self, phone_number: str, verification: PhoneVerification) -> None:
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

def batch_get( self, phone_numbers: list[str]) -> dict[str, PhoneVerification | None]:
 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

def compute_key_hash(key: str) -> str:
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.

def generate_key_id() -> str:
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().

def generate_managed_key() -> str:
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().

def validate_expiration_days(days: int) -> bool:
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.

class DecimalEncoder(json.encoder.JSONEncoder):
 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.

def default(self, obj: object) -> object:
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)
def extract_area_code(phone: str) -> str:
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.

class ManagedApiKeyService:
 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)

ManagedApiKeyService(table_name: str | None = None)
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.

table: mypy_boto3_dynamodb.service_resource.Table | None
dynamodb
table_name
def list_keys(self, user_id: str) -> list[dict[str, typing.Any]]:
 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).

def create_key( self, user_id: str, label: str, expires_in_days: int = 365) -> dict[str, typing.Any]:
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).

def update_key( self, user_id: str, key_id: str, label: str | None = None, expires_in_days: int | None = None) -> dict[str, typing.Any]:
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.

def rotate_key(self, user_id: str, key_id: str) -> dict[str, typing.Any]:
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).

def revoke_key(self, user_id: str, key_id: str) -> None:
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.

class KeyNotFoundError(builtins.ValueError):
34class KeyNotFoundError(ValueError):
35    """Raised when a managed API key is not found."""

Raised when a managed API key is not found.

class RevokedKeyError(builtins.ValueError):
38class RevokedKeyError(ValueError):
39    """Raised when attempting to modify a revoked key."""

Raised when attempting to modify a revoked key.

class LimitExceededError(builtins.ValueError):
42class LimitExceededError(ValueError):
43    """Raised when the active key limit is reached."""

Raised when the active key limit is reached.

class ProviderError(builtins.ValueError):
 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.

ProviderError(message: str, status_code: int | None = None)
14    def __init__(self, message: str, status_code: int | None = None):
15        super().__init__(message)
16        self.status_code = status_code
status_code
class FileService:
 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)

FileService(table_name: str | None = None)
 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.

table: mypy_boto3_dynamodb.service_resource.Table | None
dynamodb
table_name
def list_user_files( self, user_id: str, page: int = 1, limit: int = 20, search: str = '', sort_by: str = 'created_at', sort_order: str = 'desc', status_filter: str = '') -> dict[str, typing.Any]:
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.

def list_user_files_admin( self, user_id: str, page: int = 1, limit: int = 20) -> dict[str, typing.Any]:
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.