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.
- ai_lls_lib/__init__.py +32 -0
- ai_lls_lib/auth/__init__.py +4 -0
- ai_lls_lib/auth/context_parser.py +68 -0
- ai_lls_lib/cli/__init__.py +3 -0
- ai_lls_lib/cli/__main__.py +63 -0
- ai_lls_lib/cli/aws_client.py +123 -0
- ai_lls_lib/cli/commands/__init__.py +3 -0
- ai_lls_lib/cli/commands/admin.py +174 -0
- ai_lls_lib/cli/commands/cache.py +142 -0
- ai_lls_lib/cli/commands/monitor.py +297 -0
- ai_lls_lib/cli/commands/stripe.py +355 -0
- ai_lls_lib/cli/commands/test_stack.py +216 -0
- ai_lls_lib/cli/commands/verify.py +204 -0
- ai_lls_lib/cli/env_loader.py +129 -0
- ai_lls_lib/core/__init__.py +3 -0
- ai_lls_lib/core/cache.py +106 -0
- ai_lls_lib/core/models.py +77 -0
- ai_lls_lib/core/processor.py +295 -0
- ai_lls_lib/core/verifier.py +90 -0
- ai_lls_lib/payment/__init__.py +13 -0
- ai_lls_lib/payment/credit_manager.py +186 -0
- ai_lls_lib/payment/models.py +102 -0
- ai_lls_lib/payment/stripe_manager.py +486 -0
- ai_lls_lib/payment/webhook_processor.py +215 -0
- ai_lls_lib/providers/__init__.py +8 -0
- ai_lls_lib/providers/base.py +28 -0
- ai_lls_lib/providers/external.py +151 -0
- ai_lls_lib/providers/stub.py +48 -0
- ai_lls_lib/testing/__init__.py +3 -0
- ai_lls_lib/testing/fixtures.py +104 -0
- ai_lls_lib-2.0.0rc2.dist-info/METADATA +301 -0
- ai_lls_lib-2.0.0rc2.dist-info/RECORD +34 -0
- ai_lls_lib-2.0.0rc2.dist-info/WHEEL +4 -0
- ai_lls_lib-2.0.0rc2.dist-info/entry_points.txt +3 -0
|
@@ -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)
|
ai_lls_lib/core/cache.py
ADDED
|
@@ -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
|