ai-lls-lib 1.4.0rc2__py3-none-any.whl → 1.4.0rc4__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.
Files changed (34) hide show
  1. ai_lls_lib/__init__.py +1 -1
  2. ai_lls_lib/auth/__init__.py +4 -4
  3. ai_lls_lib/auth/context_parser.py +68 -68
  4. ai_lls_lib/cli/__init__.py +3 -3
  5. ai_lls_lib/cli/__main__.py +30 -30
  6. ai_lls_lib/cli/aws_client.py +115 -115
  7. ai_lls_lib/cli/commands/__init__.py +3 -3
  8. ai_lls_lib/cli/commands/admin.py +174 -174
  9. ai_lls_lib/cli/commands/cache.py +142 -142
  10. ai_lls_lib/cli/commands/stripe.py +377 -377
  11. ai_lls_lib/cli/commands/test_stack.py +216 -216
  12. ai_lls_lib/cli/commands/verify.py +111 -111
  13. ai_lls_lib/cli/env_loader.py +122 -122
  14. ai_lls_lib/core/__init__.py +3 -3
  15. ai_lls_lib/core/cache.py +106 -106
  16. ai_lls_lib/core/models.py +77 -77
  17. ai_lls_lib/core/processor.py +295 -295
  18. ai_lls_lib/core/verifier.py +84 -84
  19. ai_lls_lib/payment/__init__.py +13 -13
  20. ai_lls_lib/payment/credit_manager.py +186 -193
  21. ai_lls_lib/payment/models.py +102 -102
  22. ai_lls_lib/payment/stripe_manager.py +487 -487
  23. ai_lls_lib/payment/webhook_processor.py +215 -215
  24. ai_lls_lib/providers/__init__.py +7 -7
  25. ai_lls_lib/providers/base.py +28 -28
  26. ai_lls_lib/providers/external.py +87 -87
  27. ai_lls_lib/providers/stub.py +48 -48
  28. ai_lls_lib/testing/__init__.py +3 -3
  29. ai_lls_lib/testing/fixtures.py +104 -104
  30. {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/METADATA +1 -1
  31. ai_lls_lib-1.4.0rc4.dist-info/RECORD +33 -0
  32. ai_lls_lib-1.4.0rc2.dist-info/RECORD +0 -33
  33. {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/WHEEL +0 -0
  34. {ai_lls_lib-1.4.0rc2.dist-info → ai_lls_lib-1.4.0rc4.dist-info}/entry_points.txt +0 -0
@@ -1,122 +1,122 @@
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) -> 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
- click.echo(f"Warning: Could not read {env_path}: {e}", err=True)
24
- return env_vars
25
-
26
-
27
- def load_environment_config() -> Dict[str, str]:
28
- """
29
- Load environment variables from multiple sources in order:
30
- 1. ~/.lls/.env (user global config)
31
- 2. ./.env (project local config)
32
- 3. System environment variables (highest priority)
33
-
34
- Returns merged dictionary with system env taking precedence.
35
- """
36
- env_vars = {}
37
-
38
- # Load from ~/.lls/.env
39
- home_env = Path.home() / '.lls' / '.env'
40
- if home_env.exists():
41
- home_vars = load_env_file(home_env)
42
- env_vars.update(home_vars)
43
- click.echo(f"Loaded {len(home_vars)} variables from {home_env}", err=True)
44
-
45
- # Load from ./.env
46
- local_env = Path('.env')
47
- if local_env.exists():
48
- local_vars = load_env_file(local_env)
49
- env_vars.update(local_vars)
50
- click.echo(f"Loaded {len(local_vars)} variables from {local_env}", err=True)
51
-
52
- # System environment variables override file-based ones
53
- env_vars.update(os.environ)
54
-
55
- return env_vars
56
-
57
-
58
- def get_stripe_key(environment: str, env_vars: Optional[Dict[str, str]] = None) -> Optional[str]:
59
- """
60
- Get Stripe API key for the specified environment.
61
-
62
- Looks for keys in this order:
63
- 1. STAGING_STRIPE_SECRET_KEY or PROD_STRIPE_SECRET_KEY (based on environment)
64
- 2. STRIPE_SECRET_KEY (fallback)
65
-
66
- Args:
67
- environment: 'staging' or 'production'
68
- env_vars: Optional pre-loaded environment variables
69
-
70
- Returns:
71
- Stripe API key or None if not found
72
- """
73
- if env_vars is None:
74
- env_vars = load_environment_config()
75
-
76
- # Map environment names to prefixes
77
- env_prefix = {
78
- 'staging': 'STAGING',
79
- 'production': 'PROD'
80
- }.get(environment, environment.upper())
81
-
82
- # Try environment-specific key first
83
- env_key = f"{env_prefix}_STRIPE_SECRET_KEY"
84
- if env_key in env_vars:
85
- return env_vars[env_key]
86
-
87
- # Fall back to generic key
88
- if 'STRIPE_SECRET_KEY' in env_vars:
89
- return env_vars['STRIPE_SECRET_KEY']
90
-
91
- return None
92
-
93
-
94
- def get_env_variable(key: str, environment: Optional[str] = None,
95
- env_vars: Optional[Dict[str, str]] = None) -> Optional[str]:
96
- """
97
- Get an environment variable, optionally with environment prefix.
98
-
99
- Args:
100
- key: Variable name (e.g., 'API_URL')
101
- environment: Optional environment ('staging' or 'production')
102
- env_vars: Optional pre-loaded environment variables
103
-
104
- Returns:
105
- Variable value or None if not found
106
- """
107
- if env_vars is None:
108
- env_vars = load_environment_config()
109
-
110
- if environment:
111
- env_prefix = {
112
- 'staging': 'STAGING',
113
- 'production': 'PROD'
114
- }.get(environment, environment.upper())
115
-
116
- # Try environment-specific key first
117
- env_key = f"{env_prefix}_{key}"
118
- if env_key in env_vars:
119
- return env_vars[env_key]
120
-
121
- # Fall back to non-prefixed key
122
- return env_vars.get(key)
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) -> 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
+ click.echo(f"Warning: Could not read {env_path}: {e}", err=True)
24
+ return env_vars
25
+
26
+
27
+ def load_environment_config() -> Dict[str, str]:
28
+ """
29
+ Load environment variables from multiple sources in order:
30
+ 1. ~/.lls/.env (user global config)
31
+ 2. ./.env (project local config)
32
+ 3. System environment variables (highest priority)
33
+
34
+ Returns merged dictionary with system env taking precedence.
35
+ """
36
+ env_vars = {}
37
+
38
+ # Load from ~/.lls/.env
39
+ home_env = Path.home() / '.lls' / '.env'
40
+ if home_env.exists():
41
+ home_vars = load_env_file(home_env)
42
+ env_vars.update(home_vars)
43
+ click.echo(f"Loaded {len(home_vars)} variables from {home_env}", err=True)
44
+
45
+ # Load from ./.env
46
+ local_env = Path('.env')
47
+ if local_env.exists():
48
+ local_vars = load_env_file(local_env)
49
+ env_vars.update(local_vars)
50
+ click.echo(f"Loaded {len(local_vars)} variables from {local_env}", err=True)
51
+
52
+ # System environment variables override file-based ones
53
+ env_vars.update(os.environ)
54
+
55
+ return env_vars
56
+
57
+
58
+ def get_stripe_key(environment: str, env_vars: Optional[Dict[str, str]] = None) -> Optional[str]:
59
+ """
60
+ Get Stripe API key for the specified environment.
61
+
62
+ Looks for keys in this order:
63
+ 1. STAGING_STRIPE_SECRET_KEY or PROD_STRIPE_SECRET_KEY (based on environment)
64
+ 2. STRIPE_SECRET_KEY (fallback)
65
+
66
+ Args:
67
+ environment: 'staging' or 'production'
68
+ env_vars: Optional pre-loaded environment variables
69
+
70
+ Returns:
71
+ Stripe API key or None if not found
72
+ """
73
+ if env_vars is None:
74
+ env_vars = load_environment_config()
75
+
76
+ # Map environment names to prefixes
77
+ env_prefix = {
78
+ 'staging': 'STAGING',
79
+ 'production': 'PROD'
80
+ }.get(environment, environment.upper())
81
+
82
+ # Try environment-specific key first
83
+ env_key = f"{env_prefix}_STRIPE_SECRET_KEY"
84
+ if env_key in env_vars:
85
+ return env_vars[env_key]
86
+
87
+ # Fall back to generic key
88
+ if 'STRIPE_SECRET_KEY' in env_vars:
89
+ return env_vars['STRIPE_SECRET_KEY']
90
+
91
+ return None
92
+
93
+
94
+ def get_env_variable(key: str, environment: Optional[str] = None,
95
+ env_vars: Optional[Dict[str, str]] = None) -> Optional[str]:
96
+ """
97
+ Get an environment variable, optionally with environment prefix.
98
+
99
+ Args:
100
+ key: Variable name (e.g., 'API_URL')
101
+ environment: Optional environment ('staging' or 'production')
102
+ env_vars: Optional pre-loaded environment variables
103
+
104
+ Returns:
105
+ Variable value or None if not found
106
+ """
107
+ if env_vars is None:
108
+ env_vars = load_environment_config()
109
+
110
+ if environment:
111
+ env_prefix = {
112
+ 'staging': 'STAGING',
113
+ 'production': 'PROD'
114
+ }.get(environment, environment.upper())
115
+
116
+ # Try environment-specific key first
117
+ env_key = f"{env_prefix}_{key}"
118
+ if env_key in env_vars:
119
+ return env_vars[env_key]
120
+
121
+ # Fall back to non-prefixed key
122
+ return env_vars.get(key)
@@ -1,3 +1,3 @@
1
- """
2
- Core business logic modules
3
- """
1
+ """
2
+ Core business logic modules
3
+ """
ai_lls_lib/core/cache.py CHANGED
@@ -1,106 +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
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
ai_lls_lib/core/models.py CHANGED
@@ -1,77 +1,77 @@
1
- """
2
- Data models for phone verification
3
- """
4
- from datetime import datetime
5
- from enum import Enum
6
- from typing import Optional
7
- from pydantic import BaseModel, Field
8
-
9
-
10
- class LineType(str, Enum):
11
- """Phone line type enumeration"""
12
- MOBILE = "mobile"
13
- LANDLINE = "landline"
14
- VOIP = "voip"
15
- UNKNOWN = "unknown"
16
-
17
-
18
- class VerificationSource(str, Enum):
19
- """Source of verification data"""
20
- API = "api"
21
- CACHE = "cache"
22
- BULK_IMPORT = "bulk_import"
23
-
24
-
25
- class JobStatus(str, Enum):
26
- """Bulk job status enumeration"""
27
- PENDING = "pending"
28
- PROCESSING = "processing"
29
- COMPLETED = "completed"
30
- FAILED = "failed"
31
-
32
-
33
- class PhoneVerification(BaseModel):
34
- """Result of phone number verification"""
35
- phone_number: str = Field(..., description="E.164 formatted phone number")
36
- line_type: LineType = Field(
37
- ..., description="Type of phone line"
38
- )
39
- dnc: bool = Field(..., description="Whether number is on DNC list")
40
- cached: bool = Field(..., description="Whether result came from cache")
41
- verified_at: datetime = Field(..., description="When verification occurred")
42
- source: VerificationSource = Field(
43
- ..., description="Source of verification data"
44
- )
45
-
46
- class Config:
47
- json_encoders = {
48
- datetime: lambda v: v.isoformat()
49
- }
50
-
51
-
52
- class BulkJob(BaseModel):
53
- """Bulk processing job metadata"""
54
- job_id: str = Field(..., description="Unique job identifier")
55
- status: JobStatus = Field(
56
- ..., description="Current job status"
57
- )
58
-
59
-
60
- class BulkJobStatus(BulkJob):
61
- """Extended bulk job status with progress info"""
62
- total_rows: Optional[int] = Field(None, description="Total rows to process")
63
- processed_rows: Optional[int] = Field(None, description="Rows processed so far")
64
- result_url: Optional[str] = Field(None, description="S3 URL of results")
65
- created_at: datetime = Field(..., description="Job creation time")
66
- completed_at: Optional[datetime] = Field(None, description="Job completion time")
67
- error: Optional[str] = Field(None, description="Error message if failed")
68
-
69
-
70
- class CacheEntry(BaseModel):
71
- """DynamoDB cache entry"""
72
- phone_number: str
73
- line_type: str # Stored as string in DynamoDB
74
- dnc: bool
75
- verified_at: str # ISO format string
76
- source: str # Stored as string in DynamoDB
77
- ttl: int # Unix timestamp for DynamoDB TTL
1
+ """
2
+ Data models for phone verification
3
+ """
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from typing import Optional
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class LineType(str, Enum):
11
+ """Phone line type enumeration"""
12
+ MOBILE = "mobile"
13
+ LANDLINE = "landline"
14
+ VOIP = "voip"
15
+ UNKNOWN = "unknown"
16
+
17
+
18
+ class VerificationSource(str, Enum):
19
+ """Source of verification data"""
20
+ API = "api"
21
+ CACHE = "cache"
22
+ BULK_IMPORT = "bulk_import"
23
+
24
+
25
+ class JobStatus(str, Enum):
26
+ """Bulk job status enumeration"""
27
+ PENDING = "pending"
28
+ PROCESSING = "processing"
29
+ COMPLETED = "completed"
30
+ FAILED = "failed"
31
+
32
+
33
+ class PhoneVerification(BaseModel):
34
+ """Result of phone number verification"""
35
+ phone_number: str = Field(..., description="E.164 formatted phone number")
36
+ line_type: LineType = Field(
37
+ ..., description="Type of phone line"
38
+ )
39
+ dnc: bool = Field(..., description="Whether number is on DNC list")
40
+ cached: bool = Field(..., description="Whether result came from cache")
41
+ verified_at: datetime = Field(..., description="When verification occurred")
42
+ source: VerificationSource = Field(
43
+ ..., description="Source of verification data"
44
+ )
45
+
46
+ class Config:
47
+ json_encoders = {
48
+ datetime: lambda v: v.isoformat()
49
+ }
50
+
51
+
52
+ class BulkJob(BaseModel):
53
+ """Bulk processing job metadata"""
54
+ job_id: str = Field(..., description="Unique job identifier")
55
+ status: JobStatus = Field(
56
+ ..., description="Current job status"
57
+ )
58
+
59
+
60
+ class BulkJobStatus(BulkJob):
61
+ """Extended bulk job status with progress info"""
62
+ total_rows: Optional[int] = Field(None, description="Total rows to process")
63
+ processed_rows: Optional[int] = Field(None, description="Rows processed so far")
64
+ result_url: Optional[str] = Field(None, description="S3 URL of results")
65
+ created_at: datetime = Field(..., description="Job creation time")
66
+ completed_at: Optional[datetime] = Field(None, description="Job completion time")
67
+ error: Optional[str] = Field(None, description="Error message if failed")
68
+
69
+
70
+ class CacheEntry(BaseModel):
71
+ """DynamoDB cache entry"""
72
+ phone_number: str
73
+ line_type: str # Stored as string in DynamoDB
74
+ dnc: bool
75
+ verified_at: str # ISO format string
76
+ source: str # Stored as string in DynamoDB
77
+ ttl: int # Unix timestamp for DynamoDB TTL