marqetive-lib 0.1.1__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.
- marqetive/core/client.py +1 -1
- marqetive/platforms/exceptions.py +2 -1
- marqetive/platforms/models.py +2 -0
- marqetive/platforms/tiktok/__init__.py +7 -0
- marqetive/platforms/tiktok/client.py +277 -0
- marqetive/platforms/tiktok/exceptions.py +180 -0
- marqetive/platforms/tiktok/factory.py +188 -0
- marqetive/platforms/tiktok/manager.py +115 -0
- marqetive/platforms/tiktok/media.py +305 -0
- marqetive/platforms/twitter/client.py +1 -1
- marqetive/utils/oauth.py +135 -0
- {marqetive_lib-0.1.1.dist-info → marqetive_lib-0.1.2.dist-info}/METADATA +1 -2
- {marqetive_lib-0.1.1.dist-info → marqetive_lib-0.1.2.dist-info}/RECORD +14 -9
- {marqetive_lib-0.1.1.dist-info → marqetive_lib-0.1.2.dist-info}/WHEEL +0 -0
marqetive/core/client.py
CHANGED
|
@@ -73,7 +73,7 @@ class APIClient:
|
|
|
73
73
|
if not self._client:
|
|
74
74
|
raise RuntimeError("Client not initialized. Use async context manager.")
|
|
75
75
|
|
|
76
|
-
response
|
|
76
|
+
response = await self._client.get(path, params=params)
|
|
77
77
|
response.raise_for_status()
|
|
78
78
|
|
|
79
79
|
return APIResponse(
|
|
@@ -115,9 +115,10 @@ class PostNotFoundError(PlatformError):
|
|
|
115
115
|
post_id: str,
|
|
116
116
|
platform: str | None = None,
|
|
117
117
|
status_code: int | None = None,
|
|
118
|
+
message: str | None = None,
|
|
118
119
|
) -> None:
|
|
119
120
|
self.post_id = post_id
|
|
120
|
-
message = f"Post not found: {post_id}"
|
|
121
|
+
message = message or f"Post not found: {post_id}"
|
|
121
122
|
super().__init__(message, platform, status_code)
|
|
122
123
|
|
|
123
124
|
|
marqetive/platforms/models.py
CHANGED
|
@@ -305,6 +305,7 @@ class PostCreateRequest(BaseModel):
|
|
|
305
305
|
link: URL to include in the post
|
|
306
306
|
tags: List of hashtags or user tags
|
|
307
307
|
location: Location/place tag for the post
|
|
308
|
+
additional_data: Platform-specific data
|
|
308
309
|
|
|
309
310
|
Example:
|
|
310
311
|
>>> request = PostCreateRequest(
|
|
@@ -321,6 +322,7 @@ class PostCreateRequest(BaseModel):
|
|
|
321
322
|
link: str | None = None
|
|
322
323
|
tags: list[str] = Field(default_factory=list)
|
|
323
324
|
location: str | None = None
|
|
325
|
+
additional_data: dict[str, Any] = Field(default_factory=dict)
|
|
324
326
|
|
|
325
327
|
|
|
326
328
|
class PostUpdateRequest(BaseModel):
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""TikTok platform integration."""
|
|
2
|
+
|
|
3
|
+
from src.marqetive.platforms.tiktok.client import TikTokClient
|
|
4
|
+
from src.marqetive.platforms.tiktok.factory import TikTokAccountFactory
|
|
5
|
+
from src.marqetive.platforms.tiktok.manager import TikTokPostManager
|
|
6
|
+
|
|
7
|
+
__all__ = ["TikTokClient", "TikTokAccountFactory", "TikTokPostManager"]
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""TikTok API client implementation.
|
|
2
|
+
|
|
3
|
+
This module provides a concrete implementation of the SocialMediaPlatform
|
|
4
|
+
ABC for TikTok, using the TikTok for Business API (hypothetical).
|
|
5
|
+
|
|
6
|
+
API Documentation: https://developers.tiktok.com/doc/overview
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import HttpUrl
|
|
13
|
+
|
|
14
|
+
from src.marqetive.platforms.base import SocialMediaPlatform
|
|
15
|
+
from src.marqetive.platforms.exceptions import (
|
|
16
|
+
PlatformAuthError,
|
|
17
|
+
PlatformError,
|
|
18
|
+
PostNotFoundError,
|
|
19
|
+
ValidationError,
|
|
20
|
+
)
|
|
21
|
+
from src.marqetive.platforms.models import (
|
|
22
|
+
AuthCredentials,
|
|
23
|
+
Comment,
|
|
24
|
+
CommentStatus,
|
|
25
|
+
MediaAttachment,
|
|
26
|
+
MediaType,
|
|
27
|
+
Post,
|
|
28
|
+
PostCreateRequest,
|
|
29
|
+
PostStatus,
|
|
30
|
+
PostUpdateRequest,
|
|
31
|
+
)
|
|
32
|
+
from src.marqetive.platforms.tiktok.media import TikTokMediaManager
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TikTokClient(SocialMediaPlatform):
|
|
36
|
+
"""TikTok API client.
|
|
37
|
+
|
|
38
|
+
This client implements the SocialMediaPlatform interface for TikTok,
|
|
39
|
+
focusing on video uploads and management.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
credentials: AuthCredentials,
|
|
45
|
+
timeout: float = 300.0,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Initialize TikTok client."""
|
|
48
|
+
base_url = "https://open.tiktokapis.com/v2/"
|
|
49
|
+
super().__init__(
|
|
50
|
+
platform_name="tiktok",
|
|
51
|
+
credentials=credentials,
|
|
52
|
+
base_url=base_url,
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
)
|
|
55
|
+
self._media_manager: TikTokMediaManager | None = None
|
|
56
|
+
|
|
57
|
+
async def _setup_managers(self) -> None:
|
|
58
|
+
"""Setup media manager."""
|
|
59
|
+
if not self.credentials.access_token or not self.credentials.additional_data:
|
|
60
|
+
raise PlatformAuthError("Access token and open_id are required", "tiktok")
|
|
61
|
+
|
|
62
|
+
self._media_manager = TikTokMediaManager(
|
|
63
|
+
access_token=self.credentials.access_token,
|
|
64
|
+
open_id=self.credentials.additional_data["open_id"],
|
|
65
|
+
timeout=self.timeout,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async def _cleanup_managers(self) -> None:
|
|
69
|
+
"""Cleanup media manager."""
|
|
70
|
+
if self._media_manager:
|
|
71
|
+
await self._media_manager.__aexit__(None, None, None)
|
|
72
|
+
self._media_manager = None
|
|
73
|
+
|
|
74
|
+
async def __aenter__(self) -> "TikTokClient":
|
|
75
|
+
await super().__aenter__()
|
|
76
|
+
await self._setup_managers()
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
80
|
+
await self._cleanup_managers()
|
|
81
|
+
await super().__aexit__(exc_type, exc_val, exc_tb)
|
|
82
|
+
|
|
83
|
+
async def authenticate(self) -> AuthCredentials:
|
|
84
|
+
"""Perform TikTok authentication."""
|
|
85
|
+
if await self.is_authenticated():
|
|
86
|
+
return self.credentials
|
|
87
|
+
raise PlatformAuthError(
|
|
88
|
+
"Invalid or expired credentials. Please re-authenticate via TikTok OAuth.",
|
|
89
|
+
platform=self.platform_name,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
async def refresh_token(self) -> AuthCredentials:
|
|
93
|
+
"""Refresh TikTok access token."""
|
|
94
|
+
# The refresh logic should be handled by the AccountFactory
|
|
95
|
+
# For now, we assume it's done and we just return the creds
|
|
96
|
+
return self.credentials
|
|
97
|
+
|
|
98
|
+
async def is_authenticated(self) -> bool:
|
|
99
|
+
"""Check if TikTok credentials are valid."""
|
|
100
|
+
if not self.api_client or not self.credentials.additional_data:
|
|
101
|
+
return False
|
|
102
|
+
try:
|
|
103
|
+
# Verify credentials by fetching authenticated user info
|
|
104
|
+
params = {
|
|
105
|
+
"fields": "open_id,union_id,avatar_url,display_name",
|
|
106
|
+
"open_id": self.credentials.additional_data["open_id"],
|
|
107
|
+
}
|
|
108
|
+
response = await self.api_client.get("user/info/", params=params)
|
|
109
|
+
return response.data.get("data", {}).get("user") is not None
|
|
110
|
+
except PlatformError:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
async def create_post(self, request: PostCreateRequest) -> Post:
|
|
114
|
+
"""Create and publish a TikTok video.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
request: The post creation request, must contain a video URL.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The created Post object.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
ValidationError: If the request is invalid (e.g., no media).
|
|
124
|
+
MediaUploadError: If the video upload fails.
|
|
125
|
+
"""
|
|
126
|
+
if not self._media_manager or not self.api_client:
|
|
127
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
128
|
+
|
|
129
|
+
if not request.media_urls:
|
|
130
|
+
raise ValidationError(
|
|
131
|
+
"A video URL is required to create a TikTok post.",
|
|
132
|
+
platform=self.platform_name,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# 1. Upload the video
|
|
136
|
+
video_url = request.media_urls[0]
|
|
137
|
+
upload_result = await self._media_manager.upload_media(video_url)
|
|
138
|
+
|
|
139
|
+
if not upload_result.media_id:
|
|
140
|
+
raise PlatformError("Media upload succeeded but did not yield a media ID.")
|
|
141
|
+
|
|
142
|
+
# 2. Create the post with the uploaded video
|
|
143
|
+
payload = {
|
|
144
|
+
"post_info": {
|
|
145
|
+
"title": request.content or "",
|
|
146
|
+
"description": request.additional_data.get("description", ""),
|
|
147
|
+
"privacy_level": request.additional_data.get(
|
|
148
|
+
"privacy_level", "PUBLIC_TO_EVERYONE"
|
|
149
|
+
),
|
|
150
|
+
},
|
|
151
|
+
"source_info": {
|
|
152
|
+
"source": "FILE_UPLOAD",
|
|
153
|
+
"video_id": upload_result.media_id,
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
response = await self.api_client.post("video/publish/", data=payload)
|
|
157
|
+
post_id = response.data.get("data", {}).get("publish_id")
|
|
158
|
+
|
|
159
|
+
if not post_id:
|
|
160
|
+
raise PlatformError("Post creation succeeded but no publish_id returned.")
|
|
161
|
+
|
|
162
|
+
# 3. Fetch the created post to return a full Post object
|
|
163
|
+
return await self.get_post(post_id)
|
|
164
|
+
|
|
165
|
+
async def get_post(self, post_id: str) -> Post:
|
|
166
|
+
"""Retrieve a TikTok video by its ID."""
|
|
167
|
+
if not self.api_client or not self.credentials.additional_data:
|
|
168
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
169
|
+
try:
|
|
170
|
+
params = {
|
|
171
|
+
"open_id": self.credentials.additional_data["open_id"],
|
|
172
|
+
"video_ids": [post_id],
|
|
173
|
+
}
|
|
174
|
+
response = await self.api_client.post("video/query/", data=params)
|
|
175
|
+
video_data = response.data.get("data", {}).get("videos", [])
|
|
176
|
+
if not video_data:
|
|
177
|
+
raise PostNotFoundError(post_id, self.platform_name)
|
|
178
|
+
return self._parse_video_post(video_data[0])
|
|
179
|
+
except PlatformError as e:
|
|
180
|
+
raise PostNotFoundError(
|
|
181
|
+
post_id, self.platform_name, status_code=e.status_code
|
|
182
|
+
) from e
|
|
183
|
+
|
|
184
|
+
async def update_post(self, post_id: str, request: PostUpdateRequest) -> Post:
|
|
185
|
+
"""Update a TikTok video. This is not supported by TikTok API."""
|
|
186
|
+
raise PlatformError(
|
|
187
|
+
"TikTok API does not support updating videos.", self.platform_name
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async def delete_post(self, post_id: str) -> bool:
|
|
191
|
+
"""Delete a TikTok video. This is not supported by TikTok API."""
|
|
192
|
+
raise PlatformError(
|
|
193
|
+
"TikTok API does not support deleting videos.", self.platform_name
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
async def get_comments(
|
|
197
|
+
self, post_id: str, limit: int = 20, offset: int = 0
|
|
198
|
+
) -> list[Comment]:
|
|
199
|
+
"""Retrieve comments for a TikTok video."""
|
|
200
|
+
if not self.api_client or not self.credentials.additional_data:
|
|
201
|
+
raise RuntimeError("Client must be used as async context manager")
|
|
202
|
+
# This is a hypothetical endpoint
|
|
203
|
+
params = {
|
|
204
|
+
"open_id": self.credentials.additional_data["open_id"],
|
|
205
|
+
"video_id": post_id,
|
|
206
|
+
"count": limit,
|
|
207
|
+
"cursor": offset,
|
|
208
|
+
}
|
|
209
|
+
response = await self.api_client.get("video/comment/list/", params=params)
|
|
210
|
+
comments_data = response.data.get("data", {}).get("comments", [])
|
|
211
|
+
return [self._parse_comment(c, post_id) for c in comments_data]
|
|
212
|
+
|
|
213
|
+
async def create_comment(self, post_id: str, content: str) -> Comment:
|
|
214
|
+
"""Create a comment on a TikTok video."""
|
|
215
|
+
raise PlatformError("Commenting via API is not supported.", self.platform_name)
|
|
216
|
+
|
|
217
|
+
async def delete_comment(self, comment_id: str) -> bool:
|
|
218
|
+
"""Delete a comment on a TikTok video."""
|
|
219
|
+
raise PlatformError(
|
|
220
|
+
"Deleting comments via API is not supported.", self.platform_name
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def upload_media(
|
|
224
|
+
self, media_url: str, media_type: str, alt_text: str | None = None
|
|
225
|
+
) -> MediaAttachment:
|
|
226
|
+
"""Upload a video to TikTok."""
|
|
227
|
+
if not self._media_manager:
|
|
228
|
+
raise RuntimeError("Client not initialized. Use as async context manager.")
|
|
229
|
+
if media_type != "video":
|
|
230
|
+
raise ValidationError("Only video media type is supported for TikTok.")
|
|
231
|
+
|
|
232
|
+
result = await self._media_manager.upload_media(media_url)
|
|
233
|
+
return MediaAttachment(
|
|
234
|
+
media_id=result.media_id or result.upload_id,
|
|
235
|
+
media_type=MediaType.VIDEO,
|
|
236
|
+
url=HttpUrl(media_url),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _parse_video_post(self, video_data: dict[str, Any]) -> Post:
|
|
240
|
+
"""Parse a TikTok API video object into a Post model."""
|
|
241
|
+
return Post(
|
|
242
|
+
post_id=video_data["video_id"],
|
|
243
|
+
platform=self.platform_name,
|
|
244
|
+
content=video_data.get("title", ""),
|
|
245
|
+
media=[
|
|
246
|
+
MediaAttachment(
|
|
247
|
+
media_id=video_data["video_id"],
|
|
248
|
+
media_type=MediaType.VIDEO,
|
|
249
|
+
url=HttpUrl(video_data.get("share_url", "")),
|
|
250
|
+
width=video_data.get("width"),
|
|
251
|
+
height=video_data.get("height"),
|
|
252
|
+
)
|
|
253
|
+
],
|
|
254
|
+
status=PostStatus.PUBLISHED,
|
|
255
|
+
created_at=datetime.fromtimestamp(video_data.get("create_time", 0)),
|
|
256
|
+
author_id=str(video_data.get("open_id")),
|
|
257
|
+
likes_count=video_data.get("like_count", 0),
|
|
258
|
+
comments_count=video_data.get("comment_count", 0),
|
|
259
|
+
shares_count=video_data.get("share_count", 0),
|
|
260
|
+
views_count=video_data.get("view_count", 0),
|
|
261
|
+
raw_data=video_data,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _parse_comment(self, comment_data: dict[str, Any], post_id: str) -> Comment:
|
|
265
|
+
"""Parse a TikTok API comment object into a Comment model."""
|
|
266
|
+
return Comment(
|
|
267
|
+
comment_id=comment_data["comment_id"],
|
|
268
|
+
post_id=post_id,
|
|
269
|
+
platform=self.platform_name,
|
|
270
|
+
content=comment_data.get("text", ""),
|
|
271
|
+
author_id=comment_data.get("open_id", ""),
|
|
272
|
+
created_at=datetime.fromtimestamp(comment_data.get("create_time", 0)),
|
|
273
|
+
likes_count=comment_data.get("like_count", 0),
|
|
274
|
+
replies_count=0,
|
|
275
|
+
status=CommentStatus.VISIBLE,
|
|
276
|
+
raw_data=comment_data,
|
|
277
|
+
)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""TikTok-specific exception handling and error code mapping.
|
|
2
|
+
|
|
3
|
+
This module provides error handling for the TikTok API, including HTTP status
|
|
4
|
+
code mapping, TikTok-specific error codes, and user-friendly messages.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from src.marqetive.platforms.exceptions import (
|
|
10
|
+
MediaUploadError,
|
|
11
|
+
PlatformAuthError,
|
|
12
|
+
PlatformError,
|
|
13
|
+
PostNotFoundError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# TikTok API error codes (hypothetical, based on common API patterns)
|
|
20
|
+
# Source: TikTok Developer documentation (assumed)
|
|
21
|
+
class TikTokErrorCode:
|
|
22
|
+
"""TikTok API error codes."""
|
|
23
|
+
|
|
24
|
+
# Authentication & Authorization
|
|
25
|
+
INVALID_ACCESS_TOKEN = 10001
|
|
26
|
+
ACCESS_TOKEN_EXPIRED = 10002
|
|
27
|
+
SCOPE_NOT_AUTHORIZED = 10003
|
|
28
|
+
AUTHENTICATION_FAILED = 10004
|
|
29
|
+
ACCOUNT_NOT_AUTHORIZED = 10005
|
|
30
|
+
|
|
31
|
+
# Rate Limiting
|
|
32
|
+
RATE_LIMIT_EXCEEDED = 20001
|
|
33
|
+
TOO_MANY_REQUESTS = 20002
|
|
34
|
+
|
|
35
|
+
# Resource Not Found
|
|
36
|
+
VIDEO_NOT_FOUND = 30001
|
|
37
|
+
USER_NOT_FOUND = 30002
|
|
38
|
+
COMMENT_NOT_FOUND = 30003
|
|
39
|
+
|
|
40
|
+
# Validation Errors
|
|
41
|
+
INVALID_PARAMETER = 40001
|
|
42
|
+
VIDEO_TOO_LARGE = 40002
|
|
43
|
+
VIDEO_DURATION_INVALID = 40003
|
|
44
|
+
DESCRIPTION_TOO_LONG = 40004
|
|
45
|
+
DUPLICATE_VIDEO = 40005
|
|
46
|
+
INVALID_VIDEO_FORMAT = 40006
|
|
47
|
+
|
|
48
|
+
# Media Upload & Processing
|
|
49
|
+
UPLOAD_FAILED = 50001
|
|
50
|
+
INIT_UPLOAD_FAILED = 50002
|
|
51
|
+
APPEND_CHUNK_FAILED = 50003
|
|
52
|
+
FINALIZE_UPLOAD_FAILED = 50004
|
|
53
|
+
MEDIA_PROCESSING_FAILED = 50005
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Mapping of error codes to user-friendly messages
|
|
57
|
+
ERROR_MESSAGES: dict[int, str] = {
|
|
58
|
+
# Authentication
|
|
59
|
+
10001: "Invalid access token. Please re-authenticate.",
|
|
60
|
+
10002: "Access token has expired. Please refresh the token.",
|
|
61
|
+
10003: "The application is not authorized for the requested scope.",
|
|
62
|
+
10004: "Authentication failed. Please check your credentials.",
|
|
63
|
+
10005: "The user has not authorized this action for their account.",
|
|
64
|
+
# Rate Limiting
|
|
65
|
+
20001: "Rate limit exceeded. Please wait before making more requests.",
|
|
66
|
+
20002: "Too many requests. You have hit a rate limit.",
|
|
67
|
+
# Resource Not Found
|
|
68
|
+
30001: "The requested video could not be found.",
|
|
69
|
+
30002: "The specified user does not exist.",
|
|
70
|
+
30003: "The specified comment could not be found.",
|
|
71
|
+
# Validation
|
|
72
|
+
40001: "Invalid parameter in request.",
|
|
73
|
+
40002: "Video file size is too large.",
|
|
74
|
+
40003: "Video duration is outside the allowed range.",
|
|
75
|
+
40004: "Video description is too long.",
|
|
76
|
+
40005: "This video appears to be a duplicate of a previous post.",
|
|
77
|
+
40006: "Invalid video format. Please use a supported format like MP4 or MOV.",
|
|
78
|
+
# Media
|
|
79
|
+
50001: "Media upload failed.",
|
|
80
|
+
50005: "Media processing failed after upload.",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def map_tiktok_error(
|
|
85
|
+
status_code: int | None,
|
|
86
|
+
error_code: int | None = None,
|
|
87
|
+
error_message: str | None = None,
|
|
88
|
+
response_data: dict[str, Any] | None = None,
|
|
89
|
+
) -> PlatformError:
|
|
90
|
+
"""Map TikTok API error to the appropriate exception class.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
status_code: HTTP status code.
|
|
94
|
+
error_code: TikTok-specific error code from the response body.
|
|
95
|
+
error_message: Error message from the API.
|
|
96
|
+
response_data: Full response data from the API.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
An appropriate subclass of PlatformError.
|
|
100
|
+
"""
|
|
101
|
+
if response_data and "error" in response_data:
|
|
102
|
+
error_data = response_data["error"]
|
|
103
|
+
if not error_code:
|
|
104
|
+
error_code = error_data.get("code")
|
|
105
|
+
if not error_message:
|
|
106
|
+
error_message = error_data.get("message")
|
|
107
|
+
|
|
108
|
+
friendly_message = ERROR_MESSAGES.get(
|
|
109
|
+
error_code or 0, error_message or "An unknown TikTok API error occurred"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Map to exception types based on error code categories or status code
|
|
113
|
+
if error_code in (10001, 10002, 10003, 10004, 10005) or status_code in (401, 403):
|
|
114
|
+
return PlatformAuthError(
|
|
115
|
+
friendly_message,
|
|
116
|
+
platform="tiktok",
|
|
117
|
+
status_code=status_code,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if error_code in (20001, 20002) or status_code == 429:
|
|
121
|
+
retry_after = None
|
|
122
|
+
if response_data and response_data.get("extra"):
|
|
123
|
+
retry_after = response_data["extra"].get("log_id") # Placeholder
|
|
124
|
+
return RateLimitError(
|
|
125
|
+
friendly_message,
|
|
126
|
+
platform="tiktok",
|
|
127
|
+
status_code=status_code or 429,
|
|
128
|
+
retry_after=retry_after,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if error_code in (30001,):
|
|
132
|
+
return PostNotFoundError(
|
|
133
|
+
post_id="", # TikTok might use a different identifier
|
|
134
|
+
platform="tiktok",
|
|
135
|
+
status_code=status_code,
|
|
136
|
+
message=friendly_message,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if error_code in (40001, 40002, 40003, 40004, 40005, 40006):
|
|
140
|
+
return ValidationError(
|
|
141
|
+
friendly_message,
|
|
142
|
+
platform="tiktok",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
if error_code in (50001, 50002, 50003, 50004, 50005):
|
|
146
|
+
return MediaUploadError(
|
|
147
|
+
friendly_message,
|
|
148
|
+
platform="tiktok",
|
|
149
|
+
status_code=status_code,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Generic platform error for everything else
|
|
153
|
+
return PlatformError(
|
|
154
|
+
friendly_message,
|
|
155
|
+
platform="tiktok",
|
|
156
|
+
status_code=status_code,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TikTokAPIError(PlatformError):
|
|
161
|
+
"""TikTok API specific error with detailed information."""
|
|
162
|
+
|
|
163
|
+
def __init__(
|
|
164
|
+
self,
|
|
165
|
+
message: str,
|
|
166
|
+
*,
|
|
167
|
+
status_code: int | None = None,
|
|
168
|
+
error_code: int | None = None,
|
|
169
|
+
response_data: dict[str, Any] | None = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
super().__init__(message, platform="tiktok", status_code=status_code)
|
|
172
|
+
self.error_code = error_code
|
|
173
|
+
self.response_data = response_data
|
|
174
|
+
|
|
175
|
+
def __repr__(self) -> str:
|
|
176
|
+
return (
|
|
177
|
+
f"TikTokAPIError(message={self.message!r}, "
|
|
178
|
+
f"status_code={self.status_code}, "
|
|
179
|
+
f"error_code={self.error_code})"
|
|
180
|
+
)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""TikTok account factory for managing credentials and client creation."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from src.marqetive.core.account_factory import BaseAccountFactory
|
|
9
|
+
from src.marqetive.platforms.exceptions import PlatformAuthError
|
|
10
|
+
from src.marqetive.platforms.models import AccountStatus, AuthCredentials
|
|
11
|
+
from src.marqetive.platforms.tiktok.client import TikTokClient
|
|
12
|
+
from src.marqetive.utils.oauth import fetch_tiktok_token, refresh_tiktok_token
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TikTokAccountFactory(BaseAccountFactory):
|
|
18
|
+
"""Factory for creating and managing TikTok accounts and clients.
|
|
19
|
+
|
|
20
|
+
This factory handles the instantiation of TikTokClients, manages OAuth
|
|
21
|
+
credentials, and provides a mechanism for token refreshing.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
client_id: str | None = None,
|
|
27
|
+
client_secret: str | None = None,
|
|
28
|
+
on_status_update: Callable[[str, AccountStatus], None] | None = None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Initialize TikTok account factory.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
client_id: TikTok App client ID (uses TIKTOK_CLIENT_ID env if None).
|
|
34
|
+
client_secret: TikTok App client secret (uses TIKTOK_CLIENT_SECRET env if None).
|
|
35
|
+
on_status_update: Optional callback when account status changes.
|
|
36
|
+
"""
|
|
37
|
+
super().__init__(on_status_update=on_status_update)
|
|
38
|
+
self.client_id = client_id or os.getenv("TIKTOK_CLIENT_ID")
|
|
39
|
+
self.client_secret = client_secret or os.getenv("TIKTOK_CLIENT_SECRET")
|
|
40
|
+
|
|
41
|
+
if not self.client_id or not self.client_secret:
|
|
42
|
+
logger.warning(
|
|
43
|
+
"TikTok client_id/client_secret not provided. "
|
|
44
|
+
"Token refresh may not work."
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def platform_name(self) -> str:
|
|
49
|
+
"""Get the platform name."""
|
|
50
|
+
return "tiktok"
|
|
51
|
+
|
|
52
|
+
async def get_credentials_from_auth_code(
|
|
53
|
+
self,
|
|
54
|
+
auth_code: str,
|
|
55
|
+
redirect_uri: str,
|
|
56
|
+
code_verifier: str | None = None,
|
|
57
|
+
) -> AuthCredentials:
|
|
58
|
+
"""Get credentials from an authorization code.
|
|
59
|
+
|
|
60
|
+
This method exchanges an authorization code for an access token and
|
|
61
|
+
formats it into the standard AuthCredentials object.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
auth_code: The authorization code received from TikTok.
|
|
65
|
+
redirect_uri: The redirect URI used in the initial auth request.
|
|
66
|
+
code_verifier: The PKCE code verifier, if used.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
An AuthCredentials object for the user.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
PlatformAuthError: If the token exchange fails.
|
|
73
|
+
"""
|
|
74
|
+
if not self.client_id or not self.client_secret:
|
|
75
|
+
raise PlatformAuthError(
|
|
76
|
+
"TikTok client_id and client_secret are required.",
|
|
77
|
+
platform=self.platform_name,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
token_data = await fetch_tiktok_token(
|
|
81
|
+
code=auth_code,
|
|
82
|
+
client_id=self.client_id,
|
|
83
|
+
client_secret=self.client_secret,
|
|
84
|
+
redirect_uri=redirect_uri,
|
|
85
|
+
code_verifier=code_verifier,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
expires_at = None
|
|
89
|
+
if "expires_in" in token_data:
|
|
90
|
+
expires_at = datetime.now() + timedelta(seconds=token_data["expires_in"])
|
|
91
|
+
|
|
92
|
+
refresh_expires_at = None
|
|
93
|
+
if "refresh_expires_in" in token_data:
|
|
94
|
+
refresh_expires_at = datetime.now() + timedelta(
|
|
95
|
+
seconds=token_data["refresh_expires_in"]
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
credentials = AuthCredentials(
|
|
99
|
+
platform=self.platform_name,
|
|
100
|
+
access_token=token_data["access_token"],
|
|
101
|
+
refresh_token=token_data.get("refresh_token"),
|
|
102
|
+
expires_at=expires_at,
|
|
103
|
+
scope=token_data.get("scope", []),
|
|
104
|
+
additional_data={
|
|
105
|
+
"open_id": token_data["open_id"],
|
|
106
|
+
"token_type": token_data.get("token_type"),
|
|
107
|
+
"refresh_expires_at": refresh_expires_at,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
return credentials
|
|
111
|
+
|
|
112
|
+
async def refresh_token(self, credentials: AuthCredentials) -> AuthCredentials:
|
|
113
|
+
"""Refresh a TikTok OAuth2 access token.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
credentials: The current credentials containing the refresh token.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Updated credentials with a new access token.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
PlatformAuthError: If refresh fails or credentials are missing.
|
|
123
|
+
"""
|
|
124
|
+
if not self.client_id or not self.client_secret:
|
|
125
|
+
raise PlatformAuthError(
|
|
126
|
+
"TikTok client_id and client_secret are required for token refresh.",
|
|
127
|
+
platform=self.platform_name,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if not credentials.refresh_token:
|
|
131
|
+
raise PlatformAuthError(
|
|
132
|
+
"No refresh token available for TikTok.", platform=self.platform_name
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
logger.info("Refreshing TikTok access token...")
|
|
136
|
+
# This function would call the actual TikTok token refresh endpoint
|
|
137
|
+
return await refresh_tiktok_token(
|
|
138
|
+
credentials, self.client_id, self.client_secret
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async def create_client(self, credentials: AuthCredentials) -> TikTokClient:
|
|
142
|
+
"""Create a TikTok API client.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
credentials: Valid TikTok authentication credentials.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
An instance of TikTokClient.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
PlatformAuthError: If credentials are incomplete or invalid.
|
|
152
|
+
"""
|
|
153
|
+
if not credentials.access_token:
|
|
154
|
+
raise PlatformAuthError(
|
|
155
|
+
"Access token is required for TikTok.", platform=self.platform_name
|
|
156
|
+
)
|
|
157
|
+
if (
|
|
158
|
+
not credentials.additional_data
|
|
159
|
+
or "open_id" not in credentials.additional_data
|
|
160
|
+
):
|
|
161
|
+
raise PlatformAuthError(
|
|
162
|
+
"'open_id' must be provided in additional_data for TikTok.",
|
|
163
|
+
platform=self.platform_name,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return TikTokClient(credentials=credentials)
|
|
167
|
+
|
|
168
|
+
async def validate_credentials(self, credentials: AuthCredentials) -> bool:
|
|
169
|
+
"""Validate TikTok credentials by making a test API call.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
credentials: The credentials to validate.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
True if the credentials are valid, False otherwise.
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
client = await self.create_client(credentials)
|
|
179
|
+
async with client:
|
|
180
|
+
return await client.is_authenticated()
|
|
181
|
+
except PlatformAuthError as e:
|
|
182
|
+
logger.warning(f"TikTok credential validation failed: {e}")
|
|
183
|
+
return False
|
|
184
|
+
except Exception as e:
|
|
185
|
+
logger.error(
|
|
186
|
+
f"An unexpected error occurred during TikTok credential validation: {e}"
|
|
187
|
+
)
|
|
188
|
+
return False
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""TikTok post manager for handling video post operations."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from src.marqetive.core.base_manager import BasePostManager
|
|
7
|
+
from src.marqetive.platforms.models import AuthCredentials, Post, PostCreateRequest
|
|
8
|
+
from src.marqetive.platforms.tiktok.client import TikTokClient
|
|
9
|
+
from src.marqetive.platforms.tiktok.factory import TikTokAccountFactory
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TikTokPostManager(BasePostManager):
|
|
15
|
+
"""Manager for TikTok video post operations.
|
|
16
|
+
|
|
17
|
+
This manager coordinates the multi-step process of posting a video to
|
|
18
|
+
TikTok, including media upload, processing, and the final publishing step.
|
|
19
|
+
It provides progress tracking throughout the operation.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
account_factory: TikTokAccountFactory | None = None,
|
|
25
|
+
client_id: str | None = None,
|
|
26
|
+
client_secret: str | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Initialize the TikTok post manager.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
account_factory: An instance of TikTokAccountFactory. If not provided,
|
|
32
|
+
a default one will be created.
|
|
33
|
+
client_id: TikTok App client ID (for the default factory).
|
|
34
|
+
client_secret: TikTok App client secret (for the default factory).
|
|
35
|
+
"""
|
|
36
|
+
if account_factory is None:
|
|
37
|
+
account_factory = TikTokAccountFactory(
|
|
38
|
+
client_id=client_id,
|
|
39
|
+
client_secret=client_secret,
|
|
40
|
+
)
|
|
41
|
+
super().__init__(account_factory=account_factory)
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def platform_name(self) -> str:
|
|
45
|
+
"""Get the platform name."""
|
|
46
|
+
return "tiktok"
|
|
47
|
+
|
|
48
|
+
async def _execute_post_impl(
|
|
49
|
+
self,
|
|
50
|
+
client: Any,
|
|
51
|
+
request: PostCreateRequest,
|
|
52
|
+
credentials: AuthCredentials,
|
|
53
|
+
) -> Post:
|
|
54
|
+
"""Execute the TikTok video post creation process.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
client: An authenticated TikTokClient instance.
|
|
58
|
+
request: The post creation request.
|
|
59
|
+
credentials: The credentials used for authentication.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
The created Post object representing the TikTok video.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
TypeError: If the provided client is not a TikTokClient.
|
|
66
|
+
InterruptedError: If the operation is cancelled.
|
|
67
|
+
"""
|
|
68
|
+
if not isinstance(client, TikTokClient):
|
|
69
|
+
raise TypeError(f"Expected TikTokClient, got {type(client)}")
|
|
70
|
+
|
|
71
|
+
# The TikTok posting process is primarily about the video upload.
|
|
72
|
+
# The client's create_post method handles the upload and publish steps.
|
|
73
|
+
# We can wrap it here to provide progress updates.
|
|
74
|
+
|
|
75
|
+
self._progress_tracker.emit_start(
|
|
76
|
+
"execute_post", total=100, message="Starting TikTok post..."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if self.is_cancelled():
|
|
80
|
+
raise InterruptedError("Post creation was cancelled before start.")
|
|
81
|
+
|
|
82
|
+
# Media upload progress can be tracked inside the client, but for simplicity,
|
|
83
|
+
# we'll emit high-level progress here.
|
|
84
|
+
self._progress_tracker.emit_progress(
|
|
85
|
+
"execute_post",
|
|
86
|
+
progress=10,
|
|
87
|
+
total=100,
|
|
88
|
+
message="Uploading video to TikTok...",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# The `create_post` method in the client will handle the full flow:
|
|
92
|
+
# 1. Upload media
|
|
93
|
+
# 2. Publish post
|
|
94
|
+
# 3. Fetch final post data
|
|
95
|
+
# A more advanced implementation might involve callbacks from the client
|
|
96
|
+
# to the manager for more granular progress updates.
|
|
97
|
+
post = await client.create_post(request)
|
|
98
|
+
|
|
99
|
+
if self.is_cancelled():
|
|
100
|
+
# If cancellation happened during the client call, we might need cleanup.
|
|
101
|
+
# For now, we just raise.
|
|
102
|
+
raise InterruptedError("Post creation was cancelled during execution.")
|
|
103
|
+
|
|
104
|
+
self._progress_tracker.emit_progress(
|
|
105
|
+
"execute_post",
|
|
106
|
+
progress=90,
|
|
107
|
+
total=100,
|
|
108
|
+
message="Finalizing post...",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
self._progress_tracker.emit_complete(
|
|
112
|
+
"execute_post", message="TikTok post published successfully!"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return post
|
|
@@ -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
|
marqetive/utils/oauth.py
CHANGED
|
@@ -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
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: marqetive-lib
|
|
3
|
-
Version: 0.1.
|
|
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
|
|
@@ -2,12 +2,12 @@ marqetive/__init__.py,sha256=XVnYmB817AILAnJYg1WVNnmrd1MvJns3QpPbfdjmwRE,3142
|
|
|
2
2
|
marqetive/core/__init__.py,sha256=5Wgcby-EQkTxrPVD4so69nBmZ66Ng9JGqBov4RQMT4A,117
|
|
3
3
|
marqetive/core/account_factory.py,sha256=9fsP6fzLYp3UGU59fkxwURgzn7eBxr8-WJt3aP-Z65U,7153
|
|
4
4
|
marqetive/core/base_manager.py,sha256=z28bV4p1FC7s9g_YkXIKW_qkv8bjF4tMoF-cGGksJuA,10047
|
|
5
|
-
marqetive/core/client.py,sha256=
|
|
5
|
+
marqetive/core/client.py,sha256=2_FoNpqaRglsWg10i5RTbyDg_kRQKhgWjYs6iDdFxLg,3210
|
|
6
6
|
marqetive/core/progress.py,sha256=5Vyksf8YfQUVRudwDEuegcxoc0js5qbHM2SFZ6VLxHM,8593
|
|
7
7
|
marqetive/core/registry.py,sha256=D6qlafpvemYfE9GouWcWZyltK0aNcFLSi_2M-WdTIdE,8084
|
|
8
8
|
marqetive/platforms/__init__.py,sha256=6QjetNWodf32pgGQsmLKs7W-v-pG2kKckb5JEPqIPNI,1351
|
|
9
9
|
marqetive/platforms/base.py,sha256=zmCda00Szq_LswRdsWPI5V88TysLH3FIZ0mKwH6b8dE,11957
|
|
10
|
-
marqetive/platforms/exceptions.py,sha256=
|
|
10
|
+
marqetive/platforms/exceptions.py,sha256=Xyj0bzNiZm5VTErmzXgVW8T6IQnOpF92-HJiKPKjIio,7076
|
|
11
11
|
marqetive/platforms/instagram/__init__.py,sha256=9V0GPVm9HSTtDdV0bQEfuND8r7PEF2smOL3lb5BZevg,343
|
|
12
12
|
marqetive/platforms/instagram/client.py,sha256=PxVsHUW8SSpPe2Kdd5zGezD7LUvJj5tdyNj8In_ChxI,24955
|
|
13
13
|
marqetive/platforms/instagram/exceptions.py,sha256=zFG_CkstlZWG47zsFdnObOZviTDIx3Z8yVG9-x7GMVY,8987
|
|
@@ -20,10 +20,15 @@ marqetive/platforms/linkedin/exceptions.py,sha256=64J02_99cUcUW5i5xHE_sa0_6V4ibW
|
|
|
20
20
|
marqetive/platforms/linkedin/factory.py,sha256=8JNxAMC-lJcU1trnd0PFge0LQWQMiIVzZORJHwcBhmM,4536
|
|
21
21
|
marqetive/platforms/linkedin/manager.py,sha256=XB6HB6-MWsDz2Arij5GuyRH_j5xWkYb8sHCTlKFWzMg,4000
|
|
22
22
|
marqetive/platforms/linkedin/media.py,sha256=busrlTA3CisY43qDy9dmiAmbFVz8De4nDsA4CV9PeWk,16917
|
|
23
|
-
marqetive/platforms/models.py,sha256=
|
|
24
|
-
marqetive/platforms/tiktok/__init__.py,sha256=
|
|
23
|
+
marqetive/platforms/models.py,sha256=W_yOuRWItWSn82n8vXRNN_ScdNkzY1De2qqXaVN2RGU,10974
|
|
24
|
+
marqetive/platforms/tiktok/__init__.py,sha256=_lfPj-5VfNaL4cE39Uiv8QOI1DtRnKiP5feKdPQ53zI,313
|
|
25
|
+
marqetive/platforms/tiktok/client.py,sha256=p0uhPCyjykMj8mx0y3oCEOTNwEM_obr1CW8iLz-8AMw,10715
|
|
26
|
+
marqetive/platforms/tiktok/exceptions.py,sha256=SKzmAEIfBf9qeYPXdOFKL2ARx7MK_h57fyohbg_86vY,5780
|
|
27
|
+
marqetive/platforms/tiktok/factory.py,sha256=aqzyWe42i0R3Tvr2ml2xEYeW2e3USqIkz5-o8O5ae64,6764
|
|
28
|
+
marqetive/platforms/tiktok/manager.py,sha256=0bIL26NCppN5PFk0DKPaH2zyrm4wwA-Mfm8KQwq97UY,4113
|
|
29
|
+
marqetive/platforms/tiktok/media.py,sha256=B3QoE05xlQ0OSZ_1y_MiNX8bJFwbyM4jDvM4w7A-HHA,10652
|
|
25
30
|
marqetive/platforms/twitter/__init__.py,sha256=TRVOejA-wa_tw50_YUwTYnhGyGuOK2IFpZWvVZP94HQ,325
|
|
26
|
-
marqetive/platforms/twitter/client.py,sha256=
|
|
31
|
+
marqetive/platforms/twitter/client.py,sha256=s5nLUc3wOSEBCNuFCqxoLqUypBI6eDK6fRC1iP5OnZU,20856
|
|
27
32
|
marqetive/platforms/twitter/exceptions.py,sha256=yRLLcodSouaFV076iwbxcWZzXUuoswAOXuL_-iPvLOk,8935
|
|
28
33
|
marqetive/platforms/twitter/factory.py,sha256=nBBl5aFcNCgIwf45PWywjpx_Qp0aOdbOHthvQX-BzI4,5286
|
|
29
34
|
marqetive/platforms/twitter/manager.py,sha256=NEqFllDdQ6nlUQMV1M5koX8DMURuvNsa39gKiTpm5A0,4108
|
|
@@ -35,9 +40,9 @@ marqetive/utils/__init__.py,sha256=qBn_DN-L2YTfuB_G-n_bHJjtuVRiyH3ftl9JZ0JO93M,9
|
|
|
35
40
|
marqetive/utils/file_handlers.py,sha256=IUdRZxr_jxAuwxUypUkUgKqiGDcIHNr1LhCb2Ne2a98,12572
|
|
36
41
|
marqetive/utils/helpers.py,sha256=8-ljhL47SremKcQO2GF8DIHOPODEv1rSioVNuSPCbec,2634
|
|
37
42
|
marqetive/utils/media.py,sha256=Rvxw9XKU65n-z4G1bEihG3wXZBmjSDZUqClfjGFrg6k,12013
|
|
38
|
-
marqetive/utils/oauth.py,sha256=
|
|
43
|
+
marqetive/utils/oauth.py,sha256=B8GbzliQKEV5XpPshrKCHNlFl_arEOfZ10KKIUDKfQg,12113
|
|
39
44
|
marqetive/utils/retry.py,sha256=lAniJLMNWp9XsHrvU0XBNifpNEjfde4MGfd5hlFTPfA,7636
|
|
40
45
|
marqetive/utils/token_validator.py,sha256=asLMiEgT-BtqEpn_HDX15vJoSBcH7CGW0abervFOXxM,6707
|
|
41
|
-
marqetive_lib-0.1.
|
|
42
|
-
marqetive_lib-0.1.
|
|
43
|
-
marqetive_lib-0.1.
|
|
46
|
+
marqetive_lib-0.1.2.dist-info/METADATA,sha256=xk2oWdG9QJlzw1Mu49BWngovHJ4izI2N0pwZFKNzP-4,7798
|
|
47
|
+
marqetive_lib-0.1.2.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
48
|
+
marqetive_lib-0.1.2.dist-info/RECORD,,
|
|
File without changes
|