marqetive-lib 0.1.0__py3-none-any.whl → 0.1.2__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 (44) hide show
  1. marqetive/__init__.py +9 -9
  2. marqetive/core/__init__.py +1 -1
  3. marqetive/core/account_factory.py +2 -2
  4. marqetive/core/base_manager.py +4 -4
  5. marqetive/core/client.py +1 -1
  6. marqetive/core/registry.py +1 -1
  7. marqetive/platforms/__init__.py +3 -3
  8. marqetive/platforms/base.py +3 -3
  9. marqetive/platforms/exceptions.py +2 -1
  10. marqetive/platforms/instagram/__init__.py +3 -3
  11. marqetive/platforms/instagram/client.py +4 -4
  12. marqetive/platforms/instagram/exceptions.py +1 -1
  13. marqetive/platforms/instagram/factory.py +5 -5
  14. marqetive/platforms/instagram/manager.py +4 -4
  15. marqetive/platforms/instagram/media.py +2 -2
  16. marqetive/platforms/linkedin/__init__.py +3 -3
  17. marqetive/platforms/linkedin/client.py +4 -4
  18. marqetive/platforms/linkedin/exceptions.py +1 -1
  19. marqetive/platforms/linkedin/factory.py +5 -5
  20. marqetive/platforms/linkedin/manager.py +4 -4
  21. marqetive/platforms/linkedin/media.py +4 -4
  22. marqetive/platforms/models.py +2 -0
  23. marqetive/platforms/tiktok/__init__.py +7 -0
  24. marqetive/platforms/tiktok/client.py +277 -0
  25. marqetive/platforms/tiktok/exceptions.py +180 -0
  26. marqetive/platforms/tiktok/factory.py +188 -0
  27. marqetive/platforms/tiktok/manager.py +115 -0
  28. marqetive/platforms/tiktok/media.py +305 -0
  29. marqetive/platforms/twitter/__init__.py +3 -3
  30. marqetive/platforms/twitter/client.py +6 -6
  31. marqetive/platforms/twitter/exceptions.py +1 -1
  32. marqetive/platforms/twitter/factory.py +5 -5
  33. marqetive/platforms/twitter/manager.py +4 -4
  34. marqetive/platforms/twitter/media.py +4 -4
  35. marqetive/platforms/twitter/threads.py +2 -2
  36. marqetive/registry_init.py +4 -4
  37. marqetive/utils/__init__.py +3 -3
  38. marqetive/utils/file_handlers.py +1 -1
  39. marqetive/utils/oauth.py +137 -2
  40. marqetive/utils/token_validator.py +1 -1
  41. {marqetive_lib-0.1.0.dist-info → marqetive_lib-0.1.2.dist-info}/METADATA +1 -2
  42. marqetive_lib-0.1.2.dist-info/RECORD +48 -0
  43. marqetive_lib-0.1.0.dist-info/RECORD +0 -43
  44. {marqetive_lib-0.1.0.dist-info → marqetive_lib-0.1.2.dist-info}/WHEEL +0 -0
@@ -0,0 +1,305 @@
1
+ """TikTok media upload manager for handling video uploads.
2
+
3
+ This module provides functionality for uploading videos to TikTok's API,
4
+ focusing on a chunked upload process suitable for large video files.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+ import os
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from typing import Any, Literal
14
+
15
+ import httpx
16
+
17
+ from src.marqetive.platforms.exceptions import (
18
+ InvalidFileTypeError,
19
+ MediaUploadError,
20
+ )
21
+ from src.marqetive.utils.file_handlers import download_file
22
+ from src.marqetive.utils.media import (
23
+ detect_mime_type,
24
+ format_file_size,
25
+ get_chunk_count,
26
+ )
27
+ from src.marqetive.utils.retry import STANDARD_BACKOFF, retry_async
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Constants for TikTok Media Upload
32
+ DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024 # 5MB chunks
33
+ MAX_VIDEO_SIZE = 10 * 1024 * 1024 * 1024 # 10GB, hypothetical
34
+ MIN_VIDEO_DURATION_SECS = 3
35
+ MAX_VIDEO_DURATION_SECS = 600 # 10 minutes
36
+ DEFAULT_REQUEST_TIMEOUT = 300.0
37
+
38
+ # Hypothetical TikTok API endpoints for media
39
+ MEDIA_API_BASE_URL = "https://open.tiktokapis.com/v2/video"
40
+
41
+ # Supported MIME types for TikTok
42
+ SUPPORTED_VIDEO_TYPES = ["video/mp4", "video/quicktime"] # MOV
43
+
44
+
45
+ class ProcessingState(str, Enum):
46
+ """States for async media processing."""
47
+
48
+ PENDING = "pending"
49
+ PROCESSING = "processing"
50
+ READY = "ready"
51
+ FAILED = "failed"
52
+
53
+
54
+ @dataclass
55
+ class UploadProgress:
56
+ """Progress information for a media upload."""
57
+
58
+ upload_id: str | None
59
+ file_path: str
60
+ bytes_uploaded: int
61
+ total_bytes: int
62
+ status: Literal["init", "uploading", "processing", "completed", "failed"]
63
+
64
+ @property
65
+ def percentage(self) -> float:
66
+ if self.total_bytes == 0:
67
+ return 0.0
68
+ return (self.bytes_uploaded / self.total_bytes) * 100
69
+
70
+ def __str__(self) -> str:
71
+ return (
72
+ f"Upload Progress: {self.percentage:.1f}% "
73
+ f"({format_file_size(self.bytes_uploaded)} / "
74
+ f"{format_file_size(self.total_bytes)}) - {self.status}"
75
+ )
76
+
77
+
78
+ @dataclass
79
+ class MediaUploadResult:
80
+ """Result of a successful media upload."""
81
+
82
+ upload_id: str
83
+ media_id: str | None = None # Available after processing
84
+ size: int | None = None
85
+ processing_info: dict[str, Any] | None = None
86
+
87
+
88
+ class TikTokMediaManager:
89
+ """Manages video uploads to the TikTok API.
90
+
91
+ This class handles the complexities of chunked uploads for large video files
92
+ and monitors the processing status of the uploaded media.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ access_token: str,
98
+ open_id: str,
99
+ *,
100
+ progress_callback: Callable[[UploadProgress], None] | None = None,
101
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
102
+ ) -> None:
103
+ """Initialize the TikTok media manager."""
104
+ self.access_token = access_token
105
+ self.open_id = open_id
106
+ self.progress_callback = progress_callback
107
+ self.timeout = timeout
108
+ self.base_url = MEDIA_API_BASE_URL
109
+ self.client = httpx.AsyncClient(
110
+ timeout=httpx.Timeout(timeout),
111
+ headers={"Authorization": f"Bearer {access_token}"},
112
+ )
113
+
114
+ async def __aenter__(self) -> "TikTokMediaManager":
115
+ return self
116
+
117
+ async def __aexit__(self, *args: Any) -> None:
118
+ await self.client.aclose()
119
+
120
+ async def upload_media(
121
+ self,
122
+ file_path: str,
123
+ *,
124
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
125
+ wait_for_processing: bool = True,
126
+ ) -> MediaUploadResult:
127
+ """Upload a video file to TikTok.
128
+
129
+ Args:
130
+ file_path: The local path or URL of the video to upload.
131
+ chunk_size: The size of each chunk to upload in bytes.
132
+ wait_for_processing: If True, wait until the video is processed and ready.
133
+
134
+ Returns:
135
+ A MediaUploadResult containing the upload and media IDs.
136
+
137
+ Raises:
138
+ FileNotFoundError: If the local file does not exist.
139
+ InvalidFileTypeError: If the file is not a supported video format.
140
+ MediaUploadError: If the upload or processing fails.
141
+ """
142
+ if file_path.startswith(("http://", "https://")):
143
+ logger.info(f"Downloading media from URL: {file_path}")
144
+ file_path = await download_file(file_path)
145
+
146
+ if not os.path.exists(file_path):
147
+ raise FileNotFoundError(f"Media file not found: {file_path}")
148
+
149
+ mime_type = detect_mime_type(file_path)
150
+ file_size = os.path.getsize(file_path)
151
+ self._validate_media(mime_type, file_size)
152
+
153
+ # TikTok uses a one-step chunked upload
154
+ result = await self.chunked_upload(
155
+ file_path,
156
+ chunk_size=chunk_size,
157
+ )
158
+
159
+ # Note: TikTok's real API might have a different flow for processing.
160
+ # This is a hypothetical implementation.
161
+ if wait_for_processing:
162
+ await self._wait_for_processing(result, file_path)
163
+
164
+ return result
165
+
166
+ async def chunked_upload(
167
+ self, file_path: str, *, chunk_size: int = DEFAULT_CHUNK_SIZE
168
+ ) -> MediaUploadResult:
169
+ """Upload a video using the chunked upload method.
170
+
171
+ This is a simplified model of how a chunked upload to TikTok might work.
172
+ The actual API may differ.
173
+
174
+ Args:
175
+ file_path: Path to the video file.
176
+ chunk_size: Size of each upload chunk.
177
+
178
+ Returns:
179
+ MediaUploadResult with upload ID.
180
+ """
181
+ file_size = os.path.getsize(file_path)
182
+ chunk_count = get_chunk_count(file_path, chunk_size)
183
+ upload_id = None # Hypothetically obtained from an INIT step if needed
184
+
185
+ if self.progress_callback:
186
+ self.progress_callback(
187
+ UploadProgress(
188
+ upload_id=upload_id,
189
+ file_path=file_path,
190
+ bytes_uploaded=0,
191
+ total_bytes=file_size,
192
+ status="init",
193
+ )
194
+ )
195
+
196
+ @retry_async(config=STANDARD_BACKOFF)
197
+ async def _do_upload() -> MediaUploadResult:
198
+ bytes_uploaded = 0
199
+
200
+ # Hypothetical: The real API may require separate INIT/APPEND/FINALIZE steps.
201
+ # This implementation models a single POST with all chunks.
202
+ async def file_iterator():
203
+ nonlocal bytes_uploaded
204
+ with open(file_path, "rb") as f:
205
+ for i, chunk in enumerate(iter(lambda: f.read(chunk_size), b"")):
206
+ bytes_uploaded += len(chunk)
207
+ if self.progress_callback:
208
+ self.progress_callback(
209
+ UploadProgress(
210
+ upload_id=upload_id,
211
+ file_path=file_path,
212
+ bytes_uploaded=bytes_uploaded,
213
+ total_bytes=file_size,
214
+ status="uploading",
215
+ )
216
+ )
217
+ logger.debug(f"Uploading chunk {i + 1}/{chunk_count}")
218
+ yield chunk
219
+
220
+ # In a real scenario, you'd likely get an upload URL first.
221
+ upload_url = f"{self.base_url}/upload/"
222
+ response = await self.client.post(
223
+ upload_url,
224
+ params={"open_id": self.open_id},
225
+ content=file_iterator(),
226
+ headers={"Content-Type": "video/mp4"},
227
+ )
228
+ response.raise_for_status()
229
+ result_data = response.json()
230
+ upload_id = result_data.get("data", {}).get("video", {}).get("video_id")
231
+
232
+ if not upload_id:
233
+ raise MediaUploadError(
234
+ "Upload succeeded but did not return a video ID.", platform="tiktok"
235
+ )
236
+
237
+ result = MediaUploadResult(
238
+ upload_id=upload_id,
239
+ media_id=upload_id, # Assume upload_id is the media_id
240
+ size=file_size,
241
+ )
242
+
243
+ if self.progress_callback:
244
+ self.progress_callback(
245
+ UploadProgress(
246
+ upload_id=upload_id,
247
+ file_path=file_path,
248
+ bytes_uploaded=file_size,
249
+ total_bytes=file_size,
250
+ status="completed",
251
+ )
252
+ )
253
+
254
+ logger.info(f"Chunked upload completed for TikTok: {upload_id}")
255
+ return result
256
+
257
+ try:
258
+ return await _do_upload()
259
+ except httpx.HTTPError as e:
260
+ raise MediaUploadError(
261
+ f"TikTok video upload failed: {e}",
262
+ platform="tiktok",
263
+ media_type=detect_mime_type(file_path),
264
+ ) from e
265
+
266
+ async def _wait_for_processing(
267
+ self, result: MediaUploadResult, file_path: str
268
+ ) -> None:
269
+ """Wait for async video processing to complete.
270
+
271
+ This is a hypothetical implementation.
272
+ """
273
+ logger.info(f"Waiting for TikTok video processing: {result.upload_id}")
274
+ # In a real implementation, you would poll a status endpoint.
275
+ # Here, we'll just simulate a delay.
276
+ await asyncio.sleep(5) # Simulate processing time
277
+ if self.progress_callback:
278
+ self.progress_callback(
279
+ UploadProgress(
280
+ upload_id=result.upload_id,
281
+ file_path=file_path,
282
+ bytes_uploaded=os.path.getsize(file_path),
283
+ total_bytes=os.path.getsize(file_path),
284
+ status="processing",
285
+ )
286
+ )
287
+ logger.info(f"TikTok video processing assumed complete: {result.upload_id}")
288
+
289
+ def _validate_media(self, mime_type: str, file_size: int) -> None:
290
+ """Validate media type and size against TikTok's requirements."""
291
+ if mime_type not in SUPPORTED_VIDEO_TYPES:
292
+ raise InvalidFileTypeError(
293
+ f"Unsupported video type for TikTok: {mime_type}. "
294
+ f"Supported types are: {', '.join(SUPPORTED_VIDEO_TYPES)}",
295
+ platform="tiktok",
296
+ )
297
+
298
+ if file_size > MAX_VIDEO_SIZE:
299
+ raise MediaUploadError(
300
+ f"Video file size ({format_file_size(file_size)}) exceeds the "
301
+ f"TikTok limit of {format_file_size(MAX_VIDEO_SIZE)}",
302
+ platform="tiktok",
303
+ media_type=mime_type,
304
+ )
305
+ # Add duration validation logic here if possible to check before upload
@@ -1,7 +1,7 @@
1
1
  """Twitter/X platform integration."""
2
2
 
3
- from marqetive.platforms.twitter.client import TwitterClient
4
- from marqetive.platforms.twitter.factory import TwitterAccountFactory
5
- from marqetive.platforms.twitter.manager import TwitterPostManager
3
+ from src.marqetive.platforms.twitter.client import TwitterClient
4
+ from src.marqetive.platforms.twitter.factory import TwitterAccountFactory
5
+ from src.marqetive.platforms.twitter.manager import TwitterPostManager
6
6
 
7
7
  __all__ = ["TwitterClient", "TwitterAccountFactory", "TwitterPostManager"]
@@ -13,8 +13,8 @@ import httpx
13
13
  import tweepy
14
14
  from pydantic import HttpUrl
15
15
 
16
- from marqetive.platforms.base import SocialMediaPlatform
17
- from marqetive.platforms.exceptions import (
16
+ from src.marqetive.platforms.base import SocialMediaPlatform
17
+ from src.marqetive.platforms.exceptions import (
18
18
  MediaUploadError,
19
19
  PlatformAuthError,
20
20
  PlatformError,
@@ -22,7 +22,7 @@ from marqetive.platforms.exceptions import (
22
22
  RateLimitError,
23
23
  ValidationError,
24
24
  )
25
- from marqetive.platforms.models import (
25
+ from src.marqetive.platforms.models import (
26
26
  AuthCredentials,
27
27
  Comment,
28
28
  CommentStatus,
@@ -33,8 +33,8 @@ from marqetive.platforms.models import (
33
33
  PostStatus,
34
34
  PostUpdateRequest,
35
35
  )
36
- from marqetive.platforms.twitter.media import TwitterMediaManager
37
- from marqetive.platforms.twitter.threads import (
36
+ from src.marqetive.platforms.twitter.media import TwitterMediaManager
37
+ from src.marqetive.platforms.twitter.threads import (
38
38
  ThreadResult,
39
39
  TweetData,
40
40
  TwitterThreadManager,
@@ -345,7 +345,7 @@ class TwitterClient(SocialMediaPlatform):
345
345
 
346
346
  async def update_post(
347
347
  self,
348
- post_id: str,
348
+ post_id: str, # noqa: ARG002
349
349
  request: PostUpdateRequest, # noqa: ARG002
350
350
  ) -> Post:
351
351
  """Update a tweet.
@@ -9,7 +9,7 @@ This module provides comprehensive error handling for Twitter API errors includi
9
9
 
10
10
  from typing import Any
11
11
 
12
- from marqetive.platforms.exceptions import (
12
+ from src.marqetive.platforms.exceptions import (
13
13
  MediaUploadError,
14
14
  PlatformAuthError,
15
15
  PlatformError,
@@ -4,11 +4,11 @@ import logging
4
4
  import os
5
5
  from collections.abc import Callable
6
6
 
7
- from marqetive.core.account_factory import BaseAccountFactory
8
- from marqetive.platforms.exceptions import PlatformAuthError
9
- from marqetive.platforms.models import AccountStatus, AuthCredentials
10
- from marqetive.platforms.twitter.client import TwitterClient
11
- from marqetive.utils.oauth import refresh_twitter_token
7
+ from src.marqetive.core.account_factory import BaseAccountFactory
8
+ from src.marqetive.platforms.exceptions import PlatformAuthError
9
+ from src.marqetive.platforms.models import AccountStatus, AuthCredentials
10
+ from src.marqetive.platforms.twitter.client import TwitterClient
11
+ from src.marqetive.utils.oauth import refresh_twitter_token
12
12
 
13
13
  logger = logging.getLogger(__name__)
14
14
 
@@ -3,10 +3,10 @@
3
3
  import logging
4
4
  from typing import Any
5
5
 
6
- from marqetive.core.base_manager import BasePostManager
7
- from marqetive.platforms.models import AuthCredentials, Post, PostCreateRequest
8
- from marqetive.platforms.twitter.client import TwitterClient
9
- from marqetive.platforms.twitter.factory import TwitterAccountFactory
6
+ from src.marqetive.core.base_manager import BasePostManager
7
+ from src.marqetive.platforms.models import AuthCredentials, Post, PostCreateRequest
8
+ from src.marqetive.platforms.twitter.client import TwitterClient
9
+ from src.marqetive.platforms.twitter.factory import TwitterAccountFactory
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -18,17 +18,17 @@ from typing import Any, Literal
18
18
 
19
19
  import httpx
20
20
 
21
- from marqetive.platforms.exceptions import (
21
+ from src.marqetive.platforms.exceptions import (
22
22
  InvalidFileTypeError,
23
23
  MediaUploadError,
24
24
  )
25
- from marqetive.utils.file_handlers import download_file
26
- from marqetive.utils.media import (
25
+ from src.marqetive.utils.file_handlers import download_file
26
+ from src.marqetive.utils.media import (
27
27
  detect_mime_type,
28
28
  format_file_size,
29
29
  get_chunk_count,
30
30
  )
31
- from marqetive.utils.retry import STANDARD_BACKOFF, retry_async
31
+ from src.marqetive.utils.retry import STANDARD_BACKOFF, retry_async
32
32
 
33
33
  logger = logging.getLogger(__name__)
34
34
 
@@ -9,8 +9,8 @@ from typing import Any
9
9
 
10
10
  import tweepy
11
11
 
12
- from marqetive.platforms.exceptions import PlatformError, ValidationError
13
- from marqetive.platforms.models import Post, PostStatus
12
+ from src.marqetive.platforms.exceptions import PlatformError, ValidationError
13
+ from src.marqetive.platforms.models import Post, PostStatus
14
14
 
15
15
 
16
16
  @dataclass
@@ -6,10 +6,10 @@ platform managers with the global registry.
6
6
 
7
7
  import logging
8
8
 
9
- from marqetive.core.registry import register_platform
10
- from marqetive.platforms.instagram.manager import InstagramPostManager
11
- from marqetive.platforms.linkedin.manager import LinkedInPostManager
12
- from marqetive.platforms.twitter.manager import TwitterPostManager
9
+ from src.marqetive.core.registry import register_platform
10
+ from src.marqetive.platforms.instagram.manager import InstagramPostManager
11
+ from src.marqetive.platforms.linkedin.manager import LinkedInPostManager
12
+ from src.marqetive.platforms.twitter.manager import TwitterPostManager
13
13
 
14
14
  logger = logging.getLogger(__name__)
15
15
 
@@ -1,6 +1,6 @@
1
1
  """Utility functions for MarqetiveLib."""
2
2
 
3
- from marqetive.utils.file_handlers import (
3
+ from src.marqetive.utils.file_handlers import (
4
4
  TempFileManager,
5
5
  download_file,
6
6
  download_to_memory,
@@ -9,8 +9,8 @@ from marqetive.utils.file_handlers import (
9
9
  stream_file_upload,
10
10
  write_file_bytes,
11
11
  )
12
- from marqetive.utils.helpers import format_response, parse_query_params
13
- from marqetive.utils.media import (
12
+ from src.marqetive.utils.helpers import format_response, parse_query_params
13
+ from src.marqetive.utils.media import (
14
14
  MediaValidator,
15
15
  chunk_file,
16
16
  detect_mime_type,
@@ -17,7 +17,7 @@ from typing import Any
17
17
  import aiofiles
18
18
  import httpx
19
19
 
20
- from marqetive.utils.media import detect_mime_type, format_file_size
20
+ from src.marqetive.utils.media import detect_mime_type, format_file_size
21
21
 
22
22
 
23
23
  class DownloadProgress:
marqetive/utils/oauth.py CHANGED
@@ -10,8 +10,8 @@ from typing import Any
10
10
 
11
11
  import httpx
12
12
 
13
- from marqetive.platforms.exceptions import PlatformAuthError
14
- from marqetive.platforms.models import AuthCredentials
13
+ from src.marqetive.platforms.exceptions import PlatformAuthError
14
+ from src.marqetive.platforms.models import AuthCredentials
15
15
 
16
16
  logger = logging.getLogger(__name__)
17
17
 
@@ -263,3 +263,138 @@ async def refresh_instagram_token(
263
263
  f"Network error refreshing Instagram token: {e}",
264
264
  platform="instagram",
265
265
  ) from e
266
+
267
+
268
+ async def refresh_tiktok_token(
269
+ credentials: AuthCredentials,
270
+ client_id: str,
271
+ client_secret: str,
272
+ ) -> AuthCredentials:
273
+ """Refresh TikTok OAuth2 access token.
274
+
275
+ Args:
276
+ credentials: Current credentials with refresh token.
277
+ client_id: TikTok OAuth client ID (client_key).
278
+ client_secret: TikTok OAuth client secret.
279
+
280
+ Returns:
281
+ Updated credentials with new access token.
282
+
283
+ Raises:
284
+ PlatformAuthError: If refresh fails.
285
+ """
286
+ if not credentials.refresh_token:
287
+ raise PlatformAuthError(
288
+ "No refresh token available",
289
+ platform="tiktok",
290
+ )
291
+
292
+ token_url = "https://open.tiktokapis.com/v2/oauth/token/"
293
+
294
+ params = {
295
+ "client_key": client_id,
296
+ "client_secret": client_secret,
297
+ "grant_type": "refresh_token",
298
+ "refresh_token": credentials.refresh_token,
299
+ }
300
+
301
+ try:
302
+ async with httpx.AsyncClient() as client:
303
+ response = await client.post(
304
+ token_url,
305
+ data=params,
306
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
307
+ timeout=30.0,
308
+ )
309
+ response.raise_for_status()
310
+ token_data = response.json()
311
+
312
+ except httpx.HTTPStatusError as e:
313
+ logger.error(f"HTTP error refreshing tiktok token: {e.response.status_code}")
314
+ raise PlatformAuthError(
315
+ f"Failed to refresh token: {e.response.text}",
316
+ platform="tiktok",
317
+ status_code=e.response.status_code,
318
+ ) from e
319
+
320
+ except httpx.HTTPError as e:
321
+ logger.error(f"Network error refreshing tiktok token: {e}")
322
+ raise PlatformAuthError(
323
+ f"Network error refreshing token: {e}",
324
+ platform="tiktok",
325
+ ) from e
326
+
327
+ # Update credentials
328
+ credentials.access_token = token_data["access_token"]
329
+
330
+ # TikTok might provide new refresh token
331
+ if "refresh_token" in token_data:
332
+ credentials.refresh_token = token_data["refresh_token"]
333
+
334
+ # Calculate expiry
335
+ if "expires_in" in token_data:
336
+ expires_in = int(token_data["expires_in"])
337
+ credentials.expires_at = datetime.now() + timedelta(seconds=expires_in)
338
+
339
+ return credentials
340
+
341
+
342
+ async def fetch_tiktok_token(
343
+ code: str,
344
+ client_id: str,
345
+ client_secret: str,
346
+ redirect_uri: str,
347
+ code_verifier: str | None = None,
348
+ ) -> dict[str, Any]:
349
+ """Fetch a TikTok OAuth2 access token using an authorization code.
350
+
351
+ Args:
352
+ code: The authorization code from the callback.
353
+ client_id: TikTok OAuth client ID (client_key).
354
+ client_secret: TikTok OAuth client secret.
355
+ redirect_uri: The redirect URI used for the authorization request.
356
+ code_verifier: PKCE code verifier for mobile/desktop apps.
357
+
358
+ Returns:
359
+ Token response dictionary with access_token, refresh_token, etc.
360
+
361
+ Raises:
362
+ PlatformAuthError: If token fetch fails.
363
+ """
364
+ token_url = "https://open.tiktokapis.com/v2/oauth/token/"
365
+
366
+ params = {
367
+ "client_key": client_id,
368
+ "client_secret": client_secret,
369
+ "code": code,
370
+ "grant_type": "authorization_code",
371
+ "redirect_uri": redirect_uri,
372
+ }
373
+ if code_verifier:
374
+ params["code_verifier"] = code_verifier
375
+
376
+ try:
377
+ async with httpx.AsyncClient() as client:
378
+ response = await client.post(
379
+ token_url,
380
+ data=params,
381
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
382
+ timeout=30.0,
383
+ )
384
+ response.raise_for_status()
385
+ return response.json()
386
+
387
+ except httpx.HTTPStatusError as e:
388
+ logger.error(f"HTTP error fetching tiktok token: {e.response.status_code}")
389
+ raise PlatformAuthError(
390
+ f"Failed to fetch token: {e.response.text}",
391
+ platform="tiktok",
392
+ status_code=e.response.status_code,
393
+ ) from e
394
+
395
+ except httpx.HTTPError as e:
396
+ logger.error(f"Network error fetching tiktok token: {e}")
397
+ raise PlatformAuthError(
398
+ f"Network error fetching token: {e}",
399
+ platform="tiktok",
400
+ ) from e
@@ -8,7 +8,7 @@ import re
8
8
  from datetime import datetime, timedelta
9
9
  from typing import Any
10
10
 
11
- from marqetive.platforms.models import AuthCredentials
11
+ from src.marqetive.platforms.models import AuthCredentials
12
12
 
13
13
 
14
14
  def is_token_expired(
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: marqetive-lib
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Modern Python utilities for web APIs
5
5
  Keywords: api,utilities,web,http,marqetive
6
6
  Requires-Python: >=3.12
7
7
  Classifier: Development Status :: 3 - Alpha
8
8
  Classifier: Intended Audience :: Developers
9
- Classifier: License :: OSI Approved :: MIT License
10
9
  Classifier: Programming Language :: Python :: 3
11
10
  Classifier: Programming Language :: Python :: 3.12
12
11
  Classifier: Programming Language :: Python :: 3.13