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,399 @@
1
+ """Media utilities for file validation, MIME type detection, and chunking.
2
+
3
+ This module provides utilities for working with media files including:
4
+ - MIME type detection using multiple methods
5
+ - File validation (size, type, format)
6
+ - File chunking for large uploads
7
+ - File hashing for integrity verification
8
+ """
9
+
10
+ import hashlib
11
+ import mimetypes
12
+ import os
13
+ from collections.abc import AsyncGenerator
14
+ from pathlib import Path
15
+ from typing import Literal
16
+
17
+ # Initialize mimetypes database
18
+ mimetypes.init()
19
+
20
+ # Common MIME type mappings
21
+ MIME_TYPE_MAP = {
22
+ ".jpg": "image/jpeg",
23
+ ".jpeg": "image/jpeg",
24
+ ".png": "image/png",
25
+ ".gif": "image/gif",
26
+ ".webp": "image/webp",
27
+ ".mp4": "video/mp4",
28
+ ".mov": "video/quicktime",
29
+ ".avi": "video/x-msvideo",
30
+ ".mkv": "video/x-matroska",
31
+ ".webm": "video/webm",
32
+ ".pdf": "application/pdf",
33
+ ".doc": "application/msword",
34
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
35
+ ".ppt": "application/vnd.ms-powerpoint",
36
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
37
+ }
38
+
39
+ # Magic number signatures for file type detection
40
+ MAGIC_NUMBERS = {
41
+ b"\xff\xd8\xff": "image/jpeg",
42
+ b"\x89PNG\r\n\x1a\n": "image/png",
43
+ b"GIF87a": "image/gif",
44
+ b"GIF89a": "image/gif",
45
+ b"RIFF": "image/webp", # Needs additional check for WEBP
46
+ b"\x00\x00\x00\x18ftypmp4": "video/mp4", # Offset at byte 4
47
+ b"\x00\x00\x00\x1cftypiso": "video/mp4", # Alternative MP4
48
+ b"%PDF": "application/pdf",
49
+ }
50
+
51
+ # Platform-specific file size limits (in bytes)
52
+ PLATFORM_LIMITS = {
53
+ "twitter": {
54
+ "image": 5 * 1024 * 1024, # 5 MB
55
+ "gif": 15 * 1024 * 1024, # 15 MB
56
+ "video": 512 * 1024 * 1024, # 512 MB
57
+ },
58
+ "instagram": {
59
+ "image": 8 * 1024 * 1024, # 8 MB
60
+ "video": 100 * 1024 * 1024, # 100 MB
61
+ "reel": 100 * 1024 * 1024, # 100 MB
62
+ "story": 100 * 1024 * 1024, # 100 MB
63
+ },
64
+ "linkedin": {
65
+ "image": 10 * 1024 * 1024, # 10 MB
66
+ "video": 200 * 1024 * 1024, # 200 MB
67
+ "document": 10 * 1024 * 1024, # 10 MB
68
+ },
69
+ "tiktok": {
70
+ "video": 4 * 1024 * 1024 * 1024, # 4 GB
71
+ },
72
+ }
73
+
74
+ # Supported media types by platform
75
+ PLATFORM_MEDIA_TYPES = {
76
+ "twitter": {
77
+ "image": [".jpg", ".jpeg", ".png", ".gif", ".webp"],
78
+ "video": [".mp4", ".mov"],
79
+ },
80
+ "instagram": {
81
+ "image": [".jpg", ".jpeg", ".png"],
82
+ "video": [".mp4", ".mov"],
83
+ },
84
+ "linkedin": {
85
+ "image": [".jpg", ".jpeg", ".png", ".gif"],
86
+ "video": [".mp4", ".mov", ".avi", ".webm"],
87
+ "document": [".pdf", ".doc", ".docx", ".ppt", ".pptx"],
88
+ },
89
+ "tiktok": {
90
+ "video": [".mp4", ".mov", ".webm"],
91
+ },
92
+ }
93
+
94
+
95
+ class MediaValidator:
96
+ """Validator for media files with platform-specific rules."""
97
+
98
+ def __init__(
99
+ self,
100
+ platform: Literal["twitter", "instagram", "linkedin", "tiktok"],
101
+ media_type: Literal["image", "video", "document", "gif", "reel", "story"],
102
+ ) -> None:
103
+ """Initialize the media validator.
104
+
105
+ Args:
106
+ platform: Target platform name.
107
+ media_type: Type of media being validated.
108
+ """
109
+ self.platform = platform
110
+ self.media_type = media_type
111
+ self.max_size = PLATFORM_LIMITS.get(platform, {}).get(media_type, 0)
112
+ self.allowed_extensions = PLATFORM_MEDIA_TYPES.get(platform, {}).get(
113
+ media_type, []
114
+ )
115
+
116
+ def validate(self, file_path: str) -> tuple[bool, str | None]:
117
+ """Validate a media file.
118
+
119
+ Args:
120
+ file_path: Path to the file to validate.
121
+
122
+ Returns:
123
+ Tuple of (is_valid, error_message). If valid, error_message is None.
124
+ """
125
+ # Check file exists
126
+ if not os.path.exists(file_path):
127
+ return False, f"File not found: {file_path}"
128
+
129
+ # Check file extension
130
+ extension = Path(file_path).suffix.lower()
131
+ if extension not in self.allowed_extensions:
132
+ return (
133
+ False,
134
+ f"Invalid file type '{extension}' for {self.platform} "
135
+ f"{self.media_type}. Allowed: {', '.join(self.allowed_extensions)}",
136
+ )
137
+
138
+ # Check file size
139
+ file_size = os.path.getsize(file_path)
140
+ if self.max_size and file_size > self.max_size:
141
+ max_mb = self.max_size / (1024 * 1024)
142
+ actual_mb = file_size / (1024 * 1024)
143
+ return (
144
+ False,
145
+ f"File size {actual_mb:.2f}MB exceeds {self.platform} "
146
+ f"{self.media_type} limit of {max_mb:.2f}MB",
147
+ )
148
+
149
+ # Check MIME type matches extension
150
+ detected_mime = detect_mime_type(file_path)
151
+ expected_mime = MIME_TYPE_MAP.get(extension)
152
+ if expected_mime and detected_mime != expected_mime:
153
+ return (
154
+ False,
155
+ f"File content type '{detected_mime}' doesn't match "
156
+ f"extension '{extension}' (expected '{expected_mime}')",
157
+ )
158
+
159
+ return True, None
160
+
161
+
162
+ def detect_mime_type(file_path: str) -> str:
163
+ """Detect MIME type of a file using multiple methods.
164
+
165
+ Uses a combination of:
166
+ 1. Magic number (file signature) detection
167
+ 2. Extension-based lookup
168
+ 3. Python's mimetypes module
169
+
170
+ Args:
171
+ file_path: Path to the file.
172
+
173
+ Returns:
174
+ MIME type string (e.g., 'image/jpeg').
175
+
176
+ Example:
177
+ >>> mime_type = detect_mime_type('/path/to/image.jpg')
178
+ >>> print(mime_type)
179
+ image/jpeg
180
+ """
181
+ # Try magic number detection first (most reliable)
182
+ try:
183
+ with open(file_path, "rb") as f:
184
+ header = f.read(32) # Read first 32 bytes
185
+
186
+ # Check for magic numbers
187
+ for magic, mime_type in MAGIC_NUMBERS.items():
188
+ if header.startswith(magic):
189
+ # Special case for WEBP
190
+ if magic == b"RIFF" and b"WEBP" in header[:16]:
191
+ return "image/webp"
192
+ if "ftyp" not in magic.decode("latin-1", errors="ignore"):
193
+ return mime_type
194
+
195
+ # Check for MP4 variants (ftyp at offset 4)
196
+ if len(header) >= 12:
197
+ ftyp_check = header[4:12]
198
+ if b"ftyp" in ftyp_check:
199
+ return "video/mp4"
200
+
201
+ except OSError:
202
+ pass
203
+
204
+ # Try extension-based lookup
205
+ extension = Path(file_path).suffix.lower()
206
+ if extension in MIME_TYPE_MAP:
207
+ return MIME_TYPE_MAP[extension]
208
+
209
+ # Fall back to mimetypes module
210
+ mime_type, _ = mimetypes.guess_type(file_path)
211
+ return mime_type or "application/octet-stream"
212
+
213
+
214
+ def validate_file_size(
215
+ file_path: str, max_size: int, *, raise_error: bool = False
216
+ ) -> bool:
217
+ """Validate that a file doesn't exceed the maximum size.
218
+
219
+ Args:
220
+ file_path: Path to the file.
221
+ max_size: Maximum size in bytes.
222
+ raise_error: If True, raise ValueError instead of returning False.
223
+
224
+ Returns:
225
+ True if file size is within limit, False otherwise.
226
+
227
+ Raises:
228
+ ValueError: If file exceeds size limit and raise_error=True.
229
+ FileNotFoundError: If file doesn't exist.
230
+
231
+ Example:
232
+ >>> is_valid = validate_file_size('image.jpg', 5 * 1024 * 1024)
233
+ >>> if not is_valid:
234
+ ... print("File too large")
235
+ """
236
+ if not os.path.exists(file_path):
237
+ raise FileNotFoundError(f"File not found: {file_path}")
238
+
239
+ file_size = os.path.getsize(file_path)
240
+
241
+ if file_size > max_size:
242
+ if raise_error:
243
+ max_mb = max_size / (1024 * 1024)
244
+ actual_mb = file_size / (1024 * 1024)
245
+ raise ValueError(
246
+ f"File size {actual_mb:.2f}MB exceeds limit of {max_mb:.2f}MB"
247
+ )
248
+ return False
249
+
250
+ return True
251
+
252
+
253
+ def validate_media_type(
254
+ file_path: str, allowed_types: list[str], *, raise_error: bool = False
255
+ ) -> bool:
256
+ """Validate that a file is one of the allowed media types.
257
+
258
+ Args:
259
+ file_path: Path to the file.
260
+ allowed_types: List of allowed MIME types or extensions.
261
+ raise_error: If True, raise ValueError instead of returning False.
262
+
263
+ Returns:
264
+ True if file type is allowed, False otherwise.
265
+
266
+ Raises:
267
+ ValueError: If file type not allowed and raise_error=True.
268
+
269
+ Example:
270
+ >>> allowed = ['image/jpeg', 'image/png', '.jpg', '.png']
271
+ >>> is_valid = validate_media_type('photo.jpg', allowed)
272
+ >>> print(is_valid)
273
+ True
274
+ """
275
+ mime_type = detect_mime_type(file_path)
276
+ extension = Path(file_path).suffix.lower()
277
+
278
+ # Check against both MIME types and extensions
279
+ is_allowed = mime_type in allowed_types or extension in allowed_types
280
+
281
+ if not is_allowed and raise_error:
282
+ raise ValueError(
283
+ f"File type '{mime_type}' (extension '{extension}') not allowed. "
284
+ f"Allowed types: {', '.join(allowed_types)}"
285
+ )
286
+
287
+ return is_allowed
288
+
289
+
290
+ async def chunk_file(
291
+ file_path: str, chunk_size: int = 1024 * 1024
292
+ ) -> AsyncGenerator[bytes, None]:
293
+ """Asynchronously read file in chunks.
294
+
295
+ Yields file content in chunks of specified size. Useful for uploading
296
+ large files without loading entire file into memory.
297
+
298
+ Args:
299
+ file_path: Path to the file.
300
+ chunk_size: Size of each chunk in bytes (default: 1MB).
301
+
302
+ Yields:
303
+ Bytes chunks of the file.
304
+
305
+ Raises:
306
+ FileNotFoundError: If file doesn't exist.
307
+
308
+ Example:
309
+ >>> async for chunk in chunk_file('large_video.mp4', chunk_size=5*1024*1024):
310
+ ... await upload_chunk(chunk)
311
+ """
312
+ import aiofiles
313
+
314
+ if not os.path.exists(file_path):
315
+ raise FileNotFoundError(f"File not found: {file_path}")
316
+
317
+ async with aiofiles.open(file_path, "rb") as f:
318
+ while True:
319
+ chunk = await f.read(chunk_size)
320
+ if not chunk:
321
+ break
322
+ yield chunk
323
+
324
+
325
+ def get_file_hash(file_path: str, algorithm: str = "sha256") -> str:
326
+ """Calculate hash of a file for integrity verification.
327
+
328
+ Args:
329
+ file_path: Path to the file.
330
+ algorithm: Hash algorithm to use (default: 'sha256').
331
+
332
+ Returns:
333
+ Hexadecimal hash string.
334
+
335
+ Raises:
336
+ FileNotFoundError: If file doesn't exist.
337
+
338
+ Example:
339
+ >>> file_hash = get_file_hash('document.pdf')
340
+ >>> print(file_hash)
341
+ a1b2c3d4e5f6...
342
+ """
343
+ if not os.path.exists(file_path):
344
+ raise FileNotFoundError(f"File not found: {file_path}")
345
+
346
+ hash_obj = hashlib.new(algorithm)
347
+
348
+ with open(file_path, "rb") as f:
349
+ # Read in chunks to handle large files
350
+ for chunk in iter(lambda: f.read(8192), b""):
351
+ hash_obj.update(chunk)
352
+
353
+ return hash_obj.hexdigest()
354
+
355
+
356
+ def format_file_size(size_bytes: int) -> str:
357
+ """Format file size in human-readable format.
358
+
359
+ Args:
360
+ size_bytes: File size in bytes.
361
+
362
+ Returns:
363
+ Formatted string (e.g., '1.5 MB').
364
+
365
+ Example:
366
+ >>> size = format_file_size(1536000)
367
+ >>> print(size)
368
+ 1.46 MB
369
+ """
370
+ size: float = float(size_bytes)
371
+ for unit in ["B", "KB", "MB", "GB", "TB"]:
372
+ if size < 1024.0:
373
+ return f"{size:.2f} {unit}"
374
+ size /= 1024.0
375
+ return f"{size:.2f} PB"
376
+
377
+
378
+ def get_chunk_count(file_path: str, chunk_size: int) -> int:
379
+ """Calculate number of chunks needed to upload a file.
380
+
381
+ Args:
382
+ file_path: Path to the file.
383
+ chunk_size: Size of each chunk in bytes.
384
+
385
+ Returns:
386
+ Number of chunks needed.
387
+
388
+ Raises:
389
+ FileNotFoundError: If file doesn't exist.
390
+
391
+ Example:
392
+ >>> chunks = get_chunk_count('video.mp4', 5 * 1024 * 1024)
393
+ >>> print(f"Will upload in {chunks} chunks")
394
+ """
395
+ if not os.path.exists(file_path):
396
+ raise FileNotFoundError(f"File not found: {file_path}")
397
+
398
+ file_size = os.path.getsize(file_path)
399
+ return (file_size + chunk_size - 1) // chunk_size # Ceiling division
@@ -0,0 +1,265 @@
1
+ """OAuth token refresh utilities for social media platforms.
2
+
3
+ This module provides utilities for refreshing OAuth2 access tokens across
4
+ different social media platforms.
5
+ """
6
+
7
+ import logging
8
+ from datetime import datetime, timedelta
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from marqetive.platforms.exceptions import PlatformAuthError
14
+ from marqetive.platforms.models import AuthCredentials
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ async def refresh_oauth2_token(
20
+ refresh_token: str,
21
+ client_id: str,
22
+ client_secret: str,
23
+ token_url: str,
24
+ additional_params: dict[str, Any] | None = None,
25
+ ) -> dict[str, Any]:
26
+ """Refresh an OAuth2 access token.
27
+
28
+ Generic OAuth2 token refresh implementation that works with most providers.
29
+
30
+ Args:
31
+ refresh_token: The refresh token.
32
+ client_id: OAuth client ID.
33
+ client_secret: OAuth client secret.
34
+ token_url: Token endpoint URL.
35
+ additional_params: Additional parameters to include in request.
36
+
37
+ Returns:
38
+ Token response dictionary with access_token, expires_in, etc.
39
+
40
+ Raises:
41
+ PlatformAuthError: If token refresh fails.
42
+
43
+ Example:
44
+ >>> token_data = await refresh_oauth2_token(
45
+ ... refresh_token="refresh_token_here",
46
+ ... client_id="my_client_id",
47
+ ... client_secret="my_client_secret",
48
+ ... token_url="https://oauth.example.com/token"
49
+ ... )
50
+ >>> print(token_data["access_token"])
51
+ """
52
+ params = {
53
+ "grant_type": "refresh_token",
54
+ "refresh_token": refresh_token,
55
+ "client_id": client_id,
56
+ "client_secret": client_secret,
57
+ }
58
+
59
+ if additional_params:
60
+ params.update(additional_params)
61
+
62
+ try:
63
+ async with httpx.AsyncClient() as client:
64
+ response = await client.post(
65
+ token_url,
66
+ data=params,
67
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
68
+ timeout=30.0,
69
+ )
70
+ response.raise_for_status()
71
+ return response.json()
72
+
73
+ except httpx.HTTPStatusError as e:
74
+ logger.error(f"HTTP error refreshing token: {e.response.status_code}")
75
+ raise PlatformAuthError(
76
+ f"Failed to refresh token: {e.response.text}",
77
+ platform="oauth2",
78
+ status_code=e.response.status_code,
79
+ ) from e
80
+
81
+ except httpx.HTTPError as e:
82
+ logger.error(f"Network error refreshing token: {e}")
83
+ raise PlatformAuthError(
84
+ f"Network error refreshing token: {e}",
85
+ platform="oauth2",
86
+ ) from e
87
+
88
+
89
+ async def refresh_twitter_token(
90
+ credentials: AuthCredentials,
91
+ client_id: str,
92
+ client_secret: str,
93
+ ) -> AuthCredentials:
94
+ """Refresh Twitter OAuth2 access token.
95
+
96
+ Args:
97
+ credentials: Current credentials with refresh token.
98
+ client_id: Twitter OAuth client ID.
99
+ client_secret: Twitter OAuth client secret.
100
+
101
+ Returns:
102
+ Updated credentials with new access token.
103
+
104
+ Raises:
105
+ PlatformAuthError: If refresh fails.
106
+
107
+ Example:
108
+ >>> import os
109
+ >>> creds = AuthCredentials(
110
+ ... platform="twitter",
111
+ ... access_token="old_token",
112
+ ... refresh_token="refresh_token_here"
113
+ ... )
114
+ >>> refreshed = await refresh_twitter_token(
115
+ ... creds,
116
+ ... os.getenv("TWITTER_CLIENT_ID"),
117
+ ... os.getenv("TWITTER_CLIENT_SECRET")
118
+ ... )
119
+ """
120
+ if not credentials.refresh_token:
121
+ raise PlatformAuthError(
122
+ "No refresh token available",
123
+ platform="twitter",
124
+ )
125
+
126
+ token_url = "https://api.x.com/2/oauth2/token"
127
+
128
+ token_data = await refresh_oauth2_token(
129
+ refresh_token=credentials.refresh_token,
130
+ client_id=client_id,
131
+ client_secret=client_secret,
132
+ token_url=token_url,
133
+ )
134
+
135
+ # Update credentials
136
+ credentials.access_token = token_data["access_token"]
137
+
138
+ # Update refresh token if provided
139
+ if "refresh_token" in token_data:
140
+ credentials.refresh_token = token_data["refresh_token"]
141
+
142
+ # Calculate expiry
143
+ if "expires_in" in token_data:
144
+ expires_in = int(token_data["expires_in"])
145
+ credentials.expires_at = datetime.now() + timedelta(seconds=expires_in)
146
+
147
+ return credentials
148
+
149
+
150
+ async def refresh_linkedin_token(
151
+ credentials: AuthCredentials,
152
+ client_id: str,
153
+ client_secret: str,
154
+ ) -> AuthCredentials:
155
+ """Refresh LinkedIn OAuth2 access token.
156
+
157
+ Args:
158
+ credentials: Current credentials with refresh token.
159
+ client_id: LinkedIn OAuth client ID.
160
+ client_secret: LinkedIn OAuth client secret.
161
+
162
+ Returns:
163
+ Updated credentials with new access token.
164
+
165
+ Raises:
166
+ PlatformAuthError: If refresh fails.
167
+
168
+ Example:
169
+ >>> creds = AuthCredentials(
170
+ ... platform="linkedin",
171
+ ... access_token="old_token",
172
+ ... refresh_token="refresh_token_here"
173
+ ... )
174
+ >>> refreshed = await refresh_linkedin_token(creds, client_id, client_secret)
175
+ """
176
+ if not credentials.refresh_token:
177
+ raise PlatformAuthError(
178
+ "No refresh token available",
179
+ platform="linkedin",
180
+ )
181
+
182
+ token_url = "https://www.linkedin.com/oauth/v2/accessToken"
183
+
184
+ token_data = await refresh_oauth2_token(
185
+ refresh_token=credentials.refresh_token,
186
+ client_id=client_id,
187
+ client_secret=client_secret,
188
+ token_url=token_url,
189
+ )
190
+
191
+ # Update credentials
192
+ credentials.access_token = token_data["access_token"]
193
+
194
+ # LinkedIn might provide new refresh token
195
+ if "refresh_token" in token_data:
196
+ credentials.refresh_token = token_data["refresh_token"]
197
+
198
+ # Calculate expiry
199
+ if "expires_in" in token_data:
200
+ expires_in = int(token_data["expires_in"])
201
+ credentials.expires_at = datetime.now() + timedelta(seconds=expires_in)
202
+
203
+ return credentials
204
+
205
+
206
+ async def refresh_instagram_token(
207
+ credentials: AuthCredentials,
208
+ ) -> AuthCredentials:
209
+ """Refresh Instagram long-lived access token.
210
+
211
+ Instagram uses a different refresh mechanism - exchanging the current
212
+ long-lived token for a new one.
213
+
214
+ Args:
215
+ credentials: Current credentials.
216
+
217
+ Returns:
218
+ Updated credentials with refreshed token.
219
+
220
+ Raises:
221
+ PlatformAuthError: If refresh fails.
222
+
223
+ Example:
224
+ >>> creds = AuthCredentials(
225
+ ... platform="instagram",
226
+ ... access_token="current_token"
227
+ ... )
228
+ >>> refreshed = await refresh_instagram_token(creds)
229
+ """
230
+ url = "https://graph.instagram.com/refresh_access_token"
231
+ params = {
232
+ "grant_type": "ig_refresh_token",
233
+ "access_token": credentials.access_token,
234
+ }
235
+
236
+ try:
237
+ async with httpx.AsyncClient() as client:
238
+ response = await client.get(url, params=params, timeout=30.0)
239
+ response.raise_for_status()
240
+ data = response.json()
241
+
242
+ # Update credentials
243
+ credentials.access_token = data["access_token"]
244
+
245
+ # Instagram returns expires_in
246
+ if "expires_in" in data:
247
+ expires_in = int(data["expires_in"])
248
+ credentials.expires_at = datetime.now() + timedelta(seconds=expires_in)
249
+
250
+ return credentials
251
+
252
+ except httpx.HTTPStatusError as e:
253
+ logger.error(f"HTTP error refreshing Instagram token: {e.response.status_code}")
254
+ raise PlatformAuthError(
255
+ f"Failed to refresh Instagram token: {e.response.text}",
256
+ platform="instagram",
257
+ status_code=e.response.status_code,
258
+ ) from e
259
+
260
+ except httpx.HTTPError as e:
261
+ logger.error(f"Network error refreshing Instagram token: {e}")
262
+ raise PlatformAuthError(
263
+ f"Network error refreshing Instagram token: {e}",
264
+ platform="instagram",
265
+ ) from e