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.
- marqetive/__init__.py +113 -0
- marqetive/core/__init__.py +5 -0
- marqetive/core/account_factory.py +212 -0
- marqetive/core/base_manager.py +303 -0
- marqetive/core/client.py +108 -0
- marqetive/core/progress.py +291 -0
- marqetive/core/registry.py +257 -0
- marqetive/platforms/__init__.py +55 -0
- marqetive/platforms/base.py +390 -0
- marqetive/platforms/exceptions.py +238 -0
- marqetive/platforms/instagram/__init__.py +7 -0
- marqetive/platforms/instagram/client.py +786 -0
- marqetive/platforms/instagram/exceptions.py +311 -0
- marqetive/platforms/instagram/factory.py +106 -0
- marqetive/platforms/instagram/manager.py +112 -0
- marqetive/platforms/instagram/media.py +669 -0
- marqetive/platforms/linkedin/__init__.py +7 -0
- marqetive/platforms/linkedin/client.py +733 -0
- marqetive/platforms/linkedin/exceptions.py +335 -0
- marqetive/platforms/linkedin/factory.py +130 -0
- marqetive/platforms/linkedin/manager.py +119 -0
- marqetive/platforms/linkedin/media.py +549 -0
- marqetive/platforms/models.py +345 -0
- marqetive/platforms/tiktok/__init__.py +0 -0
- marqetive/platforms/twitter/__init__.py +7 -0
- marqetive/platforms/twitter/client.py +647 -0
- marqetive/platforms/twitter/exceptions.py +311 -0
- marqetive/platforms/twitter/factory.py +151 -0
- marqetive/platforms/twitter/manager.py +121 -0
- marqetive/platforms/twitter/media.py +779 -0
- marqetive/platforms/twitter/threads.py +442 -0
- marqetive/py.typed +0 -0
- marqetive/registry_init.py +66 -0
- marqetive/utils/__init__.py +45 -0
- marqetive/utils/file_handlers.py +438 -0
- marqetive/utils/helpers.py +99 -0
- marqetive/utils/media.py +399 -0
- marqetive/utils/oauth.py +265 -0
- marqetive/utils/retry.py +239 -0
- marqetive/utils/token_validator.py +240 -0
- marqetive_lib-0.1.0.dist-info/METADATA +261 -0
- marqetive_lib-0.1.0.dist-info/RECORD +43 -0
- marqetive_lib-0.1.0.dist-info/WHEEL +4 -0
marqetive/utils/retry.py
ADDED
|
@@ -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
|
+
}
|