ai-lls-lib 1.0.0__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 ADDED
@@ -0,0 +1,27 @@
1
+ """
2
+ AI LLS Library - Core business logic for Landline Scrubber
3
+ """
4
+ from ai_lls_lib.core.models import (
5
+ PhoneVerification,
6
+ BulkJob,
7
+ BulkJobStatus,
8
+ LineType,
9
+ VerificationSource,
10
+ JobStatus
11
+ )
12
+ from ai_lls_lib.core.verifier import PhoneVerifier
13
+ from ai_lls_lib.core.processor import BulkProcessor
14
+ from ai_lls_lib.core.cache import DynamoDBCache
15
+
16
+ __version__ = "1.0.0"
17
+ __all__ = [
18
+ "PhoneVerification",
19
+ "BulkJob",
20
+ "BulkJobStatus",
21
+ "LineType",
22
+ "VerificationSource",
23
+ "JobStatus",
24
+ "PhoneVerifier",
25
+ "BulkProcessor",
26
+ "DynamoDBCache",
27
+ ]
@@ -0,0 +1,3 @@
1
+ """
2
+ Landline Scrubber CLI - Infrastructure-aware administrative tools
3
+ """
@@ -0,0 +1,29 @@
1
+ """
2
+ Landline Scrubber CLI entry point
3
+ """
4
+ import click
5
+ import sys
6
+ from ai_lls_lib.cli.commands import verify, cache, admin, test_stack
7
+
8
+ @click.group()
9
+ @click.version_option(version="0.1.0", prog_name="ai-lls")
10
+ def cli():
11
+ """Landline Scrubber CLI - Administrative and debugging tools"""
12
+ pass
13
+
14
+ # Register command groups
15
+ cli.add_command(verify.verify_group)
16
+ cli.add_command(cache.cache_group)
17
+ cli.add_command(admin.admin_group)
18
+ cli.add_command(test_stack.test_stack_group)
19
+
20
+ def main():
21
+ """Main entry point"""
22
+ try:
23
+ cli()
24
+ except Exception as e:
25
+ click.echo(f"Error: {e}", err=True)
26
+ sys.exit(1)
27
+
28
+ if __name__ == "__main__":
29
+ main()
@@ -0,0 +1,115 @@
1
+ """
2
+ AWS client utilities for CLI operations
3
+ """
4
+ import os
5
+ import boto3
6
+ from typing import Optional, Any, Dict
7
+ from botocore.exceptions import ClientError
8
+ import click
9
+
10
+ class AWSClient:
11
+ """Wrapper for AWS operations with proper error handling"""
12
+
13
+ def __init__(self, region: Optional[str] = None, profile: Optional[str] = None):
14
+ """Initialize AWS clients"""
15
+ self.region = region or os.environ.get("AWS_REGION", "us-east-1")
16
+ self.profile = profile
17
+
18
+ # Create session
19
+ if profile:
20
+ self.session = boto3.Session(profile_name=profile, region_name=self.region)
21
+ else:
22
+ self.session = boto3.Session(region_name=self.region)
23
+
24
+ # Lazy-load clients
25
+ self._dynamodb = None
26
+ self._s3 = None
27
+ self._sqs = None
28
+ self._secretsmanager = None
29
+ self._cloudformation = None
30
+
31
+ @property
32
+ def dynamodb(self):
33
+ """Get DynamoDB client"""
34
+ if not self._dynamodb:
35
+ self._dynamodb = self.session.resource('dynamodb')
36
+ return self._dynamodb
37
+
38
+ @property
39
+ def s3(self):
40
+ """Get S3 client"""
41
+ if not self._s3:
42
+ self._s3 = self.session.client('s3')
43
+ return self._s3
44
+
45
+ @property
46
+ def sqs(self):
47
+ """Get SQS client"""
48
+ if not self._sqs:
49
+ self._sqs = self.session.client('sqs')
50
+ return self._sqs
51
+
52
+ @property
53
+ def secretsmanager(self):
54
+ """Get Secrets Manager client"""
55
+ if not self._secretsmanager:
56
+ self._secretsmanager = self.session.client('secretsmanager')
57
+ return self._secretsmanager
58
+
59
+ @property
60
+ def cloudformation(self):
61
+ """Get CloudFormation client"""
62
+ if not self._cloudformation:
63
+ self._cloudformation = self.session.client('cloudformation')
64
+ return self._cloudformation
65
+
66
+ def get_stack_outputs(self, stack_name: str) -> Dict[str, str]:
67
+ """Get CloudFormation stack outputs"""
68
+ try:
69
+ response = self.cloudformation.describe_stacks(StackName=stack_name)
70
+ stack = response['Stacks'][0]
71
+ outputs = {}
72
+ for output in stack.get('Outputs', []):
73
+ outputs[output['OutputKey']] = output['OutputValue']
74
+ return outputs
75
+ except ClientError as e:
76
+ if e.response['Error']['Code'] == 'ValidationError':
77
+ raise click.ClickException(f"Stack '{stack_name}' not found")
78
+ raise
79
+
80
+ def get_table_name(self, stack_name: str, logical_name: str) -> str:
81
+ """Get actual table name from stack"""
82
+ try:
83
+ response = self.cloudformation.describe_stack_resource(
84
+ StackName=stack_name,
85
+ LogicalResourceId=logical_name
86
+ )
87
+ return response['StackResourceDetail']['PhysicalResourceId']
88
+ except ClientError:
89
+ # Fallback to conventional naming
90
+ return f"{stack_name}-{logical_name.lower()}"
91
+
92
+ def scan_table(self, table_name: str, limit: int = 100) -> list:
93
+ """Scan DynamoDB table"""
94
+ try:
95
+ table = self.dynamodb.Table(table_name)
96
+ response = table.scan(Limit=limit)
97
+ return response.get('Items', [])
98
+ except ClientError as e:
99
+ raise click.ClickException(f"Error scanning table: {e}")
100
+
101
+ def put_item(self, table_name: str, item: dict) -> None:
102
+ """Put item to DynamoDB"""
103
+ try:
104
+ table = self.dynamodb.Table(table_name)
105
+ table.put_item(Item=item)
106
+ except ClientError as e:
107
+ raise click.ClickException(f"Error putting item: {e}")
108
+
109
+ def delete_item(self, table_name: str, key: dict) -> None:
110
+ """Delete item from DynamoDB"""
111
+ try:
112
+ table = self.dynamodb.Table(table_name)
113
+ table.delete_item(Key=key)
114
+ except ClientError as e:
115
+ raise click.ClickException(f"Error deleting item: {e}")
@@ -0,0 +1,3 @@
1
+ """
2
+ CLI command modules
3
+ """
@@ -0,0 +1,174 @@
1
+ """
2
+ Administrative commands for user and credit management
3
+ """
4
+ import click
5
+ import json
6
+ from datetime import datetime
7
+ from ai_lls_lib.cli.aws_client import AWSClient
8
+
9
+ @click.group(name="admin")
10
+ def admin_group():
11
+ """Administrative commands"""
12
+ pass
13
+
14
+ @admin_group.command(name="user-credits")
15
+ @click.argument("user_id")
16
+ @click.option("--add", type=int, help="Add credits")
17
+ @click.option("--set", "set_credits", type=int, help="Set credits to specific value")
18
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
19
+ @click.option("--profile", help="AWS profile")
20
+ @click.option("--region", help="AWS region")
21
+ def user_credits(user_id, add, set_credits, stack, profile, region):
22
+ """Manage user credits"""
23
+ aws = AWSClient(region=region, profile=profile)
24
+ credits_table = aws.get_table_name(stack, "CreditsTable")
25
+
26
+ try:
27
+ table = aws.dynamodb.Table(credits_table)
28
+
29
+ # Get current credits
30
+ response = table.get_item(Key={"user_id": user_id})
31
+ current = response.get('Item', {}).get('credits', 0)
32
+
33
+ click.echo(f"User {user_id} - Current credits: {current}")
34
+
35
+ if add:
36
+ new_credits = current + add
37
+ table.update_item(
38
+ Key={"user_id": user_id},
39
+ UpdateExpression="SET credits = :val, updated_at = :now",
40
+ ExpressionAttributeValues={
41
+ ":val": new_credits,
42
+ ":now": datetime.utcnow().isoformat()
43
+ }
44
+ )
45
+ click.echo(f"Added {add} credits. New balance: {new_credits}")
46
+
47
+ elif set_credits is not None:
48
+ table.update_item(
49
+ Key={"user_id": user_id},
50
+ UpdateExpression="SET credits = :val, updated_at = :now",
51
+ ExpressionAttributeValues={
52
+ ":val": set_credits,
53
+ ":now": datetime.utcnow().isoformat()
54
+ }
55
+ )
56
+ click.echo(f"Set credits to {set_credits}")
57
+
58
+ except Exception as e:
59
+ click.echo(f"Error managing credits: {e}", err=True)
60
+
61
+ @admin_group.command(name="api-keys")
62
+ @click.option("--user", help="Filter by user ID")
63
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
64
+ @click.option("--profile", help="AWS profile")
65
+ @click.option("--region", help="AWS region")
66
+ def list_api_keys(user, stack, profile, region):
67
+ """List API keys"""
68
+ aws = AWSClient(region=region, profile=profile)
69
+ keys_table = aws.get_table_name(stack, "ApiKeysTable")
70
+
71
+ try:
72
+ if user:
73
+ # Query by user using GSI
74
+ table = aws.dynamodb.Table(keys_table)
75
+ response = table.query(
76
+ IndexName='gsi_user',
77
+ KeyConditionExpression='user_id = :uid',
78
+ ExpressionAttributeValues={':uid': user}
79
+ )
80
+ items = response.get('Items', [])
81
+ else:
82
+ # Scan all
83
+ items = aws.scan_table(keys_table)
84
+
85
+ if not items:
86
+ click.echo("No API keys found")
87
+ return
88
+
89
+ click.echo(f"\nAPI Keys ({len(items)} total):")
90
+ click.echo("=" * 60)
91
+
92
+ for item in items:
93
+ click.echo(f"Key ID: {item.get('api_key_id', 'N/A')}")
94
+ click.echo(f"User: {item.get('user_id', 'N/A')}")
95
+ click.echo(f"Created: {item.get('created_at', 'N/A')}")
96
+ click.echo(f"Last Used: {item.get('last_used', 'Never')}")
97
+ click.echo("-" * 60)
98
+
99
+ except Exception as e:
100
+ click.echo(f"Error listing API keys: {e}", err=True)
101
+
102
+ @admin_group.command(name="queue-stats")
103
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
104
+ @click.option("--profile", help="AWS profile")
105
+ @click.option("--region", help="AWS region")
106
+ def queue_stats(stack, profile, region):
107
+ """Show SQS queue statistics"""
108
+ aws = AWSClient(region=region, profile=profile)
109
+
110
+ try:
111
+ # Get queue URLs from stack
112
+ outputs = aws.get_stack_outputs(stack)
113
+
114
+ # Find queue URLs or construct them
115
+ queue_name = f"{stack}-bulk-processing"
116
+ dlq_name = f"{stack}-bulk-processing-dlq"
117
+
118
+ # Get queue attributes
119
+ queues = [
120
+ ("Processing Queue", queue_name),
121
+ ("Dead Letter Queue", dlq_name)
122
+ ]
123
+
124
+ for display_name, queue in queues:
125
+ try:
126
+ response = aws.sqs.get_queue_url(QueueName=queue)
127
+ queue_url = response['QueueUrl']
128
+
129
+ attrs = aws.sqs.get_queue_attributes(
130
+ QueueUrl=queue_url,
131
+ AttributeNames=['All']
132
+ )['Attributes']
133
+
134
+ click.echo(f"\n{display_name}:")
135
+ click.echo(f" Messages Available: {attrs.get('ApproximateNumberOfMessages', 0)}")
136
+ click.echo(f" Messages In Flight: {attrs.get('ApproximateNumberOfMessagesNotVisible', 0)}")
137
+ click.echo(f" Messages Delayed: {attrs.get('ApproximateNumberOfMessagesDelayed', 0)}")
138
+
139
+ except aws.sqs.exceptions.QueueDoesNotExist:
140
+ click.echo(f"\n{display_name}: Not found")
141
+
142
+ except Exception as e:
143
+ click.echo(f"Error getting queue stats: {e}", err=True)
144
+
145
+ @admin_group.command(name="secrets")
146
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
147
+ @click.option("--show", is_flag=True, help="Show secret values (CAREFUL!)")
148
+ @click.option("--profile", help="AWS profile")
149
+ @click.option("--region", help="AWS region")
150
+ def manage_secrets(stack, show, profile, region):
151
+ """Manage secrets"""
152
+ aws = AWSClient(region=region, profile=profile)
153
+ secret_name = f"{stack}-secrets"
154
+
155
+ try:
156
+ response = aws.secretsmanager.describe_secret(SecretId=secret_name)
157
+ click.echo(f"\nSecret: {secret_name}")
158
+ click.echo(f"ARN: {response['ARN']}")
159
+ click.echo(f"Last Updated: {response.get('LastChangedDate', 'N/A')}")
160
+
161
+ if show and click.confirm("Show secret values? This will display sensitive data!"):
162
+ secret_value = aws.secretsmanager.get_secret_value(SecretId=secret_name)
163
+ secrets = json.loads(secret_value['SecretString'])
164
+
165
+ click.echo("\nSecret Values:")
166
+ for key, value in secrets.items():
167
+ # Mask most of the value
168
+ masked = value[:4] + "*" * (len(value) - 8) + value[-4:] if len(value) > 8 else "*" * len(value)
169
+ click.echo(f" {key}: {masked}")
170
+
171
+ except aws.secretsmanager.exceptions.ResourceNotFoundException:
172
+ click.echo(f"Secret '{secret_name}' not found")
173
+ except Exception as e:
174
+ click.echo(f"Error managing secrets: {e}", err=True)
@@ -0,0 +1,142 @@
1
+ """
2
+ Cache management commands
3
+ """
4
+ import click
5
+ import json
6
+ from datetime import datetime, timedelta
7
+ from ai_lls_lib.cli.aws_client import AWSClient
8
+
9
+ @click.group(name="cache")
10
+ def cache_group():
11
+ """Cache management commands"""
12
+ pass
13
+
14
+ @cache_group.command(name="stats")
15
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
16
+ @click.option("--profile", help="AWS profile")
17
+ @click.option("--region", help="AWS region")
18
+ def cache_stats(stack, profile, region):
19
+ """Show cache statistics"""
20
+ aws = AWSClient(region=region, profile=profile)
21
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
22
+
23
+ try:
24
+ # Get table description for item count
25
+ table = aws.dynamodb.Table(cache_table)
26
+ desc = table.meta.client.describe_table(TableName=cache_table)
27
+
28
+ click.echo(f"\nCache Table: {cache_table}")
29
+ click.echo("=" * 50)
30
+ click.echo(f"Item Count: {desc['Table']['ItemCount']:,}")
31
+ click.echo(f"Table Size: {desc['Table']['TableSizeBytes']:,} bytes")
32
+ click.echo(f"Status: {desc['Table']['TableStatus']}")
33
+
34
+ # Sample some items to show age distribution
35
+ items = aws.scan_table(cache_table, limit=100)
36
+ if items:
37
+ now = datetime.utcnow()
38
+ ages = []
39
+ for item in items:
40
+ if 'verified_at' in item:
41
+ verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
42
+ age = (now - verified.replace(tzinfo=None)).days
43
+ ages.append(age)
44
+
45
+ if ages:
46
+ click.echo(f"\nCache Age (sample of {len(ages)} items):")
47
+ click.echo(f" Newest: {min(ages)} days")
48
+ click.echo(f" Oldest: {max(ages)} days")
49
+ click.echo(f" Average: {sum(ages)/len(ages):.1f} days")
50
+
51
+ except Exception as e:
52
+ click.echo(f"Error getting cache stats: {e}", err=True)
53
+
54
+ @cache_group.command(name="get")
55
+ @click.argument("phone_number")
56
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
57
+ @click.option("--profile", help="AWS profile")
58
+ @click.option("--region", help="AWS region")
59
+ def cache_get(phone_number, stack, profile, region):
60
+ """Get cached entry for a phone number"""
61
+ from ai_lls_lib.core.verifier import PhoneVerifier
62
+ from ai_lls_lib.core.cache import DynamoDBCache
63
+
64
+ aws = AWSClient(region=region, profile=profile)
65
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
66
+
67
+ # Normalize phone first
68
+ verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
69
+ try:
70
+ normalized = verifier.normalize_phone(phone_number)
71
+ except ValueError as e:
72
+ click.echo(f"Invalid phone: {e}", err=True)
73
+ return
74
+
75
+ # Get from cache
76
+ cache = DynamoDBCache(table_name=cache_table)
77
+ result = cache.get(normalized)
78
+
79
+ if result:
80
+ click.echo(f"\nCached entry for {normalized}:")
81
+ click.echo(json.dumps(result.dict() if hasattr(result, 'dict') else result, indent=2, default=str))
82
+ else:
83
+ click.echo(f"No cache entry found for {normalized}")
84
+
85
+ @cache_group.command(name="invalidate")
86
+ @click.argument("phone_number")
87
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
88
+ @click.option("--profile", help="AWS profile")
89
+ @click.option("--region", help="AWS region")
90
+ @click.confirmation_option(prompt="Are you sure you want to invalidate this cache entry?")
91
+ def cache_invalidate(phone_number, stack, profile, region):
92
+ """Invalidate cached entry for a phone number"""
93
+ from ai_lls_lib.core.verifier import PhoneVerifier
94
+ from ai_lls_lib.core.cache import DynamoDBCache
95
+
96
+ aws = AWSClient(region=region, profile=profile)
97
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
98
+
99
+ # Normalize phone
100
+ verifier = PhoneVerifier(cache=DynamoDBCache(table_name=cache_table))
101
+ try:
102
+ normalized = verifier.normalize_phone(phone_number)
103
+ except ValueError as e:
104
+ click.echo(f"Invalid phone: {e}", err=True)
105
+ return
106
+
107
+ # Delete from cache
108
+ try:
109
+ aws.delete_item(cache_table, {"phone_number": normalized})
110
+ click.echo(f"Cache entry invalidated for {normalized}")
111
+ except Exception as e:
112
+ click.echo(f"Error invalidating cache: {e}", err=True)
113
+
114
+ @cache_group.command(name="clear")
115
+ @click.option("--stack", default="landline-api", help="CloudFormation stack name")
116
+ @click.option("--older-than", type=int, help="Clear entries older than N days")
117
+ @click.option("--profile", help="AWS profile")
118
+ @click.option("--region", help="AWS region")
119
+ @click.confirmation_option(prompt="This will clear cache entries. Continue?")
120
+ def cache_clear(stack, older_than, profile, region):
121
+ """Clear cache entries"""
122
+ aws = AWSClient(region=region, profile=profile)
123
+ cache_table = aws.get_table_name(stack, "PhoneCacheTable")
124
+
125
+ if older_than:
126
+ click.echo(f"Clearing entries older than {older_than} days...")
127
+ cutoff = datetime.utcnow() - timedelta(days=older_than)
128
+
129
+ # Scan and delete old entries
130
+ items = aws.scan_table(cache_table, limit=1000)
131
+ deleted = 0
132
+
133
+ for item in items:
134
+ if 'verified_at' in item:
135
+ verified = datetime.fromisoformat(item['verified_at'].replace('Z', '+00:00'))
136
+ if verified.replace(tzinfo=None) < cutoff:
137
+ aws.delete_item(cache_table, {"phone_number": item['phone_number']})
138
+ deleted += 1
139
+
140
+ click.echo(f"Deleted {deleted} entries older than {older_than} days")
141
+ else:
142
+ click.echo("Full cache clear not implemented for safety. Use --older-than option.")