marqetive-lib 0.1.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.
Files changed (43) hide show
  1. marqetive/__init__.py +113 -0
  2. marqetive/core/__init__.py +5 -0
  3. marqetive/core/account_factory.py +212 -0
  4. marqetive/core/base_manager.py +303 -0
  5. marqetive/core/client.py +108 -0
  6. marqetive/core/progress.py +291 -0
  7. marqetive/core/registry.py +257 -0
  8. marqetive/platforms/__init__.py +55 -0
  9. marqetive/platforms/base.py +390 -0
  10. marqetive/platforms/exceptions.py +238 -0
  11. marqetive/platforms/instagram/__init__.py +7 -0
  12. marqetive/platforms/instagram/client.py +786 -0
  13. marqetive/platforms/instagram/exceptions.py +311 -0
  14. marqetive/platforms/instagram/factory.py +106 -0
  15. marqetive/platforms/instagram/manager.py +112 -0
  16. marqetive/platforms/instagram/media.py +669 -0
  17. marqetive/platforms/linkedin/__init__.py +7 -0
  18. marqetive/platforms/linkedin/client.py +733 -0
  19. marqetive/platforms/linkedin/exceptions.py +335 -0
  20. marqetive/platforms/linkedin/factory.py +130 -0
  21. marqetive/platforms/linkedin/manager.py +119 -0
  22. marqetive/platforms/linkedin/media.py +549 -0
  23. marqetive/platforms/models.py +345 -0
  24. marqetive/platforms/tiktok/__init__.py +0 -0
  25. marqetive/platforms/twitter/__init__.py +7 -0
  26. marqetive/platforms/twitter/client.py +647 -0
  27. marqetive/platforms/twitter/exceptions.py +311 -0
  28. marqetive/platforms/twitter/factory.py +151 -0
  29. marqetive/platforms/twitter/manager.py +121 -0
  30. marqetive/platforms/twitter/media.py +779 -0
  31. marqetive/platforms/twitter/threads.py +442 -0
  32. marqetive/py.typed +0 -0
  33. marqetive/registry_init.py +66 -0
  34. marqetive/utils/__init__.py +45 -0
  35. marqetive/utils/file_handlers.py +438 -0
  36. marqetive/utils/helpers.py +99 -0
  37. marqetive/utils/media.py +399 -0
  38. marqetive/utils/oauth.py +265 -0
  39. marqetive/utils/retry.py +239 -0
  40. marqetive/utils/token_validator.py +240 -0
  41. marqetive_lib-0.1.0.dist-info/METADATA +261 -0
  42. marqetive_lib-0.1.0.dist-info/RECORD +43 -0
  43. marqetive_lib-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,239 @@
1
+ """Retry utilities for handling transient failures in API calls.
2
+
3
+ This module provides decorators and utilities for implementing retry logic
4
+ with exponential backoff for async functions.
5
+ """
6
+
7
+ import asyncio
8
+ import functools
9
+ import logging
10
+ from collections.abc import Awaitable, Callable
11
+ from dataclasses import dataclass
12
+ from typing import Any, TypeVar
13
+
14
+ import httpx
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # Type variable for function return types
19
+ T = TypeVar("T")
20
+
21
+
22
+ @dataclass
23
+ class BackoffConfig:
24
+ """Configuration for exponential backoff retry logic.
25
+
26
+ Attributes:
27
+ max_attempts: Maximum number of retry attempts (including first try).
28
+ base_delay: Initial delay between retries in seconds.
29
+ max_delay: Maximum delay between retries in seconds.
30
+ exponential_base: Base for exponential backoff calculation.
31
+ jitter: Whether to add random jitter to delay times.
32
+
33
+ Example:
34
+ >>> config = BackoffConfig(max_attempts=5, base_delay=2, max_delay=30)
35
+ >>> print(config.calculate_delay(attempt=2))
36
+ 4.0
37
+ """
38
+
39
+ max_attempts: int = 3
40
+ base_delay: float = 1.0
41
+ max_delay: float = 10.0
42
+ exponential_base: float = 2.0
43
+ jitter: bool = True
44
+
45
+ def calculate_delay(self, attempt: int) -> float:
46
+ """Calculate delay for given attempt number.
47
+
48
+ Args:
49
+ attempt: The attempt number (0-indexed).
50
+
51
+ Returns:
52
+ Delay in seconds.
53
+ """
54
+ import random
55
+
56
+ delay = min(
57
+ self.base_delay * (self.exponential_base**attempt),
58
+ self.max_delay,
59
+ )
60
+
61
+ if self.jitter:
62
+ # Add random jitter (0-25% of delay)
63
+ jitter_amount = delay * random.uniform(0, 0.25)
64
+ delay += jitter_amount
65
+
66
+ return delay
67
+
68
+
69
+ # Standard backoff configuration used across the library
70
+ STANDARD_BACKOFF = BackoffConfig(
71
+ max_attempts=3,
72
+ base_delay=1.0,
73
+ max_delay=10.0,
74
+ exponential_base=2.0,
75
+ jitter=True,
76
+ )
77
+
78
+
79
+ def is_retryable_error(error: Exception) -> bool:
80
+ """Determine if an error is retryable.
81
+
82
+ Args:
83
+ error: The exception to check.
84
+
85
+ Returns:
86
+ True if error is retryable, False otherwise.
87
+ """
88
+ # HTTP errors that are retryable
89
+ if isinstance(error, httpx.HTTPStatusError):
90
+ # Retry on 5xx server errors and 429 rate limit
91
+ return bool(
92
+ error.response.status_code >= 500 or error.response.status_code == 429
93
+ )
94
+
95
+ # Network/connection errors are retryable
96
+ if isinstance(
97
+ error, (httpx.ConnectError, httpx.TimeoutException, httpx.NetworkError)
98
+ ):
99
+ return True
100
+
101
+ # Any other httpx error (or not retryable)
102
+ return isinstance(error, httpx.HTTPError)
103
+
104
+
105
+ def retry_async(
106
+ config: BackoffConfig | None = None,
107
+ retryable_exceptions: tuple[type[Exception], ...] | None = None,
108
+ error_classifier: Callable[[Exception], bool] | None = None,
109
+ ) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
110
+ """Decorator for retrying async functions with exponential backoff.
111
+
112
+ Args:
113
+ config: Backoff configuration (uses STANDARD_BACKOFF if None).
114
+ retryable_exceptions: Tuple of exception types to retry.
115
+ If None, uses is_retryable_error().
116
+ error_classifier: Custom function to determine if error is retryable.
117
+ Overrides retryable_exceptions if provided.
118
+
119
+ Returns:
120
+ Decorator function.
121
+
122
+ Example:
123
+ >>> @retry_async(config=BackoffConfig(max_attempts=5))
124
+ ... async def fetch_data(url: str) -> dict:
125
+ ... async with httpx.AsyncClient() as client:
126
+ ... response = await client.get(url)
127
+ ... response.raise_for_status()
128
+ ... return response.json()
129
+ """
130
+ if config is None:
131
+ config = STANDARD_BACKOFF
132
+
133
+ def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
134
+ @functools.wraps(func)
135
+ async def wrapper(*args: Any, **kwargs: Any) -> T:
136
+ last_exception: Exception | None = None
137
+
138
+ for attempt in range(config.max_attempts):
139
+ try:
140
+ return await func(*args, **kwargs)
141
+
142
+ except Exception as e:
143
+ last_exception = e
144
+
145
+ # Determine if error is retryable
146
+ should_retry = False
147
+ if error_classifier is not None:
148
+ should_retry = error_classifier(e)
149
+ elif retryable_exceptions is not None:
150
+ should_retry = isinstance(e, retryable_exceptions)
151
+ else:
152
+ should_retry = is_retryable_error(e)
153
+
154
+ if not should_retry:
155
+ raise
156
+
157
+ # Check if we should retry
158
+ if attempt < config.max_attempts - 1:
159
+ delay = config.calculate_delay(attempt)
160
+ logger.warning(
161
+ f"Attempt {attempt + 1}/{config.max_attempts} failed "
162
+ f"for {func.__name__}: {str(e)}. "
163
+ f"Retrying in {delay:.2f}s..."
164
+ )
165
+ await asyncio.sleep(delay)
166
+ else:
167
+ logger.error(
168
+ f"All {config.max_attempts} attempts failed "
169
+ f"for {func.__name__}: {str(e)}"
170
+ )
171
+ raise
172
+
173
+ # Should never reach here, but just in case
174
+ if last_exception:
175
+ raise last_exception
176
+ raise RuntimeError("Retry logic failed unexpectedly")
177
+
178
+ return wrapper
179
+
180
+ return decorator
181
+
182
+
183
+ async def retry_async_func[T](
184
+ func: Callable[..., Awaitable[T]],
185
+ *args: Any,
186
+ config: BackoffConfig | None = None,
187
+ **kwargs: Any,
188
+ ) -> T:
189
+ """Retry an async function with exponential backoff.
190
+
191
+ Alternative to the decorator for cases where you can't use decorators.
192
+
193
+ Args:
194
+ func: Async function to retry.
195
+ *args: Positional arguments to pass to function.
196
+ config: Backoff configuration (uses STANDARD_BACKOFF if None).
197
+ **kwargs: Keyword arguments to pass to function.
198
+
199
+ Returns:
200
+ Function return value.
201
+
202
+ Example:
203
+ >>> async def fetch_data(url: str) -> dict:
204
+ ... async with httpx.AsyncClient() as client:
205
+ ... response = await client.get(url)
206
+ ... response.raise_for_status()
207
+ ... return response.json()
208
+ >>>
209
+ >>> data = await retry_async_func(fetch_data, "https://api.example.com")
210
+ """
211
+ if config is None:
212
+ config = STANDARD_BACKOFF
213
+
214
+ last_exception: Exception | None = None
215
+
216
+ for attempt in range(config.max_attempts):
217
+ try:
218
+ return await func(*args, **kwargs)
219
+
220
+ except Exception as e:
221
+ last_exception = e
222
+
223
+ if not is_retryable_error(e):
224
+ raise
225
+
226
+ if attempt < config.max_attempts - 1:
227
+ delay = config.calculate_delay(attempt)
228
+ logger.warning(
229
+ f"Attempt {attempt + 1}/{config.max_attempts} failed: {str(e)}. "
230
+ f"Retrying in {delay:.2f}s..."
231
+ )
232
+ await asyncio.sleep(delay)
233
+ else:
234
+ logger.error(f"All {config.max_attempts} attempts failed: {str(e)}")
235
+ raise
236
+
237
+ if last_exception:
238
+ raise last_exception
239
+ raise RuntimeError("Retry logic failed unexpectedly")
@@ -0,0 +1,240 @@
1
+ """Token validation utilities for checking credential validity.
2
+
3
+ This module provides utilities for validating OAuth tokens and determining
4
+ if they need to be refreshed.
5
+ """
6
+
7
+ import re
8
+ from datetime import datetime, timedelta
9
+ from typing import Any
10
+
11
+ from marqetive.platforms.models import AuthCredentials
12
+
13
+
14
+ def is_token_expired(
15
+ expires_at: datetime | None,
16
+ threshold_minutes: int = 5,
17
+ ) -> bool:
18
+ """Check if a token has expired or will expire soon.
19
+
20
+ Args:
21
+ expires_at: Token expiration timestamp.
22
+ threshold_minutes: Consider expired if expires within this many minutes.
23
+
24
+ Returns:
25
+ True if token is expired or will expire soon, False otherwise.
26
+
27
+ Example:
28
+ >>> from datetime import datetime, timedelta
29
+ >>> expires = datetime.now() + timedelta(minutes=3)
30
+ >>> is_token_expired(expires, threshold_minutes=5)
31
+ True
32
+ >>> expires = datetime.now() + timedelta(hours=1)
33
+ >>> is_token_expired(expires, threshold_minutes=5)
34
+ False
35
+ """
36
+ if expires_at is None:
37
+ # No expiry means token doesn't expire
38
+ return False
39
+
40
+ threshold = datetime.now() + timedelta(minutes=threshold_minutes)
41
+ return expires_at <= threshold
42
+
43
+
44
+ def needs_refresh(
45
+ credentials: AuthCredentials,
46
+ threshold_minutes: int = 5, # noqa: ARG001
47
+ ) -> bool:
48
+ """Check if credentials need to be refreshed.
49
+
50
+ Args:
51
+ credentials: Credentials to check.
52
+ threshold_minutes: Expiry threshold in minutes.
53
+
54
+ Returns:
55
+ True if refresh is needed, False otherwise.
56
+
57
+ Example:
58
+ >>> creds = AuthCredentials(
59
+ ... platform="twitter",
60
+ ... access_token="token",
61
+ ... expires_at=datetime.now() + timedelta(minutes=2)
62
+ ... )
63
+ >>> needs_refresh(creds)
64
+ True
65
+ """
66
+ return credentials.needs_refresh()
67
+
68
+
69
+ def validate_token_format(token: str, min_length: int = 10) -> bool:
70
+ """Validate basic token format.
71
+
72
+ Checks if token looks valid (not empty, meets minimum length).
73
+
74
+ Args:
75
+ token: Token string to validate.
76
+ min_length: Minimum acceptable token length.
77
+
78
+ Returns:
79
+ True if token format is valid, False otherwise.
80
+
81
+ Example:
82
+ >>> validate_token_format("abc123xyz")
83
+ False
84
+ >>> validate_token_format("a" * 50)
85
+ True
86
+ """
87
+ if not token or not isinstance(token, str):
88
+ return False
89
+
90
+ # Remove whitespace
91
+ token = token.strip()
92
+
93
+ # Check minimum length
94
+ if len(token) < min_length:
95
+ return False
96
+
97
+ # Check for obviously invalid tokens
98
+ return token.lower() not in ["none", "null", "undefined", ""]
99
+
100
+
101
+ def validate_bearer_token(token: str) -> bool:
102
+ """Validate Bearer token format.
103
+
104
+ Args:
105
+ token: Bearer token to validate.
106
+
107
+ Returns:
108
+ True if token appears valid, False otherwise.
109
+
110
+ Example:
111
+ >>> validate_bearer_token("ya29.a0AfH6SMB...")
112
+ True
113
+ >>> validate_bearer_token("invalid")
114
+ False
115
+ """
116
+ # Bearer tokens are typically base64-like strings
117
+ if not validate_token_format(token, min_length=20):
118
+ return False
119
+
120
+ # Check for suspicious patterns
121
+ return not re.search(r"[<>\"']", token)
122
+
123
+
124
+ def calculate_token_ttl(expires_at: datetime | None) -> timedelta | None:
125
+ """Calculate time-to-live for a token.
126
+
127
+ Args:
128
+ expires_at: Token expiration timestamp.
129
+
130
+ Returns:
131
+ Time remaining until expiration, or None if no expiry.
132
+
133
+ Example:
134
+ >>> from datetime import datetime, timedelta
135
+ >>> expires = datetime.now() + timedelta(hours=1)
136
+ >>> ttl = calculate_token_ttl(expires)
137
+ >>> ttl.total_seconds() > 3500 # Approximately 1 hour
138
+ True
139
+ """
140
+ if expires_at is None:
141
+ return None
142
+
143
+ now = datetime.now()
144
+ if expires_at <= now:
145
+ return timedelta(0)
146
+
147
+ return expires_at - now
148
+
149
+
150
+ def should_proactively_refresh(
151
+ credentials: AuthCredentials,
152
+ refresh_threshold_minutes: int = 5,
153
+ ) -> bool:
154
+ """Determine if token should be proactively refreshed.
155
+
156
+ Checks if token will expire soon and if refresh token is available.
157
+
158
+ Args:
159
+ credentials: Credentials to check.
160
+ refresh_threshold_minutes: Refresh if expires within this many minutes.
161
+
162
+ Returns:
163
+ True if should proactively refresh, False otherwise.
164
+
165
+ Example:
166
+ >>> creds = AuthCredentials(
167
+ ... platform="twitter",
168
+ ... access_token="token",
169
+ ... refresh_token="refresh",
170
+ ... expires_at=datetime.now() + timedelta(minutes=3)
171
+ ... )
172
+ >>> should_proactively_refresh(creds)
173
+ True
174
+ """
175
+ # Need refresh token to refresh
176
+ if not credentials.refresh_token:
177
+ return False
178
+
179
+ # Check if expiring soon
180
+ return is_token_expired(credentials.expires_at, refresh_threshold_minutes)
181
+
182
+
183
+ def is_credentials_complete(credentials: AuthCredentials) -> bool:
184
+ """Check if credentials have all required fields.
185
+
186
+ Args:
187
+ credentials: Credentials to validate.
188
+
189
+ Returns:
190
+ True if credentials are complete, False otherwise.
191
+
192
+ Example:
193
+ >>> creds = AuthCredentials(
194
+ ... platform="twitter",
195
+ ... access_token="token"
196
+ ... )
197
+ >>> is_credentials_complete(creds)
198
+ True
199
+ """
200
+ # Must have platform and access token
201
+ if not credentials.platform or not credentials.access_token:
202
+ return False
203
+
204
+ # Access token must be valid format
205
+ return validate_token_format(credentials.access_token)
206
+
207
+
208
+ def get_token_health_status(credentials: AuthCredentials) -> dict[str, Any]:
209
+ """Get comprehensive health status of credentials.
210
+
211
+ Args:
212
+ credentials: Credentials to analyze.
213
+
214
+ Returns:
215
+ Dictionary with health information.
216
+
217
+ Example:
218
+ >>> creds = AuthCredentials(
219
+ ... platform="twitter",
220
+ ... access_token="token",
221
+ ... expires_at=datetime.now() + timedelta(hours=1)
222
+ ... )
223
+ >>> status = get_token_health_status(creds)
224
+ >>> status["is_valid"]
225
+ True
226
+ >>> status["needs_refresh"]
227
+ False
228
+ """
229
+ ttl = calculate_token_ttl(credentials.expires_at)
230
+
231
+ return {
232
+ "is_valid": credentials.is_valid(),
233
+ "is_expired": credentials.is_expired(),
234
+ "needs_refresh": credentials.needs_refresh(),
235
+ "has_refresh_token": credentials.refresh_token is not None,
236
+ "time_to_expiry_seconds": ttl.total_seconds() if ttl else None,
237
+ "should_proactively_refresh": should_proactively_refresh(credentials),
238
+ "status": credentials.status.value,
239
+ "is_complete": is_credentials_complete(credentials),
240
+ }