ai-lls-lib 1.0.0__py3-none-any.whl → 1.2.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.
@@ -2,7 +2,8 @@
2
2
  Bulk CSV processing for phone verification
3
3
  """
4
4
  import csv
5
- from typing import List, Optional
5
+ from io import StringIO
6
+ from typing import List, Optional, Iterator, Iterable
6
7
  from aws_lambda_powertools import Logger
7
8
  from .models import PhoneVerification
8
9
  from .verifier import PhoneVerifier
@@ -16,47 +17,48 @@ class BulkProcessor:
16
17
  def __init__(self, verifier: PhoneVerifier):
17
18
  self.verifier = verifier
18
19
 
19
- def process_csv_sync(self, file_path: str, phone_column: str = "phone") -> List[PhoneVerification]:
20
+ def process_csv(self, csv_text: str, phone_column: str = "phone") -> List[PhoneVerification]:
20
21
  """
21
- Process CSV file synchronously.
22
+ Process CSV text content.
22
23
  Returns list of verification results.
23
24
  """
24
25
  results = []
25
26
 
26
27
  try:
27
- with open(file_path, 'r', encoding='utf-8-sig') as f:
28
- reader = csv.DictReader(f)
28
+ # Use StringIO to parse CSV text
29
+ csv_file = StringIO(csv_text)
30
+ reader = csv.DictReader(csv_file)
29
31
 
30
- # Find phone column (case-insensitive)
31
- headers = reader.fieldnames or []
32
- phone_col = self._find_phone_column(headers, phone_column)
32
+ # Find phone column (case-insensitive)
33
+ headers = reader.fieldnames or []
34
+ phone_col = self._find_phone_column(headers, phone_column)
33
35
 
34
- if not phone_col:
35
- raise ValueError(f"Phone column '{phone_column}' not found in CSV")
36
+ if not phone_col:
37
+ raise ValueError(f"Phone column '{phone_column}' not found in CSV")
36
38
 
37
- for row_num, row in enumerate(reader, start=2): # Start at 2 (header is 1)
38
- try:
39
- phone = row.get(phone_col, "").strip()
40
- if not phone:
41
- logger.warning(f"Empty phone at row {row_num}")
42
- continue
39
+ for row_num, row in enumerate(reader, start=2): # Start at 2 (header is 1)
40
+ try:
41
+ phone = row.get(phone_col, "").strip()
42
+ if not phone:
43
+ logger.warning(f"Empty phone at row {row_num}")
44
+ continue
43
45
 
44
- # Verify phone
45
- result = self.verifier.verify_sync(phone)
46
- results.append(result)
46
+ # Verify phone
47
+ result = self.verifier.verify(phone)
48
+ results.append(result)
47
49
 
48
- # Log progress every 100 rows
49
- if len(results) % 100 == 0:
50
- logger.info(f"Processed {len(results)} phones")
50
+ # Log progress every 100 rows
51
+ if len(results) % 100 == 0:
52
+ logger.info(f"Processed {len(results)} phones")
51
53
 
52
- except ValueError as e:
53
- logger.warning(f"Invalid phone at row {row_num}: {str(e)}")
54
- continue
55
- except Exception as e:
56
- logger.error(f"Error processing row {row_num}: {str(e)}")
57
- continue
54
+ except ValueError as e:
55
+ logger.warning(f"Invalid phone at row {row_num}: {str(e)}")
56
+ continue
57
+ except Exception as e:
58
+ logger.error(f"Error processing row {row_num}: {str(e)}")
59
+ continue
58
60
 
59
- logger.info(f"Completed processing {len(results)} valid phones")
61
+ logger.info(f"Completed processing {len(results)} valid phones")
60
62
 
61
63
  except Exception as e:
62
64
  logger.error(f"CSV processing failed: {str(e)}")
@@ -88,48 +90,206 @@ class BulkProcessor:
88
90
 
89
91
  def generate_results_csv(
90
92
  self,
91
- original_path: str,
92
- results: List[PhoneVerification],
93
- output_path: str
94
- ) -> None:
93
+ original_csv_text: str,
94
+ results: List[PhoneVerification]
95
+ ) -> str:
95
96
  """
96
97
  Generate CSV with original data plus verification results.
97
98
  Adds columns: line_type, dnc, cached
99
+ Returns CSV text string.
98
100
  """
99
101
  # Create lookup dict
100
102
  results_map = {r.phone_number: r for r in results}
101
103
 
102
- with open(original_path, 'r', encoding='utf-8-sig') as infile:
103
- reader = csv.DictReader(infile)
104
+ # Parse original CSV
105
+ input_file = StringIO(original_csv_text)
106
+ reader = csv.DictReader(input_file)
107
+ headers = reader.fieldnames or []
108
+
109
+ # Add new columns
110
+ output_headers = headers + ["line_type", "dnc", "cached"]
111
+
112
+ # Create output CSV in memory
113
+ output = StringIO()
114
+ writer = csv.DictWriter(output, fieldnames=output_headers)
115
+ writer.writeheader()
116
+
117
+ phone_col = self._find_phone_column(headers, "phone")
118
+
119
+ for row in reader:
120
+ phone = row.get(phone_col, "").strip()
121
+
122
+ # Try to normalize for lookup
123
+ try:
124
+ normalized = self.verifier.normalize_phone(phone)
125
+ if normalized in results_map:
126
+ result = results_map[normalized]
127
+ row["line_type"] = result.line_type.value
128
+ row["dnc"] = "true" if result.dnc else "false"
129
+ row["cached"] = "true" if result.cached else "false"
130
+ else:
131
+ row["line_type"] = "unknown"
132
+ row["dnc"] = ""
133
+ row["cached"] = ""
134
+ except:
135
+ row["line_type"] = "invalid"
136
+ row["dnc"] = ""
137
+ row["cached"] = ""
138
+
139
+ writer.writerow(row)
140
+
141
+ # Return CSV text
142
+ return output.getvalue()
143
+
144
+ def process_csv_stream(
145
+ self,
146
+ lines: Iterable[str],
147
+ phone_column: str = "phone",
148
+ batch_size: int = 100
149
+ ) -> Iterator[List[PhoneVerification]]:
150
+ """
151
+ Process CSV lines as a stream, yielding batches of results.
152
+ Memory-efficient for large files.
153
+
154
+ Args:
155
+ lines: Iterator of CSV lines (including header)
156
+ phone_column: Column name containing phone numbers
157
+ batch_size: Number of results to accumulate before yielding
158
+
159
+ Yields:
160
+ Batches of PhoneVerification results
161
+ """
162
+ lines_list = list(lines) # Need to iterate twice - once for headers, once for data
163
+
164
+ if not lines_list:
165
+ logger.error("Empty CSV stream")
166
+ return
167
+
168
+ # Parse header
169
+ header_line = lines_list[0]
170
+ reader = csv.DictReader(StringIO(header_line))
171
+ headers = reader.fieldnames or []
172
+ phone_col = self._find_phone_column(headers, phone_column)
173
+
174
+ if not phone_col:
175
+ raise ValueError(f"Phone column '{phone_column}' not found in CSV")
176
+
177
+ batch = []
178
+ row_num = 2 # Start at 2 (header is 1)
179
+ total_processed = 0
180
+
181
+ # Process data lines
182
+ for line in lines_list[1:]:
183
+ if not line.strip():
184
+ continue
185
+
186
+ try:
187
+ # Parse single line
188
+ row = next(csv.DictReader(StringIO(line), fieldnames=headers))
189
+ phone = row.get(phone_col, "").strip()
190
+
191
+ if not phone:
192
+ logger.warning(f"Empty phone at row {row_num}")
193
+ row_num += 1
194
+ continue
195
+
196
+ # Verify phone
197
+ result = self.verifier.verify(phone)
198
+ batch.append(result)
199
+ total_processed += 1
200
+
201
+ # Yield batch when full
202
+ if len(batch) >= batch_size:
203
+ logger.info(f"Processed batch of {len(batch)} phones (total: {total_processed})")
204
+ yield batch
205
+ batch = []
206
+
207
+ except ValueError as e:
208
+ logger.warning(f"Invalid phone at row {row_num}: {str(e)}")
209
+ except Exception as e:
210
+ logger.error(f"Error processing row {row_num}: {str(e)}")
211
+ finally:
212
+ row_num += 1
213
+
214
+ # Yield remaining results
215
+ if batch:
216
+ logger.info(f"Processed final batch of {len(batch)} phones (total: {total_processed})")
217
+ yield batch
218
+
219
+ logger.info(f"Stream processing completed. Total processed: {total_processed}")
220
+
221
+ def generate_results_csv_stream(
222
+ self,
223
+ original_lines: Iterable[str],
224
+ results_stream: Iterator[List[PhoneVerification]],
225
+ phone_column: str = "phone"
226
+ ) -> Iterator[str]:
227
+ """
228
+ Generate CSV results as a stream, line by line.
229
+ Memory-efficient for large files.
230
+
231
+ Args:
232
+ original_lines: Iterator of original CSV lines
233
+ results_stream: Iterator of batched PhoneVerification results
234
+ phone_column: Column name containing phone numbers
235
+
236
+ Yields:
237
+ CSV lines with verification results added
238
+ """
239
+ lines_iter = iter(original_lines)
240
+
241
+ # Read and yield modified header
242
+ try:
243
+ header_line = next(lines_iter)
244
+ reader = csv.DictReader(StringIO(header_line))
104
245
  headers = reader.fieldnames or []
105
246
 
106
247
  # Add new columns
107
248
  output_headers = headers + ["line_type", "dnc", "cached"]
249
+ yield ','.join(output_headers) + '\n'
108
250
 
109
- with open(output_path, 'w', newline='', encoding='utf-8') as outfile:
110
- writer = csv.DictWriter(outfile, fieldnames=output_headers)
111
- writer.writeheader()
251
+ phone_col = self._find_phone_column(headers, phone_column)
112
252
 
113
- phone_col = self._find_phone_column(headers, "phone")
253
+ except StopIteration:
254
+ return
114
255
 
115
- for row in reader:
116
- phone = row.get(phone_col, "").strip()
256
+ # Build results lookup from stream
257
+ results_map = {}
258
+ for batch in results_stream:
259
+ for result in batch:
260
+ results_map[result.phone_number] = result
261
+
262
+ # Reset lines iterator
263
+ lines_iter = iter(original_lines)
264
+ next(lines_iter) # Skip header
265
+
266
+ # Process and yield data lines
267
+ for line in lines_iter:
268
+ if not line.strip():
269
+ continue
270
+
271
+ row = next(csv.DictReader(StringIO(line), fieldnames=headers))
272
+ phone = row.get(phone_col, "").strip()
273
+
274
+ # Add verification results
275
+ try:
276
+ normalized = self.verifier.normalize_phone(phone)
277
+ if normalized in results_map:
278
+ result = results_map[normalized]
279
+ row["line_type"] = result.line_type.value
280
+ row["dnc"] = "true" if result.dnc else "false"
281
+ row["cached"] = "true" if result.cached else "false"
282
+ else:
283
+ row["line_type"] = "unknown"
284
+ row["dnc"] = ""
285
+ row["cached"] = ""
286
+ except:
287
+ row["line_type"] = "invalid"
288
+ row["dnc"] = ""
289
+ row["cached"] = ""
117
290
 
118
- # Try to normalize for lookup
119
- try:
120
- normalized = self.verifier.normalize_phone(phone)
121
- if normalized in results_map:
122
- result = results_map[normalized]
123
- row["line_type"] = result.line_type
124
- row["dnc"] = "true" if result.dnc else "false"
125
- row["cached"] = "true" if result.cached else "false"
126
- else:
127
- row["line_type"] = "unknown"
128
- row["dnc"] = ""
129
- row["cached"] = ""
130
- except:
131
- row["line_type"] = "invalid"
132
- row["dnc"] = ""
133
- row["cached"] = ""
134
-
135
- writer.writerow(row)
291
+ # Write row
292
+ output = StringIO()
293
+ writer = csv.DictWriter(output, fieldnames=output_headers)
294
+ writer.writerow(row)
295
+ yield output.getvalue()
@@ -2,14 +2,13 @@
2
2
  Phone verification logic - checks line type and DNC status
3
3
  """
4
4
  import os
5
- import re
6
5
  from datetime import datetime, timezone
7
6
  from typing import Optional
8
- import httpx
9
7
  import phonenumbers
10
8
  from aws_lambda_powertools import Logger
11
9
  from .models import PhoneVerification, LineType, VerificationSource
12
10
  from .cache import DynamoDBCache
11
+ from ..providers import VerificationProvider, StubProvider
13
12
 
14
13
  logger = Logger()
15
14
 
@@ -17,11 +16,16 @@ logger = Logger()
17
16
  class PhoneVerifier:
18
17
  """Verifies phone numbers for line type and DNC status"""
19
18
 
20
- def __init__(self, cache: DynamoDBCache):
19
+ def __init__(self, cache: DynamoDBCache, provider: Optional[VerificationProvider] = None):
20
+ """
21
+ Initialize phone verifier.
22
+
23
+ Args:
24
+ cache: DynamoDB cache for storing results
25
+ provider: Verification provider (defaults to StubProvider)
26
+ """
21
27
  self.cache = cache
22
- self.dnc_api_key = os.environ.get("DNC_API_KEY", "")
23
- self.phone_api_key = os.environ.get("PHONE_VERIFY_API_KEY", "")
24
- self.http_client = httpx.Client(timeout=10.0)
28
+ self.provider = provider or StubProvider()
25
29
 
26
30
  def normalize_phone(self, phone: str) -> str:
27
31
  """Normalize phone to E.164 format"""
@@ -37,8 +41,8 @@ class PhoneVerifier:
37
41
  logger.error(f"Phone normalization failed: {str(e)}")
38
42
  raise ValueError(f"Invalid phone format: {phone}")
39
43
 
40
- def verify_sync(self, phone: str) -> PhoneVerification:
41
- """Synchronous verification for Lambda handlers"""
44
+ def verify(self, phone: str) -> PhoneVerification:
45
+ """Verify phone number for line type and DNC status"""
42
46
  normalized = self.normalize_phone(phone)
43
47
 
44
48
  # Check cache first
@@ -46,9 +50,8 @@ class PhoneVerifier:
46
50
  if cached:
47
51
  return cached
48
52
 
49
- # Call external APIs
50
- line_type = self._check_line_type_sync(normalized)
51
- dnc_status = self._check_dnc_sync(normalized)
53
+ # Use provider to verify
54
+ line_type, dnc_status = self.provider.verify_phone(normalized)
52
55
 
53
56
  result = PhoneVerification(
54
57
  phone_number=normalized,
@@ -64,32 +67,18 @@ class PhoneVerifier:
64
67
 
65
68
  return result
66
69
 
67
- def _check_line_type_sync(self, phone: str) -> LineType:
68
- """Check if phone is mobile/landline/voip"""
69
- # TODO: Implement actual API call to phone verification service
70
- # Would use self.phone_api_key to authenticate
71
- logger.info(f"Checking line type for {phone[:6]}***")
72
-
73
- # Stub implementation based on last digit
74
- last_digit = phone[-1] if phone else '5'
75
- if last_digit in ['2', '0']:
76
- return LineType.LANDLINE
77
- else:
78
- return LineType.MOBILE
79
-
80
- def _check_dnc_sync(self, phone: str) -> bool:
81
- """Check if phone is on DNC list"""
82
- # TODO: Implement actual DNC API call
83
- # Would use self.dnc_api_key or os.environ.get("DNC_CHECK_API_KEY")
84
- logger.info(f"Checking DNC status for {phone[:6]}***")
85
-
86
- # Stub implementation based on last digit:
87
- # - Ends in 1 or 0: on DNC list
88
- # - Otherwise: not on DNC
89
- last_digit = phone[-1] if phone else '5'
90
- return last_digit in ['1', '0']
91
-
92
- def __del__(self):
93
- """Cleanup HTTP client"""
94
- if hasattr(self, 'http_client'):
95
- self.http_client.close()
70
+ def _check_line_type(self, phone: str) -> LineType:
71
+ """
72
+ Check line type (for backwards compatibility with CLI).
73
+ Delegates to provider.
74
+ """
75
+ line_type, _ = self.provider.verify_phone(phone)
76
+ return line_type
77
+
78
+ def _check_dnc(self, phone: str) -> bool:
79
+ """
80
+ Check DNC status (for backwards compatibility with CLI).
81
+ Delegates to provider.
82
+ """
83
+ _, dnc_status = self.provider.verify_phone(phone)
84
+ return dnc_status
@@ -0,0 +1,13 @@
1
+ """Payment module for Landline Scrubber."""
2
+
3
+ from .models import Plan, PlanType, SubscriptionStatus
4
+ from .stripe_manager import StripeManager
5
+ from .credit_manager import CreditManager
6
+
7
+ __all__ = [
8
+ "Plan",
9
+ "PlanType",
10
+ "SubscriptionStatus",
11
+ "StripeManager",
12
+ "CreditManager",
13
+ ]
@@ -0,0 +1,174 @@
1
+ """Credit balance management with DynamoDB."""
2
+
3
+ import os
4
+ from typing import Optional, Dict, Any
5
+ from decimal import Decimal
6
+ import logging
7
+ from datetime import datetime
8
+
9
+ try:
10
+ import boto3
11
+ from botocore.exceptions import ClientError
12
+ except ImportError:
13
+ boto3 = None # Handle gracefully for testing
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class CreditManager:
19
+ """
20
+ Manages user credit balances in DynamoDB CreditsTable.
21
+ """
22
+
23
+ def __init__(self, table_name: Optional[str] = None):
24
+ """Initialize with DynamoDB table."""
25
+ if not boto3:
26
+ logger.warning("boto3 not installed, using mock credit manager")
27
+ self.table = None
28
+ return
29
+
30
+ self.dynamodb = boto3.resource("dynamodb")
31
+ self.table_name = table_name or os.environ.get("CREDITS_TABLE", "CreditsTable")
32
+
33
+ try:
34
+ self.table = self.dynamodb.Table(self.table_name)
35
+ except Exception as e:
36
+ logger.error(f"Failed to connect to DynamoDB table {self.table_name}: {e}")
37
+ self.table = None
38
+
39
+ def get_balance(self, user_id: str) -> int:
40
+ """Get current credit balance for a user."""
41
+ if not self.table:
42
+ return 1000 # Mock balance for testing
43
+
44
+ try:
45
+ response = self.table.get_item(Key={"user_id": user_id})
46
+ if "Item" in response:
47
+ return int(response["Item"].get("credits", 0))
48
+ return 0
49
+ except ClientError as e:
50
+ logger.error(f"Error getting balance for {user_id}: {e}")
51
+ return 0
52
+
53
+ def add_credits(self, user_id: str, amount: int) -> int:
54
+ """Add credits to user balance and return new balance."""
55
+ if not self.table:
56
+ return 1000 + amount # Mock for testing
57
+
58
+ try:
59
+ response = self.table.update_item(
60
+ Key={"user_id": user_id},
61
+ UpdateExpression="ADD credits :amount SET updated_at = :now",
62
+ ExpressionAttributeValues={
63
+ ":amount": Decimal(amount),
64
+ ":now": datetime.utcnow().isoformat()
65
+ },
66
+ ReturnValues="ALL_NEW"
67
+ )
68
+ return int(response["Attributes"]["credits"])
69
+ except ClientError as e:
70
+ logger.error(f"Error adding credits for {user_id}: {e}")
71
+ raise
72
+
73
+ def deduct_credits(self, user_id: str, amount: int) -> bool:
74
+ """
75
+ Deduct credits from user balance.
76
+ Returns True if successful, False if insufficient balance.
77
+ """
78
+ if not self.table:
79
+ return True # Mock for testing
80
+
81
+ try:
82
+ # Conditional update - only deduct if balance >= amount
83
+ self.table.update_item(
84
+ Key={"user_id": user_id},
85
+ UpdateExpression="ADD credits :negative_amount SET updated_at = :now",
86
+ ConditionExpression="credits >= :amount",
87
+ ExpressionAttributeValues={
88
+ ":negative_amount": Decimal(-amount),
89
+ ":amount": Decimal(amount),
90
+ ":now": datetime.utcnow().isoformat()
91
+ }
92
+ )
93
+ return True
94
+ except ClientError as e:
95
+ if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
96
+ logger.info(f"Insufficient credits for {user_id}")
97
+ return False
98
+ logger.error(f"Error deducting credits for {user_id}: {e}")
99
+ raise
100
+
101
+ def set_subscription_state(
102
+ self,
103
+ user_id: str,
104
+ status: str,
105
+ stripe_customer_id: Optional[str] = None,
106
+ stripe_subscription_id: Optional[str] = None
107
+ ) -> None:
108
+ """Update subscription state in CreditsTable."""
109
+ if not self.table:
110
+ return # Mock for testing
111
+
112
+ try:
113
+ update_expr = "SET subscription_status = :status, updated_at = :now"
114
+ expr_values = {
115
+ ":status": status,
116
+ ":now": datetime.utcnow().isoformat()
117
+ }
118
+
119
+ if stripe_customer_id:
120
+ update_expr += ", stripe_customer_id = :customer_id"
121
+ expr_values[":customer_id"] = stripe_customer_id
122
+
123
+ if stripe_subscription_id:
124
+ update_expr += ", stripe_subscription_id = :subscription_id"
125
+ expr_values[":subscription_id"] = stripe_subscription_id
126
+
127
+ self.table.update_item(
128
+ Key={"user_id": user_id},
129
+ UpdateExpression=update_expr,
130
+ ExpressionAttributeValues=expr_values
131
+ )
132
+ except ClientError as e:
133
+ logger.error(f"Error updating subscription state for {user_id}: {e}")
134
+ raise
135
+
136
+ def get_user_payment_info(self, user_id: str) -> Dict[str, Any]:
137
+ """Get user's payment-related information."""
138
+ if not self.table:
139
+ return {
140
+ "credits": 1000,
141
+ "stripe_customer_id": None,
142
+ "stripe_subscription_id": None,
143
+ "subscription_status": None
144
+ }
145
+
146
+ try:
147
+ response = self.table.get_item(Key={"user_id": user_id})
148
+ if "Item" in response:
149
+ item = response["Item"]
150
+ return {
151
+ "credits": int(item.get("credits", 0)),
152
+ "stripe_customer_id": item.get("stripe_customer_id"),
153
+ "stripe_subscription_id": item.get("stripe_subscription_id"),
154
+ "subscription_status": item.get("subscription_status")
155
+ }
156
+ return {
157
+ "credits": 0,
158
+ "stripe_customer_id": None,
159
+ "stripe_subscription_id": None,
160
+ "subscription_status": None
161
+ }
162
+ except ClientError as e:
163
+ logger.error(f"Error getting payment info for {user_id}: {e}")
164
+ return {
165
+ "credits": 0,
166
+ "stripe_customer_id": None,
167
+ "stripe_subscription_id": None,
168
+ "subscription_status": None
169
+ }
170
+
171
+ def has_unlimited_access(self, user_id: str) -> bool:
172
+ """Check if user has unlimited access via active subscription."""
173
+ info = self.get_user_payment_info(user_id)
174
+ return info.get("subscription_status") == "active"