ai-lls-lib 2.0.0rc2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ """
2
+ Test stack management commands
3
+ """
4
+ import click
5
+ import subprocess
6
+ import os
7
+ import time
8
+ from ai_lls_lib.cli.aws_client import AWSClient
9
+
10
+ @click.group(name="test-stack")
11
+ def test_stack_group():
12
+ """Test stack management"""
13
+ pass
14
+
15
+ @test_stack_group.command(name="deploy")
16
+ @click.option("--stack-name", default="ai-lls-lib-test", help="Test stack name")
17
+ @click.option("--profile", help="AWS profile")
18
+ @click.option("--region", help="AWS region")
19
+ def deploy_test_stack(stack_name, profile, region):
20
+ """Deploy the test stack"""
21
+ template_path = os.path.join(
22
+ os.path.dirname(__file__),
23
+ "..", "..", "..", "..", "template.yaml"
24
+ )
25
+
26
+ if not os.path.exists(template_path):
27
+ click.echo(f"Test stack template not found at {template_path}")
28
+ click.echo("Creating minimal test stack template...")
29
+
30
+ # Create a minimal test stack
31
+ template_content = """AWSTemplateFormatVersion: '2010-09-09'
32
+ Description: Minimal test stack for ai-lls-lib integration testing
33
+
34
+ Resources:
35
+ TestPhoneCache:
36
+ Type: AWS::DynamoDB::Table
37
+ Properties:
38
+ TableName: !Sub "${AWS::StackName}-phone-cache"
39
+ BillingMode: PAY_PER_REQUEST
40
+ AttributeDefinitions:
41
+ - AttributeName: phone_number
42
+ AttributeType: S
43
+ KeySchema:
44
+ - AttributeName: phone_number
45
+ KeyType: HASH
46
+ TimeToLiveSpecification:
47
+ AttributeName: ttl
48
+ Enabled: true
49
+
50
+ TestUploadBucket:
51
+ Type: AWS::S3::Bucket
52
+ Properties:
53
+ BucketName: !Sub "${AWS::StackName}-uploads-${AWS::AccountId}"
54
+ LifecycleConfiguration:
55
+ Rules:
56
+ - Id: DeleteOldTestFiles
57
+ Status: Enabled
58
+ ExpirationInDays: 1
59
+
60
+ TestQueue:
61
+ Type: AWS::SQS::Queue
62
+ Properties:
63
+ QueueName: !Sub "${AWS::StackName}-test-queue"
64
+ MessageRetentionPeriod: 3600 # 1 hour for test
65
+
66
+ Outputs:
67
+ CacheTableName:
68
+ Value: !Ref TestPhoneCache
69
+ BucketName:
70
+ Value: !Ref TestUploadBucket
71
+ QueueUrl:
72
+ Value: !Ref TestQueue
73
+ """
74
+
75
+ os.makedirs(os.path.dirname(template_path), exist_ok=True)
76
+ with open(template_path, 'w') as f:
77
+ f.write(template_content)
78
+ click.echo(f"Created {template_path}")
79
+
80
+ # Deploy using AWS CLI
81
+ cmd = [
82
+ "aws", "cloudformation", "deploy",
83
+ "--template-file", template_path,
84
+ "--stack-name", stack_name,
85
+ "--capabilities", "CAPABILITY_IAM"
86
+ ]
87
+
88
+ if profile:
89
+ cmd.extend(["--profile", profile])
90
+ if region:
91
+ cmd.extend(["--region", region])
92
+
93
+ click.echo(f"Deploying test stack '{stack_name}'...")
94
+ try:
95
+ result = subprocess.run(cmd, capture_output=True, text=True)
96
+ if result.returncode == 0:
97
+ click.echo("Test stack deployed successfully!")
98
+
99
+ # Show outputs
100
+ aws = AWSClient(region=region, profile=profile)
101
+ outputs = aws.get_stack_outputs(stack_name)
102
+ if outputs:
103
+ click.echo("\nStack Outputs:")
104
+ for key, value in outputs.items():
105
+ click.echo(f" {key}: {value}")
106
+ else:
107
+ click.echo(f"Deployment failed: {result.stderr}", err=True)
108
+ except Exception as e:
109
+ click.echo(f"Error deploying test stack: {e}", err=True)
110
+
111
+ @test_stack_group.command(name="delete")
112
+ @click.option("--stack-name", default="ai-lls-lib-test", help="Test stack name")
113
+ @click.option("--profile", help="AWS profile")
114
+ @click.option("--region", help="AWS region")
115
+ @click.confirmation_option(prompt="Delete test stack and all resources?")
116
+ def delete_test_stack(stack_name, profile, region):
117
+ """Delete the test stack"""
118
+ aws = AWSClient(region=region, profile=profile)
119
+
120
+ try:
121
+ # Empty S3 bucket first
122
+ outputs = aws.get_stack_outputs(stack_name)
123
+ if 'BucketName' in outputs:
124
+ bucket_name = outputs['BucketName']
125
+ click.echo(f"Emptying bucket {bucket_name}...")
126
+
127
+ # List and delete all objects
128
+ try:
129
+ objects = aws.s3.list_objects_v2(Bucket=bucket_name)
130
+ if 'Contents' in objects:
131
+ for obj in objects['Contents']:
132
+ aws.s3.delete_object(Bucket=bucket_name, Key=obj['Key'])
133
+ except:
134
+ pass # Bucket might not exist
135
+
136
+ # Delete stack
137
+ click.echo(f"Deleting stack '{stack_name}'...")
138
+ aws.cloudformation.delete_stack(StackName=stack_name)
139
+
140
+ # Wait for deletion
141
+ click.echo("Waiting for stack deletion...")
142
+ waiter = aws.cloudformation.get_waiter('stack_delete_complete')
143
+ waiter.wait(StackName=stack_name)
144
+
145
+ click.echo("Test stack deleted successfully!")
146
+
147
+ except Exception as e:
148
+ click.echo(f"Error deleting test stack: {e}", err=True)
149
+
150
+ @test_stack_group.command(name="status")
151
+ @click.option("--stack-name", default="ai-lls-lib-test", help="Test stack name")
152
+ @click.option("--profile", help="AWS profile")
153
+ @click.option("--region", help="AWS region")
154
+ def test_stack_status(stack_name, profile, region):
155
+ """Show test stack status"""
156
+ aws = AWSClient(region=region, profile=profile)
157
+
158
+ try:
159
+ response = aws.cloudformation.describe_stacks(StackName=stack_name)
160
+ stack = response['Stacks'][0]
161
+
162
+ click.echo(f"\nTest Stack: {stack_name}")
163
+ click.echo("=" * 50)
164
+ click.echo(f"Status: {stack['StackStatus']}")
165
+ click.echo(f"Created: {stack.get('CreationTime', 'N/A')}")
166
+
167
+ if 'Outputs' in stack:
168
+ click.echo("\nOutputs:")
169
+ for output in stack['Outputs']:
170
+ click.echo(f" {output['OutputKey']}: {output['OutputValue']}")
171
+
172
+ except aws.cloudformation.exceptions.ClientError as e:
173
+ if 'does not exist' in str(e):
174
+ click.echo(f"Test stack '{stack_name}' does not exist")
175
+ else:
176
+ click.echo(f"Error: {e}", err=True)
177
+
178
+ @test_stack_group.command(name="test")
179
+ @click.option("--stack-name", default="ai-lls-lib-test", help="Test stack name")
180
+ @click.option("--profile", help="AWS profile")
181
+ @click.option("--region", help="AWS region")
182
+ def run_integration_tests(stack_name, profile, region):
183
+ """Run integration tests against test stack"""
184
+ aws = AWSClient(region=region, profile=profile)
185
+
186
+ try:
187
+ # Check stack exists
188
+ outputs = aws.get_stack_outputs(stack_name)
189
+ if not outputs:
190
+ click.echo(f"Test stack '{stack_name}' not found. Deploy it first.")
191
+ return
192
+
193
+ # Set environment variables for tests
194
+ os.environ['TEST_STACK_NAME'] = stack_name
195
+ os.environ['TEST_CACHE_TABLE'] = outputs.get('CacheTableName', '')
196
+ os.environ['TEST_BUCKET'] = outputs.get('BucketName', '')
197
+ os.environ['TEST_QUEUE_URL'] = outputs.get('QueueUrl', '')
198
+
199
+ if profile:
200
+ os.environ['AWS_PROFILE'] = profile
201
+ if region:
202
+ os.environ['AWS_REGION'] = region
203
+
204
+ click.echo(f"Running integration tests against '{stack_name}'...")
205
+
206
+ # Run pytest
207
+ cmd = ["pytest", "tests/integration", "-v"]
208
+ result = subprocess.run(cmd, cwd=os.path.dirname(template_path))
209
+
210
+ if result.returncode == 0:
211
+ click.echo("\nIntegration tests passed!")
212
+ else:
213
+ click.echo("\nSome tests failed", err=True)
214
+
215
+ except Exception as e:
216
+ click.echo(f"Error running tests: {e}", err=True)
@@ -0,0 +1,204 @@
1
+ """
2
+ Verification commands - direct phone verification bypassing API
3
+ """
4
+ import click
5
+ import json
6
+ from datetime import datetime
7
+ from ai_lls_lib.core.verifier import PhoneVerifier
8
+ from ai_lls_lib.core.cache import DynamoDBCache
9
+ from ai_lls_lib.cli.aws_client import AWSClient
10
+ from ai_lls_lib.providers import StubProvider, ExternalAPIProvider
11
+
12
+ @click.group(name="verify")
13
+ def verify_group():
14
+ """Phone verification commands"""
15
+ pass
16
+
17
+ @verify_group.command(name="phone")
18
+ @click.argument("phone_number")
19
+ @click.option("--output", type=click.Choice(["json", "text"], case_sensitive=False), default="json", help="Output format")
20
+ @click.option("--provider", type=click.Choice(["external", "stub"], case_sensitive=False), default="external", help="Verification provider to use")
21
+ @click.option("--no-cache", is_flag=True, help="Disable caching even if AWS is available")
22
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name (for caching)")
23
+ @click.option("--profile", help="AWS profile to use (for caching)")
24
+ @click.option("--region", help="AWS region (for caching)")
25
+ @click.option("--verbose", is_flag=True, help="Show detailed output")
26
+ def verify_phone(phone_number, output, provider, no_cache, stack, profile, region, verbose):
27
+ """
28
+ Verify a phone number (works with or without AWS).
29
+
30
+ Accepts phone numbers in various formats:
31
+ - 6197966726 (10 digits)
32
+ - 16197966726 (11 digits with 1)
33
+ - +16197966726 (E.164 format)
34
+ - 619-796-6726 (with dashes)
35
+ - (619) 796-6726 (with parentheses)
36
+ """
37
+
38
+ # Select provider based on parameter
39
+ if provider == "stub":
40
+ provider_instance = StubProvider()
41
+ if verbose:
42
+ click.echo("Using StubProvider (deterministic test mode)")
43
+ else:
44
+ provider_instance = ExternalAPIProvider()
45
+ if verbose:
46
+ click.echo("Using ExternalAPIProvider (real API calls)")
47
+
48
+ # Try to set up caching if not explicitly disabled
49
+ cache = None
50
+ cache_status = ""
51
+
52
+ if not no_cache:
53
+ try:
54
+ aws = AWSClient(region=region, profile=profile)
55
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
56
+ cache = DynamoDBCache(table_name=cache_table)
57
+ cache_status = "(Using cache)"
58
+ if verbose:
59
+ click.echo(f"Using DynamoDB cache table: {cache_table}")
60
+ except Exception as e:
61
+ cache_status = "(No cache - direct API call)"
62
+ if verbose:
63
+ click.echo(f"Cache not available: {e}")
64
+ click.echo("Continuing without cache (direct API calls)")
65
+ else:
66
+ cache_status = "(Cache disabled)"
67
+ if verbose:
68
+ click.echo("Cache explicitly disabled with --no-cache")
69
+
70
+ # Initialize verifier (works with or without cache)
71
+ verifier = PhoneVerifier(cache=cache, provider=provider_instance)
72
+
73
+ try:
74
+ # Verify the phone number
75
+ result = verifier.verify(phone_number)
76
+ result_dict = result.dict() if hasattr(result, 'dict') else result
77
+
78
+ # Prepare clean output
79
+ # Extract line type value if it's an enum
80
+ line_type = result_dict['line_type']
81
+ if hasattr(line_type, 'value'):
82
+ line_type = line_type.value
83
+
84
+ # Build clean output dictionary
85
+ output_data = {
86
+ "phone": result_dict['phone_number'],
87
+ "line_type": line_type,
88
+ "dnc": result_dict['dnc'],
89
+ "cached": result_dict.get('cached', False),
90
+ "verified_at": str(result_dict.get('verified_at', 'Unknown'))
91
+ }
92
+
93
+ if output == "json":
94
+ # JSON output (default for composability)
95
+ click.echo(json.dumps(output_data, indent=2))
96
+ else:
97
+ # Text output (human-readable)
98
+ click.echo(f"Phone: {output_data['phone']}")
99
+ click.echo(f"Line Type: {output_data['line_type']}")
100
+ click.echo(f"DNC: {output_data['dnc']}")
101
+ click.echo(f"Cached: {output_data['cached']}")
102
+ click.echo(f"Verified: {output_data['verified_at']}")
103
+
104
+ except ValueError as e:
105
+ if output == "json":
106
+ error_data = {"error": str(e)}
107
+ click.echo(json.dumps(error_data), err=True)
108
+ else:
109
+ click.echo(f"Error: {e}", err=True)
110
+ click.echo("\nSupported formats:", err=True)
111
+ click.echo(" 6197966726 (10 digits)", err=True)
112
+ click.echo(" 16197966726 (11 digits)", err=True)
113
+ click.echo(" +16197966726 (E.164)", err=True)
114
+ click.echo(" 619-796-6726 (with dashes)", err=True)
115
+ click.echo(" (619) 796-6726 (with parentheses)", err=True)
116
+ import sys
117
+ sys.exit(1)
118
+ except Exception as e:
119
+ if output == "json":
120
+ error_data = {"error": str(e)}
121
+ click.echo(json.dumps(error_data), err=True)
122
+ else:
123
+ click.echo(f"Verification failed: {e}", err=True)
124
+ if verbose:
125
+ import traceback
126
+ click.echo(traceback.format_exc(), err=True)
127
+ import sys
128
+ sys.exit(1)
129
+
130
+ @verify_group.command(name="bulk")
131
+ @click.argument("csv_file", type=click.Path(exists=True))
132
+ @click.option("--output", "-o", help="Output CSV file")
133
+ @click.option("--provider", type=click.Choice(["external", "stub"], case_sensitive=False), default="external", help="Verification provider to use")
134
+ @click.option("--no-cache", is_flag=True, help="Disable caching even if AWS is available")
135
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name (for caching)")
136
+ @click.option("--profile", help="AWS profile to use (for caching)")
137
+ @click.option("--region", help="AWS region (for caching)")
138
+ @click.option("--verbose", is_flag=True, help="Show detailed output")
139
+ def verify_bulk(csv_file, output, provider, no_cache, stack, profile, region, verbose):
140
+ """Process a CSV file for bulk verification"""
141
+ from ai_lls_lib.core.processor import BulkProcessor
142
+
143
+ # Select provider based on parameter
144
+ if provider == "stub":
145
+ provider_instance = StubProvider()
146
+ if verbose:
147
+ click.echo("Using StubProvider (deterministic test mode)")
148
+ else:
149
+ provider_instance = ExternalAPIProvider()
150
+ if verbose:
151
+ click.echo("Using ExternalAPIProvider (real API calls)")
152
+
153
+ # Try to set up caching if not explicitly disabled
154
+ cache = None
155
+
156
+ if not no_cache:
157
+ try:
158
+ aws = AWSClient(region=region, profile=profile)
159
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
160
+ cache = DynamoDBCache(table_name=cache_table)
161
+ if verbose:
162
+ click.echo(f"Using DynamoDB cache table: {cache_table}")
163
+ except Exception as e:
164
+ if verbose:
165
+ click.echo(f"Cache not available: {e}")
166
+ click.echo("Continuing without cache (direct API calls)")
167
+ else:
168
+ if verbose:
169
+ click.echo("Cache explicitly disabled with --no-cache")
170
+
171
+ # Initialize verifier (works with or without cache)
172
+ verifier = PhoneVerifier(cache=cache, provider=provider_instance)
173
+ processor = BulkProcessor(verifier=verifier)
174
+
175
+ click.echo(f"Processing {csv_file}...")
176
+
177
+ try:
178
+ # Process CSV
179
+ results = processor.process_csv_sync(csv_file)
180
+ click.echo(f"\nProcessed {len(results)} phone numbers")
181
+
182
+ # Show summary
183
+ mobile_count = sum(1 for r in results if r.line_type == "mobile")
184
+ landline_count = sum(1 for r in results if r.line_type == "landline")
185
+ dnc_count = sum(1 for r in results if r.dnc)
186
+ cached_count = sum(1 for r in results if r.cached)
187
+
188
+ click.echo("\nSummary:")
189
+ click.echo(f" Mobile: {mobile_count}")
190
+ click.echo(f" Landline: {landline_count}")
191
+ click.echo(f" On DNC: {dnc_count}")
192
+ if cache:
193
+ click.echo(f" From Cache: {cached_count}")
194
+
195
+ # Generate output if requested
196
+ if output:
197
+ processor.generate_results_csv(csv_file, results, output)
198
+ click.echo(f"\nResults saved to: {output}")
199
+
200
+ except Exception as e:
201
+ click.echo(f"Bulk processing failed: {e}", err=True)
202
+ if verbose:
203
+ import traceback
204
+ click.echo(traceback.format_exc(), err=True)
@@ -0,0 +1,129 @@
1
+ """Environment variable loader for CLI commands."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional, Dict
6
+ import click
7
+
8
+
9
+ def load_env_file(env_path: Path, verbose: bool = False) -> Dict[str, str]:
10
+ """Load environment variables from a .env file."""
11
+ env_vars = {}
12
+ if env_path.exists():
13
+ try:
14
+ with open(env_path, 'r') as f:
15
+ for line in f:
16
+ line = line.strip()
17
+ if line and not line.startswith('#') and '=' in line:
18
+ key, value = line.split('=', 1)
19
+ # Remove quotes if present
20
+ value = value.strip().strip('"').strip("'")
21
+ env_vars[key.strip()] = value
22
+ except Exception as e:
23
+ if verbose:
24
+ click.echo(f"Warning: Could not read {env_path}: {e}", err=True)
25
+ return env_vars
26
+
27
+
28
+ def load_environment_config() -> Dict[str, str]:
29
+ """
30
+ Load environment variables from multiple sources in order:
31
+ 1. ~/.lls/.env (user global config)
32
+ 2. ./.env (project local config)
33
+ 3. System environment variables (highest priority)
34
+
35
+ Returns merged dictionary with system env taking precedence.
36
+ """
37
+ env_vars = {}
38
+
39
+ # Load from ~/.lls/.env
40
+ home_env = Path.home() / '.lls' / '.env'
41
+ if home_env.exists():
42
+ home_vars = load_env_file(home_env)
43
+ env_vars.update(home_vars)
44
+ click.echo(f"Loaded {len(home_vars)} variables from {home_env}", err=True)
45
+
46
+ # Load from ./.env
47
+ local_env = Path('.env')
48
+ if local_env.exists():
49
+ local_vars = load_env_file(local_env)
50
+ env_vars.update(local_vars)
51
+ click.echo(f"Loaded {len(local_vars)} variables from {local_env}", err=True)
52
+
53
+ # System environment variables override file-based ones
54
+ env_vars.update(os.environ)
55
+
56
+ return env_vars
57
+
58
+
59
+ def get_stripe_key(environment: str, env_vars: Optional[Dict[str, str]] = None) -> Optional[str]:
60
+ """
61
+ Get Stripe API key for the specified environment.
62
+
63
+ Looks for keys in this order:
64
+ 1. STAGING_STRIPE_SECRET_KEY or PROD_STRIPE_SECRET_KEY (based on environment)
65
+ 2. STRIPE_SECRET_KEY (fallback)
66
+
67
+ Args:
68
+ environment: 'staging' or 'production'
69
+ env_vars: Optional pre-loaded environment variables
70
+
71
+ Returns:
72
+ Stripe API key or None if not found
73
+ """
74
+ if env_vars is None:
75
+ env_vars = load_environment_config()
76
+
77
+ # Map environment names to prefixes
78
+ env_prefix_map = {
79
+ 'staging': 'STAGING',
80
+ 'production': 'PROD'
81
+ }
82
+ if environment not in env_prefix_map:
83
+ raise ValueError(f"Invalid environment '{environment}'. Must be 'staging' or 'production'")
84
+ env_prefix = env_prefix_map[environment]
85
+
86
+ # Try environment-specific key first
87
+ env_key = f"{env_prefix}_STRIPE_SECRET_KEY"
88
+ if env_key in env_vars:
89
+ return env_vars[env_key]
90
+
91
+ # Fall back to generic key
92
+ if 'STRIPE_SECRET_KEY' in env_vars:
93
+ return env_vars['STRIPE_SECRET_KEY']
94
+
95
+ return None
96
+
97
+
98
+ def get_env_variable(key: str, environment: Optional[str] = None,
99
+ env_vars: Optional[Dict[str, str]] = None) -> Optional[str]:
100
+ """
101
+ Get an environment variable, optionally with environment prefix.
102
+
103
+ Args:
104
+ key: Variable name (e.g., 'API_URL')
105
+ environment: Optional environment ('staging' or 'production')
106
+ env_vars: Optional pre-loaded environment variables
107
+
108
+ Returns:
109
+ Variable value or None if not found
110
+ """
111
+ if env_vars is None:
112
+ env_vars = load_environment_config()
113
+
114
+ if environment:
115
+ env_prefix_map = {
116
+ 'staging': 'STAGING',
117
+ 'production': 'PROD'
118
+ }
119
+ if environment not in env_prefix_map:
120
+ raise ValueError(f"Invalid environment '{environment}'. Must be 'staging' or 'production'")
121
+ env_prefix = env_prefix_map[environment]
122
+
123
+ # Try environment-specific key first
124
+ env_key = f"{env_prefix}_{key}"
125
+ if env_key in env_vars:
126
+ return env_vars[env_key]
127
+
128
+ # Fall back to non-prefixed key
129
+ return env_vars.get(key)
@@ -0,0 +1,3 @@
1
+ """
2
+ Core business logic modules
3
+ """
@@ -0,0 +1,106 @@
1
+ """
2
+ DynamoDB cache implementation for phone verifications
3
+ """
4
+ import os
5
+ from datetime import datetime, timedelta, timezone
6
+ from typing import Optional
7
+ import boto3
8
+ from aws_lambda_powertools import Logger
9
+ from .models import PhoneVerification, CacheEntry
10
+
11
+ logger = Logger()
12
+
13
+
14
+ class DynamoDBCache:
15
+ """Cache for phone verification results using DynamoDB with TTL"""
16
+
17
+ def __init__(self, table_name: str, ttl_days: int = 30):
18
+ self.table_name = table_name
19
+ self.ttl_days = ttl_days
20
+ self.dynamodb = boto3.resource("dynamodb")
21
+ self.table = self.dynamodb.Table(table_name)
22
+
23
+ def get(self, phone_number: str) -> Optional[PhoneVerification]:
24
+ """Get cached verification result"""
25
+ try:
26
+ response = self.table.get_item(Key={"phone_number": phone_number})
27
+
28
+ if "Item" not in response:
29
+ logger.info(f"Cache miss for {phone_number[:6]}***")
30
+ return None
31
+
32
+ item = response["Item"]
33
+ logger.info(f"Cache hit for {phone_number[:6]}***")
34
+
35
+ return PhoneVerification(
36
+ phone_number=item["phone_number"],
37
+ line_type=item["line_type"],
38
+ dnc=item["dnc"],
39
+ cached=True,
40
+ verified_at=datetime.fromisoformat(item["verified_at"]),
41
+ source="cache"
42
+ )
43
+
44
+ except Exception as e:
45
+ logger.error(f"Cache get error: {str(e)}")
46
+ return None
47
+
48
+ def set(self, phone_number: str, verification: PhoneVerification) -> None:
49
+ """Store verification result in cache"""
50
+ try:
51
+ ttl = int((datetime.now(timezone.utc) + timedelta(days=self.ttl_days)).timestamp())
52
+
53
+ self.table.put_item(
54
+ Item={
55
+ "phone_number": phone_number,
56
+ "line_type": verification.line_type,
57
+ "dnc": verification.dnc,
58
+ "verified_at": verification.verified_at.isoformat(),
59
+ "source": verification.source,
60
+ "ttl": ttl
61
+ }
62
+ )
63
+
64
+ logger.info(f"Cached result for {phone_number[:6]}***")
65
+
66
+ except Exception as e:
67
+ logger.error(f"Cache set error: {str(e)}")
68
+ # Don't fail the request if cache write fails
69
+
70
+ def batch_get(self, phone_numbers: list[str]) -> dict[str, Optional[PhoneVerification]]:
71
+ """Get multiple cached results"""
72
+ results = {}
73
+
74
+ # DynamoDB batch get (max 100 items per request)
75
+ for i in range(0, len(phone_numbers), 100):
76
+ batch = phone_numbers[i:i+100]
77
+
78
+ try:
79
+ response = self.dynamodb.batch_get_item(
80
+ RequestItems={
81
+ self.table_name: {
82
+ "Keys": [{"phone_number": phone} for phone in batch]
83
+ }
84
+ }
85
+ )
86
+
87
+ for item in response.get("Responses", {}).get(self.table_name, []):
88
+ phone = item["phone_number"]
89
+ results[phone] = PhoneVerification(
90
+ phone_number=phone,
91
+ line_type=item["line_type"],
92
+ dnc=item["dnc"],
93
+ cached=True,
94
+ verified_at=datetime.fromisoformat(item["verified_at"]),
95
+ source="cache"
96
+ )
97
+
98
+ except Exception as e:
99
+ logger.error(f"Batch cache get error: {str(e)}")
100
+
101
+ # Fill in None for misses
102
+ for phone in phone_numbers:
103
+ if phone not in results:
104
+ results[phone] = None
105
+
106
+ return results