ai-lls-lib 1.5.0rc3__py3-none-any.whl → 1.5.0rc5__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.

Potentially problematic release.


This version of ai-lls-lib might be problematic. Click here for more details.

ai_lls_lib/__init__.py CHANGED
@@ -17,7 +17,7 @@ from ai_lls_lib.core.verifier import PhoneVerifier
17
17
  from ai_lls_lib.core.processor import BulkProcessor
18
18
  from ai_lls_lib.core.cache import DynamoDBCache
19
19
 
20
- __version__ = "1.5.0-rc.3"
20
+ __version__ = "1.5.0-rc.5"
21
21
  __all__ = [
22
22
  "PhoneVerification",
23
23
  "BulkJob",
@@ -3,13 +3,45 @@ Landline Scrubber CLI entry point
3
3
  """
4
4
  import click
5
5
  import sys
6
- from ai_lls_lib.cli.commands import verify, cache, admin, test_stack, stripe
6
+ import os
7
+ from pathlib import Path
8
+ from ai_lls_lib.cli.commands import verify, cache, admin, test_stack, stripe, monitor
9
+ from ai_lls_lib.cli.env_loader import load_env_file
10
+
11
+ def load_env_files(verbose=False):
12
+ """Load .env files from ~/.lls and current directory"""
13
+ # Load from ~/.lls/.env
14
+ home_env = Path.home() / '.lls' / '.env'
15
+ if home_env.exists():
16
+ env_vars = load_env_file(home_env)
17
+ for key, value in env_vars.items():
18
+ if key not in os.environ: # Don't override existing env vars
19
+ os.environ[key] = value
20
+ if env_vars and verbose:
21
+ click.echo(f"Loaded {len(env_vars)} variables from {home_env}", err=True)
22
+
23
+ # Load from current directory .env
24
+ local_env = Path('.env')
25
+ if local_env.exists():
26
+ env_vars = load_env_file(local_env)
27
+ for key, value in env_vars.items():
28
+ if key not in os.environ: # Don't override existing env vars
29
+ os.environ[key] = value
30
+ if env_vars and verbose:
31
+ click.echo(f"Loaded {len(env_vars)} variables from {local_env}", err=True)
32
+
33
+ # Set default environment to 'prod' if not set
34
+ if 'ENVIRONMENT' not in os.environ:
35
+ os.environ['ENVIRONMENT'] = 'prod'
36
+ if verbose:
37
+ click.echo(f"Set default ENVIRONMENT=prod", err=True)
7
38
 
8
39
  @click.group()
9
40
  @click.version_option(version="0.1.0", prog_name="ai-lls")
10
41
  def cli():
11
42
  """Landline Scrubber CLI - Administrative and debugging tools"""
12
- pass
43
+ # Load environment files at startup
44
+ load_env_files()
13
45
 
14
46
  # Register command groups
15
47
  cli.add_command(verify.verify_group)
@@ -17,6 +49,7 @@ cli.add_command(cache.cache_group)
17
49
  cli.add_command(admin.admin_group)
18
50
  cli.add_command(test_stack.test_stack_group)
19
51
  cli.add_command(stripe.stripe_group)
52
+ cli.add_command(monitor.monitor_group)
20
53
 
21
54
  def main():
22
55
  """Main entry point"""
@@ -27,6 +27,7 @@ class AWSClient:
27
27
  self._sqs = None
28
28
  self._secretsmanager = None
29
29
  self._cloudformation = None
30
+ self._logs = None
30
31
 
31
32
  @property
32
33
  def dynamodb(self):
@@ -63,6 +64,13 @@ class AWSClient:
63
64
  self._cloudformation = self.session.client('cloudformation')
64
65
  return self._cloudformation
65
66
 
67
+ @property
68
+ def logs(self):
69
+ """Get CloudWatch Logs client"""
70
+ if not self._logs:
71
+ self._logs = self.session.client('logs')
72
+ return self._logs
73
+
66
74
  def get_stack_outputs(self, stack_name: str) -> Dict[str, str]:
67
75
  """Get CloudFormation stack outputs"""
68
76
  try:
@@ -0,0 +1,297 @@
1
+ """
2
+ Real-time CloudWatch log monitoring for Lambda functions
3
+ """
4
+ import click
5
+ import json
6
+ import time
7
+ from datetime import datetime, timedelta
8
+ from typing import Optional, List, Dict
9
+ from botocore.exceptions import ClientError
10
+ from ai_lls_lib.cli.aws_client import AWSClient
11
+
12
+ try:
13
+ from rich.console import Console
14
+ from rich.syntax import Syntax
15
+ from rich.table import Table
16
+ from rich.live import Live
17
+ from rich.panel import Panel
18
+ from rich.text import Text
19
+ from rich import box
20
+ HAS_RICH = True
21
+ except ImportError:
22
+ HAS_RICH = False
23
+ Console = None
24
+
25
+
26
+ def get_log_groups(aws_client: AWSClient, environment: str) -> List[str]:
27
+ """Get relevant CloudWatch log groups for the environment"""
28
+ # Map environment to stack name pattern
29
+ stack_prefix = "landline-api-staging" if environment == "staging" else "landline-api"
30
+
31
+ # Key Lambda functions to monitor
32
+ lambda_functions = [
33
+ "PhoneVerifyHandler",
34
+ "BulkUploadHandler",
35
+ "BulkProcessHandler",
36
+ "PlansHandler",
37
+ "PaymentHandler",
38
+ "UserProfileHandler"
39
+ ]
40
+
41
+ log_groups = []
42
+ for func in lambda_functions:
43
+ log_group = f"/aws/lambda/{stack_prefix}-{func}"
44
+ log_groups.append(log_group)
45
+
46
+ return log_groups
47
+
48
+
49
+ def format_log_event(event: Dict, use_rich: bool = True) -> str:
50
+ """Format a CloudWatch log event for display"""
51
+ timestamp = datetime.fromtimestamp(event.get('timestamp', 0) / 1000)
52
+ message = event.get('message', '')
53
+
54
+ # Try to parse JSON message
55
+ try:
56
+ if message.strip().startswith('{'):
57
+ parsed = json.loads(message)
58
+
59
+ # Check for external API calls
60
+ if 'external_api_call' in message.lower() or 'landlineremover.com' in message:
61
+ if use_rich and HAS_RICH:
62
+ return f"[bold green]{timestamp.strftime('%H:%M:%S')}[/bold green] [cyan]EXTERNAL API:[/cyan] {message}"
63
+ else:
64
+ return f"{timestamp.strftime('%H:%M:%S')} EXTERNAL API: {message}"
65
+
66
+ # Check for cache events
67
+ if 'cache' in message.lower():
68
+ if use_rich and HAS_RICH:
69
+ return f"[bold blue]{timestamp.strftime('%H:%M:%S')}[/bold blue] [yellow]CACHE:[/yellow] {message}"
70
+ else:
71
+ return f"{timestamp.strftime('%H:%M:%S')} CACHE: {message}"
72
+
73
+ # Check for errors
74
+ if 'error' in message.lower() or 'exception' in message.lower():
75
+ if use_rich and HAS_RICH:
76
+ return f"[bold red]{timestamp.strftime('%H:%M:%S')}[/bold red] [red]ERROR:[/red] {message}"
77
+ else:
78
+ return f"{timestamp.strftime('%H:%M:%S')} ERROR: {message}"
79
+
80
+ # Format structured logs
81
+ if use_rich and HAS_RICH:
82
+ message = json.dumps(parsed, indent=2)
83
+ except (json.JSONDecodeError, ValueError):
84
+ pass
85
+
86
+ # Default formatting
87
+ if use_rich and HAS_RICH:
88
+ return f"[dim]{timestamp.strftime('%H:%M:%S')}[/dim] {message}"
89
+ else:
90
+ return f"{timestamp.strftime('%H:%M:%S')} {message}"
91
+
92
+
93
+ def tail_logs(aws_client: AWSClient, log_groups: List[str], duration: int = 300, follow: bool = True):
94
+ """Tail CloudWatch logs in real-time"""
95
+ console = Console() if HAS_RICH else None
96
+
97
+ # Calculate start time
98
+ start_time = int((datetime.utcnow() - timedelta(seconds=duration)).timestamp() * 1000)
99
+
100
+ # Track last event time for each log group
101
+ last_event_times = {group: start_time for group in log_groups}
102
+
103
+ if console and HAS_RICH:
104
+ console.print(Panel.fit(
105
+ f"[bold cyan]Monitoring {len(log_groups)} Lambda functions[/bold cyan]\n"
106
+ f"[dim]Press Ctrl+C to stop[/dim]",
107
+ title="CloudWatch Log Monitor",
108
+ border_style="cyan"
109
+ ))
110
+ console.print()
111
+ else:
112
+ click.echo(f"Monitoring {len(log_groups)} Lambda functions")
113
+ click.echo("Press Ctrl+C to stop\n")
114
+
115
+ try:
116
+ while True:
117
+ events_found = False
118
+
119
+ for log_group in log_groups:
120
+ try:
121
+ # Use filter_log_events for real-time tailing
122
+ response = aws_client.logs.filter_log_events(
123
+ logGroupName=log_group,
124
+ startTime=last_event_times[log_group],
125
+ limit=100
126
+ )
127
+
128
+ events = response.get('events', [])
129
+
130
+ if events:
131
+ events_found = True
132
+ # Update last event time
133
+ last_event_times[log_group] = events[-1]['timestamp'] + 1
134
+
135
+ # Display log group header
136
+ if console and HAS_RICH:
137
+ console.print(f"\n[bold magenta]═══ {log_group} ═══[/bold magenta]")
138
+ else:
139
+ click.echo(f"\n=== {log_group} ===")
140
+
141
+ # Display events
142
+ for event in events:
143
+ formatted = format_log_event(event, use_rich=(console is not None))
144
+ if console and HAS_RICH:
145
+ console.print(formatted)
146
+ else:
147
+ click.echo(formatted)
148
+
149
+ except ClientError as e:
150
+ if e.response['Error']['Code'] != 'ResourceNotFoundException':
151
+ if console and HAS_RICH:
152
+ console.print(f"[yellow]Warning: {e}[/yellow]")
153
+ else:
154
+ click.echo(f"Warning: {e}", err=True)
155
+
156
+ if not follow:
157
+ break
158
+
159
+ # Sleep briefly before next poll
160
+ time.sleep(2 if events_found else 5)
161
+
162
+ except KeyboardInterrupt:
163
+ if console and HAS_RICH:
164
+ console.print("\n[yellow]Monitoring stopped[/yellow]")
165
+ else:
166
+ click.echo("\nMonitoring stopped")
167
+
168
+
169
+ def start_live_tail(aws_client: AWSClient, log_groups: List[str]):
170
+ """Start a CloudWatch Logs Live Tail session (requires AWS SDK v2)"""
171
+ console = Console() if HAS_RICH else None
172
+
173
+ try:
174
+ # Start live tail session
175
+ response = aws_client.logs.start_live_tail(
176
+ logGroupIdentifiers=log_groups,
177
+ logStreamNamePrefixes=[],
178
+ logEventFilterPattern=""
179
+ )
180
+
181
+ session_id = response.get('sessionId')
182
+ session_url = response.get('sessionUrl')
183
+
184
+ if console and HAS_RICH:
185
+ console.print(Panel.fit(
186
+ f"[bold green]Live Tail session started[/bold green]\n"
187
+ f"Session ID: {session_id}\n"
188
+ f"[dim]Note: Live Tail API requires WebSocket support[/dim]",
189
+ title="CloudWatch Live Tail",
190
+ border_style="green"
191
+ ))
192
+ else:
193
+ click.echo(f"Live Tail session started")
194
+ click.echo(f"Session ID: {session_id}")
195
+ click.echo(f"Note: Live Tail API requires WebSocket support")
196
+
197
+ # Note: Full WebSocket implementation would be needed here
198
+ # For now, fall back to filter_log_events polling
199
+ click.echo("\nFalling back to standard log polling...")
200
+ tail_logs(aws_client, log_groups, duration=300, follow=True)
201
+
202
+ except ClientError as e:
203
+ if 'start_live_tail' in str(e):
204
+ # Fall back to regular tailing if Live Tail not available
205
+ if console and HAS_RICH:
206
+ console.print("[yellow]Live Tail API not available, using standard polling[/yellow]")
207
+ else:
208
+ click.echo("Live Tail API not available, using standard polling")
209
+ tail_logs(aws_client, log_groups, duration=300, follow=True)
210
+ else:
211
+ raise
212
+
213
+
214
+ @click.group(name="monitor")
215
+ def monitor_group():
216
+ """Monitor CloudWatch logs in real-time"""
217
+ pass
218
+
219
+
220
+ @monitor_group.command()
221
+ @click.option('--staging', is_flag=True, help='Monitor staging environment (default: production)')
222
+ @click.option('--production', is_flag=True, help='Monitor production environment')
223
+ @click.option('--duration', default=300, help='How far back to start (seconds, default: 300)')
224
+ @click.option('--follow', is_flag=True, default=True, help='Follow logs in real-time (default: true)')
225
+ @click.option('--filter', 'filter_pattern', help='CloudWatch filter pattern to apply')
226
+ @click.option('--profile', help='AWS profile to use')
227
+ @click.option('--region', default='us-east-1', help='AWS region')
228
+ def logs(staging, production, duration, follow, filter_pattern, profile, region):
229
+ """Tail CloudWatch logs for Lambda functions
230
+
231
+ Examples:
232
+ ai-lls monitor logs --staging
233
+ ai-lls monitor logs --production
234
+ ai-lls monitor logs --staging --filter "ERROR"
235
+ ai-lls monitor logs --duration 600 --follow
236
+ """
237
+ # Determine environment
238
+ if staging and production:
239
+ raise click.ClickException("Cannot specify both --staging and --production")
240
+
241
+ environment = "staging" if staging else "production"
242
+
243
+ # Show warning if Rich not installed
244
+ if not HAS_RICH:
245
+ click.echo("Warning: Install 'rich' library for better output formatting", err=True)
246
+ click.echo("Run: pip install rich", err=True)
247
+ click.echo()
248
+
249
+ # Initialize AWS client
250
+ aws_client = AWSClient(region=region, profile=profile)
251
+
252
+ # Get log groups
253
+ log_groups = get_log_groups(aws_client, environment)
254
+
255
+ click.echo(f"Monitoring {environment} environment...")
256
+ click.echo(f"Log groups: {', '.join([g.split('/')[-1] for g in log_groups])}")
257
+ click.echo()
258
+
259
+ # Start tailing logs
260
+ tail_logs(aws_client, log_groups, duration=duration, follow=follow)
261
+
262
+
263
+ @monitor_group.command()
264
+ @click.option('--staging', is_flag=True, help='Monitor staging environment')
265
+ @click.option('--production', is_flag=True, help='Monitor production environment')
266
+ @click.option('--profile', help='AWS profile to use')
267
+ @click.option('--region', default='us-east-1', help='AWS region')
268
+ def live(staging, production, profile, region):
269
+ """Start CloudWatch Logs Live Tail session (experimental)
270
+
271
+ Uses the CloudWatch Logs Live Tail API for real-time streaming.
272
+ Falls back to standard polling if Live Tail is not available.
273
+
274
+ Examples:
275
+ ai-lls monitor live --staging
276
+ ai-lls monitor live --production
277
+ """
278
+ # Determine environment
279
+ if staging and production:
280
+ raise click.ClickException("Cannot specify both --staging and --production")
281
+
282
+ environment = "staging" if staging else "production"
283
+
284
+ # Initialize AWS client
285
+ aws_client = AWSClient(region=region, profile=profile)
286
+
287
+ # Get log groups
288
+ log_groups = get_log_groups(aws_client, environment)
289
+
290
+ click.echo(f"Starting Live Tail for {environment} environment...")
291
+
292
+ # Start live tail
293
+ start_live_tail(aws_client, log_groups)
294
+
295
+
296
+ # Export the group for registration
297
+ __all__ = ['monitor_group']
@@ -7,6 +7,7 @@ from datetime import datetime
7
7
  from ai_lls_lib.core.verifier import PhoneVerifier
8
8
  from ai_lls_lib.core.cache import DynamoDBCache
9
9
  from ai_lls_lib.cli.aws_client import AWSClient
10
+ from ai_lls_lib.providers import StubProvider, ExternalAPIProvider
10
11
 
11
12
  @click.group(name="verify")
12
13
  def verify_group():
@@ -15,72 +16,160 @@ def verify_group():
15
16
 
16
17
  @verify_group.command(name="phone")
17
18
  @click.argument("phone_number")
18
- @click.option("--stack", default="landline-api", help="CloudFormation stack name")
19
- @click.option("--skip-cache", is_flag=True, help="Skip cache lookup")
20
- @click.option("--profile", help="AWS profile to use")
21
- @click.option("--region", help="AWS region")
22
- def verify_phone(phone_number, stack, skip_cache, profile, region):
23
- """Verify a single phone number"""
24
- aws = AWSClient(region=region, profile=profile)
25
-
26
- # Get cache table name from stack
27
- cache_table = aws.get_table_name(stack, "PhoneCacheTable")
28
- click.echo(f"Using cache table: {cache_table}")
29
-
30
- # Initialize cache and verifier
31
- cache = DynamoDBCache(table_name=cache_table)
32
- verifier = PhoneVerifier(cache=cache)
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)
33
72
 
34
73
  try:
35
- if skip_cache:
36
- # Force fresh lookup
37
- normalized = verifier.normalize_phone(phone_number)
38
- line_type = verifier._check_line_type(normalized)
39
- dnc = verifier._check_dnc(normalized)
40
- result = {
41
- "phone_number": normalized,
42
- "line_type": line_type,
43
- "dnc": dnc,
44
- "cached": False,
45
- "verified_at": datetime.utcnow().isoformat(),
46
- "source": "cli-direct"
47
- }
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))
48
96
  else:
49
- result = verifier.verify(phone_number)
50
- result = result.dict() if hasattr(result, 'dict') else result
51
-
52
- # Display results
53
- click.echo("\n" + "=" * 40)
54
- click.echo(f"Phone: {result['phone_number']}")
55
- click.echo(f"Line Type: {result['line_type']}")
56
- click.echo(f"DNC Status: {'Yes' if result['dnc'] else 'No'}")
57
- click.echo(f"From Cache: {'Yes' if result.get('cached') else 'No'}")
58
- click.echo(f"Verified: {result.get('verified_at', 'Unknown')}")
59
- click.echo("=" * 40)
60
-
61
- if click.confirm("\nShow JSON output?"):
62
- click.echo(json.dumps(result, indent=2, default=str))
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']}")
63
103
 
64
104
  except ValueError as e:
65
- click.echo(f"Error: {e}", err=True)
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)
66
118
  except Exception as e:
67
- click.echo(f"Verification failed: {e}", err=True)
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)
68
129
 
69
130
  @verify_group.command(name="bulk")
70
131
  @click.argument("csv_file", type=click.Path(exists=True))
71
132
  @click.option("--output", "-o", help="Output CSV file")
72
- @click.option("--stack", default="landline-api", help="CloudFormation stack name")
73
- @click.option("--profile", help="AWS profile to use")
74
- @click.option("--region", help="AWS region")
75
- def verify_bulk(csv_file, output, stack, profile, region):
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):
76
140
  """Process a CSV file for bulk verification"""
77
141
  from ai_lls_lib.core.processor import BulkProcessor
78
142
 
79
- aws = AWSClient(region=region, profile=profile)
80
- cache_table = aws.get_table_name(stack, "PhoneCacheTable")
81
-
82
- cache = DynamoDBCache(table_name=cache_table)
83
- verifier = PhoneVerifier(cache=cache)
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)
84
173
  processor = BulkProcessor(verifier=verifier)
85
174
 
86
175
  click.echo(f"Processing {csv_file}...")
@@ -100,7 +189,8 @@ def verify_bulk(csv_file, output, stack, profile, region):
100
189
  click.echo(f" Mobile: {mobile_count}")
101
190
  click.echo(f" Landline: {landline_count}")
102
191
  click.echo(f" On DNC: {dnc_count}")
103
- click.echo(f" From Cache: {cached_count}")
192
+ if cache:
193
+ click.echo(f" From Cache: {cached_count}")
104
194
 
105
195
  # Generate output if requested
106
196
  if output:
@@ -109,3 +199,6 @@ def verify_bulk(csv_file, output, stack, profile, region):
109
199
 
110
200
  except Exception as e:
111
201
  click.echo(f"Bulk processing failed: {e}", err=True)
202
+ if verbose:
203
+ import traceback
204
+ click.echo(traceback.format_exc(), err=True)
@@ -6,7 +6,7 @@ from typing import Optional, Dict
6
6
  import click
7
7
 
8
8
 
9
- def load_env_file(env_path: Path) -> Dict[str, str]:
9
+ def load_env_file(env_path: Path, verbose: bool = False) -> Dict[str, str]:
10
10
  """Load environment variables from a .env file."""
11
11
  env_vars = {}
12
12
  if env_path.exists():
@@ -20,7 +20,8 @@ def load_env_file(env_path: Path) -> Dict[str, str]:
20
20
  value = value.strip().strip('"').strip("'")
21
21
  env_vars[key.strip()] = value
22
22
  except Exception as e:
23
- click.echo(f"Warning: Could not read {env_path}: {e}", err=True)
23
+ if verbose:
24
+ click.echo(f"Warning: Could not read {env_path}: {e}", err=True)
24
25
  return env_vars
25
26
 
26
27
 
@@ -8,7 +8,7 @@ import phonenumbers
8
8
  from aws_lambda_powertools import Logger
9
9
  from .models import PhoneVerification, LineType, VerificationSource
10
10
  from .cache import DynamoDBCache
11
- from ..providers import VerificationProvider, StubProvider
11
+ from ..providers import VerificationProvider, StubProvider, ExternalAPIProvider
12
12
 
13
13
  logger = Logger()
14
14
 
@@ -16,16 +16,16 @@ logger = Logger()
16
16
  class PhoneVerifier:
17
17
  """Verifies phone numbers for line type and DNC status"""
18
18
 
19
- def __init__(self, cache: DynamoDBCache, provider: Optional[VerificationProvider] = None):
19
+ def __init__(self, cache: Optional[DynamoDBCache] = None, provider: Optional[VerificationProvider] = None):
20
20
  """
21
21
  Initialize phone verifier.
22
22
 
23
23
  Args:
24
- cache: DynamoDB cache for storing results
25
- provider: Verification provider (defaults to StubProvider)
24
+ cache: Optional DynamoDB cache for storing results
25
+ provider: Verification provider (defaults to ExternalAPIProvider)
26
26
  """
27
27
  self.cache = cache
28
- self.provider = provider or StubProvider()
28
+ self.provider = provider or ExternalAPIProvider()
29
29
 
30
30
  def normalize_phone(self, phone: str) -> str:
31
31
  """Normalize phone to E.164 format"""
@@ -45,10 +45,11 @@ class PhoneVerifier:
45
45
  """Verify phone number for line type and DNC status"""
46
46
  normalized = self.normalize_phone(phone)
47
47
 
48
- # Check cache first
49
- cached = self.cache.get(normalized)
50
- if cached:
51
- return cached
48
+ # Check cache first if available
49
+ if self.cache:
50
+ cached = self.cache.get(normalized)
51
+ if cached:
52
+ return cached
52
53
 
53
54
  # Use provider to verify
54
55
  line_type, dnc_status = self.provider.verify_phone(normalized)
@@ -62,8 +63,13 @@ class PhoneVerifier:
62
63
  source=VerificationSource.API
63
64
  )
64
65
 
65
- # Store in cache
66
- self.cache.set(normalized, result)
66
+ # Store in cache if available
67
+ if self.cache:
68
+ try:
69
+ self.cache.set(normalized, result)
70
+ except Exception as e:
71
+ logger.warning(f"Failed to cache result: {e}")
72
+ # Continue without caching - don't fail the verification
67
73
 
68
74
  return result
69
75
 
@@ -3,5 +3,6 @@ Verification providers for phone number checking
3
3
  """
4
4
  from .base import VerificationProvider
5
5
  from .stub import StubProvider
6
+ from .external import ExternalAPIProvider
6
7
 
7
- __all__ = ["VerificationProvider", "StubProvider"]
8
+ __all__ = ["VerificationProvider", "StubProvider", "ExternalAPIProvider"]
@@ -13,29 +13,45 @@ logger = Logger()
13
13
  class ExternalAPIProvider:
14
14
  """
15
15
  Production provider that calls external verification APIs.
16
+ Uses landlineremover.com which returns both line type and DNC status in a single call.
16
17
  """
17
18
 
18
19
  def __init__(
19
20
  self,
20
- phone_api_key: Optional[str] = None,
21
- dnc_api_key: Optional[str] = None,
21
+ api_key: Optional[str] = None,
22
22
  timeout: float = 10.0
23
23
  ):
24
24
  """
25
25
  Initialize external API provider.
26
26
 
27
27
  Args:
28
- phone_api_key: API key for phone line type verification
29
- dnc_api_key: API key for DNC list checking
28
+ api_key: API key for landlineremover.com (if not provided, uses environment)
30
29
  timeout: HTTP request timeout in seconds
31
30
  """
32
- self.phone_api_key = phone_api_key or os.environ.get("PHONE_VERIFY_API_KEY", "")
33
- self.dnc_api_key = dnc_api_key or os.environ.get("DNC_API_KEY", "")
31
+ # Get environment-specific API key
32
+ # Default to 'prod' if not specified
33
+ env = os.environ.get("ENVIRONMENT", "prod").upper()
34
+
35
+ # Try environment-specific key first, then fall back to provided key
36
+ self.api_key = (
37
+ api_key or
38
+ os.environ.get(f"{env}_LANDLINE_API_KEY") or
39
+ os.environ.get("PHONE_VERIFY_API_KEY", "")
40
+ )
41
+
42
+ if not self.api_key:
43
+ logger.warning(f"No API key found for environment {env}")
44
+
45
+ self.api_url = "https://app.landlineremover.com/api/check-number"
46
+ self.timeout = timeout
34
47
  self.http_client = httpx.Client(timeout=timeout)
35
48
 
36
49
  def verify_phone(self, phone: str) -> Tuple[LineType, bool]:
37
50
  """
38
- Verify phone using external APIs.
51
+ Verify phone using landlineremover.com API.
52
+
53
+ This API returns both line type and DNC status in a single call,
54
+ which is more efficient than making two separate API calls.
39
55
 
40
56
  Args:
41
57
  phone: E.164 formatted phone number
@@ -47,41 +63,89 @@ class ExternalAPIProvider:
47
63
  httpx.HTTPError: For API communication errors
48
64
  ValueError: For invalid responses
49
65
  """
50
- line_type = self._check_line_type(phone)
51
- is_dnc = self._check_dnc(phone)
52
- return line_type, is_dnc
53
-
54
- def _check_line_type(self, phone: str) -> LineType:
55
- """
56
- Check line type via external API.
57
-
58
- TODO: Implement actual API call
59
- - Use self.phone_api_key for authentication
60
- - Parse API response
61
- - Map to LineType enum
66
+ logger.debug(f"Verifying phone {phone[:6]}*** via external API")
67
+
68
+ if not self.api_key:
69
+ raise ValueError("API key not configured")
70
+
71
+ try:
72
+ # Make single API call that returns both line type and DNC status
73
+ # The API may redirect, so we need to follow redirects
74
+ response = self.http_client.get(
75
+ self.api_url,
76
+ params={
77
+ "apikey": self.api_key,
78
+ "number": phone
79
+ },
80
+ follow_redirects=True
81
+ )
82
+
83
+ # Raise for HTTP errors
84
+ response.raise_for_status()
85
+
86
+ # Parse response
87
+ json_response = response.json()
88
+
89
+ # Extract data from response wrapper
90
+ if "data" in json_response:
91
+ data = json_response["data"]
92
+ else:
93
+ data = json_response
94
+
95
+ # Map line type from API response
96
+ line_type = self._map_line_type(data)
97
+
98
+ # Map DNC status - API uses "DNCType" field
99
+ # Values can be "dnc", "clean", etc.
100
+ dnc_type = data.get("DNCType", data.get("dnc_type", "")).lower()
101
+ is_dnc = dnc_type != "clean" and dnc_type != ""
102
+
103
+ logger.debug(
104
+ f"Verification complete for {phone[:6]}***",
105
+ extra={
106
+ "line_type": line_type.value,
107
+ "is_dnc": is_dnc,
108
+ "dnc_type": dnc_type
109
+ }
110
+ )
111
+
112
+ return line_type, is_dnc
113
+
114
+ except httpx.HTTPStatusError as e:
115
+ logger.error(f"API error: {e.response.status_code} - {e.response.text}")
116
+ raise ValueError(f"API request failed with status {e.response.status_code}")
117
+ except httpx.RequestError as e:
118
+ logger.error(f"Network error: {str(e)}")
119
+ raise ValueError(f"Network error during API call: {str(e)}")
120
+ except Exception as e:
121
+ logger.error(f"Unexpected error during verification: {str(e)}")
122
+ raise
123
+
124
+ def _map_line_type(self, data: dict) -> LineType:
62
125
  """
63
- logger.info(f"External line type check for {phone[:6]}***")
126
+ Map API response to LineType enum.
64
127
 
65
- # Placeholder implementation
66
- # In production, this would make an actual API call
67
- raise NotImplementedError("External line type API not yet configured")
128
+ Args:
129
+ data: API response dictionary
68
130
 
69
- def _check_dnc(self, phone: str) -> bool:
131
+ Returns:
132
+ LineType enum value
70
133
  """
71
- Check DNC status via external API.
134
+ # API uses "LineType" (capitalized) field
135
+ line_type_str = data.get("LineType", data.get("line_type", "")).lower()
72
136
 
73
- TODO: Implement actual API call
74
- - Use self.dnc_api_key for authentication
75
- - Parse API response
76
- - Return boolean status
77
- """
78
- logger.info(f"External DNC check for {phone[:6]}***")
137
+ # Map common line types
138
+ line_type_map = {
139
+ "mobile": LineType.MOBILE,
140
+ "landline": LineType.LANDLINE,
141
+ "voip": LineType.VOIP,
142
+ "wireless": LineType.MOBILE, # Some APIs return "wireless" for mobile
143
+ "fixed": LineType.LANDLINE, # Some APIs return "fixed" for landline
144
+ }
79
145
 
80
- # Placeholder implementation
81
- # In production, this would make an actual API call
82
- raise NotImplementedError("External DNC API not yet configured")
146
+ return line_type_map.get(line_type_str, LineType.UNKNOWN)
83
147
 
84
148
  def __del__(self):
85
149
  """Cleanup HTTP client"""
86
150
  if hasattr(self, 'http_client'):
87
- self.http_client.close()
151
+ self.http_client.close()
@@ -32,7 +32,7 @@ class StubProvider:
32
32
  Returns:
33
33
  Tuple of (line_type, is_on_dnc_list)
34
34
  """
35
- logger.info(f"Stub verification for {phone[:6]}***")
35
+ logger.debug(f"Stub verification for {phone[:6]}***")
36
36
 
37
37
  last_digit = phone[-1] if phone else '5'
38
38
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-lls-lib
3
- Version: 1.5.0rc3
3
+ Version: 1.5.0rc5
4
4
  Summary: Landline Scrubber core library - phone verification and DNC checking
5
5
  Author: LandlineScrubber Team
6
6
  Requires-Python: >=3.12,<4.0
@@ -186,6 +186,33 @@ ai-lls test-stack test
186
186
  ai-lls test-stack delete
187
187
  ```
188
188
 
189
+ ### CloudWatch Log Monitoring
190
+ ```bash
191
+ # Monitor staging environment logs in real-time
192
+ ai-lls monitor logs --staging
193
+
194
+ # Monitor production environment logs
195
+ ai-lls monitor logs --production
196
+
197
+ # Monitor with custom duration (look back 10 minutes)
198
+ ai-lls monitor logs --staging --duration 600
199
+
200
+ # Filter logs for errors only
201
+ ai-lls monitor logs --staging --filter "ERROR"
202
+
203
+ # Use specific AWS profile
204
+ ai-lls monitor logs --staging --profile myprofile
205
+
206
+ # Experimental: Use CloudWatch Logs Live Tail API
207
+ ai-lls monitor live --staging
208
+ ```
209
+
210
+ The monitor command provides real-time log streaming from Lambda functions with:
211
+ - Color-coded output for different event types (external API calls, cache events, errors)
212
+ - Support for multiple log groups simultaneously
213
+ - Rich formatting when the `rich` library is installed
214
+ - Automatic detection of external API calls to landlineremover.com
215
+
189
216
  ## Project Structure
190
217
 
191
218
  ```
@@ -1,33 +1,34 @@
1
- ai_lls_lib/__init__.py,sha256=pGHYFGC4SkH9TaaSFWG7qyjwC63DGMx2zZOvHaY5diI,734
1
+ ai_lls_lib/__init__.py,sha256=XPFbxbxGbOhVzGxkFlvmuENvWHUK2AnWUOpRcu29gT8,734
2
2
  ai_lls_lib/auth/__init__.py,sha256=ZjwlfTvYUo9A9BaLinaXW2elxYectScmu0XtKAppSFs,198
3
3
  ai_lls_lib/auth/context_parser.py,sha256=AWyGWejrFR0jJ7fMhajdA1ABo656Qlvs2YAzz-IfTrs,2290
4
4
  ai_lls_lib/cli/__init__.py,sha256=HyjnIBGlrbTlHQ82n_UppjtJbZl4_7QsnAgRqNlXKMU,77
5
- ai_lls_lib/cli/__main__.py,sha256=wsSjkPdrkqqmcxYZr-_CWmOd1YQAN4fo3cey-P4BQxI,746
6
- ai_lls_lib/cli/aws_client.py,sha256=lqihYQQtpx8Xb5HcVTbexl1pZdlYYdozPLDEGcen27Q,4067
5
+ ai_lls_lib/cli/__main__.py,sha256=OLpqz78s2nAtib7soqI-WQ2_V9aqa9Cd4NRwvso0IbM,2120
6
+ ai_lls_lib/cli/aws_client.py,sha256=yJ_seXyAOSy1xFqUHD8CScnN5un18b2412a7pUG_UQs,4283
7
7
  ai_lls_lib/cli/commands/__init__.py,sha256=yi0bdkZSPIXY33apvJRcuK410-Ta0-_-n31MQYuL_aU,31
8
8
  ai_lls_lib/cli/commands/admin.py,sha256=JlFljbblVWJkJ2z2FIM6P0fy3Pmc0VqG62Y3POtvRFw,6861
9
9
  ai_lls_lib/cli/commands/cache.py,sha256=1Vm3m5vXJkRagNmQXpT8_YEsQEv92veJUFwA_v9FPb0,5822
10
+ ai_lls_lib/cli/commands/monitor.py,sha256=TLbf5Q_viwF7q_aPXo9XenzY4W0R1bjO68Iu8KlzvoE,11315
10
11
  ai_lls_lib/cli/commands/stripe.py,sha256=_aRsz6Z7BN-S9_07efUovXwsbL-eCE-raw1uBOkDP5g,15276
11
12
  ai_lls_lib/cli/commands/test_stack.py,sha256=FK6ITOek7j4e0QiSNTv_taLghNnR8d_kWG02cl0A3rY,7594
12
- ai_lls_lib/cli/commands/verify.py,sha256=a63KSXd0XfJq2LFEt3RrGmQ7SXK40pQhS-oaPo8SCdo,4349
13
- ai_lls_lib/cli/env_loader.py,sha256=dIgD5PC3u4MW2Qs_MHRJcUeoA0Wwe7e8nnitEe26xrY,4208
13
+ ai_lls_lib/cli/commands/verify.py,sha256=8rTg8xbdQbJdebDy5CT7HFcJ-ODFZdAOAo2JX82ARJs,8522
14
+ ai_lls_lib/cli/env_loader.py,sha256=ZL8GykQw8gf--aSMvIR4pdfQYKIA8bbJDWWNUpxZYsI,4260
14
15
  ai_lls_lib/core/__init__.py,sha256=ATh5sGhhB9g5Sd2H0kRqoCA0hxqe6UWonlPq2Wnl_i8,39
15
16
  ai_lls_lib/core/cache.py,sha256=4HSVaRvv_wueLV30fVbTk_KWZmXjR2mBrGl7UNT_5kQ,3817
16
17
  ai_lls_lib/core/models.py,sha256=jzvc-SlP-nLgvkom9hj-TwKHxBePbEfC9xL_Tt1Uh4Y,2458
17
18
  ai_lls_lib/core/processor.py,sha256=8PI_j1ZMOD1hru3PyV5QMmMx7Em8X0ivq8prMXwait4,10269
18
- ai_lls_lib/core/verifier.py,sha256=dM5DkVudc29Evsd-QSkX9cQEM8EeUJQlCYxImDIJ7Uc,2800
19
+ ai_lls_lib/core/verifier.py,sha256=mQBgzV-vZiiKGzeeVl3TwfOWGrYTeB_zQ6wpaI07u54,3147
19
20
  ai_lls_lib/payment/__init__.py,sha256=uQwWZ2tw-cxS06hbWiKu8ubxzzYCmxeKBQQU7m_mRX4,308
20
21
  ai_lls_lib/payment/credit_manager.py,sha256=DH_t8AMx74vexegoTD3UkFNA1XuomIQWOZu9VoJMO3E,7340
21
22
  ai_lls_lib/payment/models.py,sha256=LxGop5e1hhUmBCpnzJwU-dDQSLHy6uM6gSFYYo2V_FI,3609
22
23
  ai_lls_lib/payment/stripe_manager.py,sha256=cSJvRWk1OT16SlDh5BMyoEQD8TU0IzeP0TaL8e2U4zA,18423
23
24
  ai_lls_lib/payment/webhook_processor.py,sha256=bAf8oUBzvCsb1stzKZ-cjZs02T-RJl-MeVnZmnnj5UM,8980
24
- ai_lls_lib/providers/__init__.py,sha256=xDh9KY3pgDef59CqQlAR215bawxdEY2OP-BRRT9ru_c,186
25
+ ai_lls_lib/providers/__init__.py,sha256=dBjXJHn2nnA8KUuLYkgCJjZZzWeYAPPd6IJHUd7b-Kk,252
25
26
  ai_lls_lib/providers/base.py,sha256=WSnSkHtRvwpX8QtGINjO-EWsZL0augp5E8M1DPwpf00,709
26
- ai_lls_lib/providers/external.py,sha256=T8P_XtLNCXZBB0DzK4fradNBoiapIbpQ09Ai4DihpMM,2710
27
- ai_lls_lib/providers/stub.py,sha256=n8WCcuqcv62DnIO-WPhuold-AC3yrxjQAboJ4CO2n6U,1198
27
+ ai_lls_lib/providers/external.py,sha256=MKu3BZV8gfSqV3lNcF9Vt4UmKIJixQ5Q7FI2wFeSBz0,5075
28
+ ai_lls_lib/providers/stub.py,sha256=XXUObkIb3hs7_fYMA9mlLh1llJUGIgqQHVjgZHZl1T8,1199
28
29
  ai_lls_lib/testing/__init__.py,sha256=NytBz7T0YtNLDArRYHsczaeopIQmmBeV21-XXUprdeY,42
29
30
  ai_lls_lib/testing/fixtures.py,sha256=OC1huorIqUndePoUTfgoU-aDvsWAFJqmQ6fGDjH9E14,3414
30
- ai_lls_lib-1.5.0rc3.dist-info/METADATA,sha256=G3VICOTTMbTEJ5AvC6Iz0ezj0jerMMAOQS8ynl4Z91I,7302
31
- ai_lls_lib-1.5.0rc3.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
32
- ai_lls_lib-1.5.0rc3.dist-info/entry_points.txt,sha256=Pi0V_HBViEKGFbNQKatl5lhhnHHBXlxaom-5gH9gXZ0,55
33
- ai_lls_lib-1.5.0rc3.dist-info/RECORD,,
31
+ ai_lls_lib-1.5.0rc5.dist-info/METADATA,sha256=BmJasSh-TbAwCxGegpTRVrw2Cb8BCDKe5zXhJjW34rs,8175
32
+ ai_lls_lib-1.5.0rc5.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
33
+ ai_lls_lib-1.5.0rc5.dist-info/entry_points.txt,sha256=Pi0V_HBViEKGFbNQKatl5lhhnHHBXlxaom-5gH9gXZ0,55
34
+ ai_lls_lib-1.5.0rc5.dist-info/RECORD,,