marqetive-lib 0.1.7__py3-none-any.whl → 0.1.9__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 +15 -0
- marqetive/platforms/instagram/client.py +29 -8
- marqetive/platforms/tiktok/client.py +24 -12
- marqetive/platforms/tiktok/media.py +114 -55
- marqetive/platforms/tiktok/models.py +17 -20
- marqetive/platforms/twitter/media.py +6 -5
- marqetive/utils/media.py +86 -0
- marqetive/utils/oauth.py +31 -4
- {marqetive_lib-0.1.7.dist-info → marqetive_lib-0.1.9.dist-info}/METADATA +1 -1
- {marqetive_lib-0.1.7.dist-info → marqetive_lib-0.1.9.dist-info}/RECORD +11 -11
- {marqetive_lib-0.1.7.dist-info → marqetive_lib-0.1.9.dist-info}/WHEEL +0 -0
marqetive/core/client.py
CHANGED
|
@@ -5,6 +5,11 @@ from typing import Any
|
|
|
5
5
|
import httpx
|
|
6
6
|
from pydantic import BaseModel
|
|
7
7
|
|
|
8
|
+
# Default connection pool limits for optimal performance
|
|
9
|
+
DEFAULT_MAX_CONNECTIONS = 100
|
|
10
|
+
DEFAULT_MAX_KEEPALIVE_CONNECTIONS = 20
|
|
11
|
+
DEFAULT_KEEPALIVE_EXPIRY = 30.0
|
|
12
|
+
|
|
8
13
|
|
|
9
14
|
class APIResponse(BaseModel):
|
|
10
15
|
"""Response model for API calls."""
|
|
@@ -24,6 +29,8 @@ class APIClient:
|
|
|
24
29
|
base_url: The base URL for API requests
|
|
25
30
|
timeout: Request timeout in seconds (default: 30)
|
|
26
31
|
headers: Optional default headers for all requests
|
|
32
|
+
max_connections: Maximum total connections in pool (default: 100)
|
|
33
|
+
max_keepalive_connections: Maximum persistent connections (default: 20)
|
|
27
34
|
|
|
28
35
|
Example:
|
|
29
36
|
>>> client = APIClient(base_url="https://api.example.com")
|
|
@@ -36,12 +43,19 @@ class APIClient:
|
|
|
36
43
|
base_url: str,
|
|
37
44
|
timeout: float = 30.0,
|
|
38
45
|
headers: dict[str, str] | None = None,
|
|
46
|
+
max_connections: int = DEFAULT_MAX_CONNECTIONS,
|
|
47
|
+
max_keepalive_connections: int = DEFAULT_MAX_KEEPALIVE_CONNECTIONS,
|
|
39
48
|
) -> None:
|
|
40
49
|
"""Initialize the API client."""
|
|
41
50
|
self.base_url = base_url.rstrip("/")
|
|
42
51
|
self.timeout = timeout
|
|
43
52
|
self.default_headers = headers or {}
|
|
44
53
|
self._client: httpx.AsyncClient | None = None
|
|
54
|
+
self._limits = httpx.Limits(
|
|
55
|
+
max_connections=max_connections,
|
|
56
|
+
max_keepalive_connections=max_keepalive_connections,
|
|
57
|
+
keepalive_expiry=DEFAULT_KEEPALIVE_EXPIRY,
|
|
58
|
+
)
|
|
45
59
|
|
|
46
60
|
async def __aenter__(self) -> "APIClient":
|
|
47
61
|
"""Async context manager entry."""
|
|
@@ -49,6 +63,7 @@ class APIClient:
|
|
|
49
63
|
base_url=self.base_url,
|
|
50
64
|
timeout=self.timeout,
|
|
51
65
|
headers=self.default_headers,
|
|
66
|
+
limits=self._limits,
|
|
52
67
|
)
|
|
53
68
|
return self
|
|
54
69
|
|
|
@@ -35,6 +35,7 @@ from marqetive.platforms.instagram.media import (
|
|
|
35
35
|
InstagramMediaManager,
|
|
36
36
|
MediaItem,
|
|
37
37
|
)
|
|
38
|
+
from marqetive.utils.media import validate_media_url
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
class InstagramClient(SocialMediaPlatform):
|
|
@@ -245,7 +246,11 @@ class InstagramClient(SocialMediaPlatform):
|
|
|
245
246
|
}
|
|
246
247
|
|
|
247
248
|
if request.media_urls:
|
|
248
|
-
|
|
249
|
+
# Validate URL to prevent SSRF attacks
|
|
250
|
+
validated_url = validate_media_url(
|
|
251
|
+
request.media_urls[0], platform=self.platform_name
|
|
252
|
+
)
|
|
253
|
+
container_params["image_url"] = validated_url
|
|
249
254
|
|
|
250
255
|
if request.content:
|
|
251
256
|
container_params["caption"] = request.content
|
|
@@ -533,14 +538,17 @@ class InstagramClient(SocialMediaPlatform):
|
|
|
533
538
|
if not self.api_client:
|
|
534
539
|
raise RuntimeError("Client must be used as async context manager")
|
|
535
540
|
|
|
541
|
+
# Validate URL to prevent SSRF attacks
|
|
542
|
+
validated_url = validate_media_url(media_url, platform=self.platform_name)
|
|
543
|
+
|
|
536
544
|
params: dict[str, Any] = {
|
|
537
545
|
"access_token": self.credentials.access_token,
|
|
538
546
|
}
|
|
539
547
|
|
|
540
548
|
if media_type.lower() == "image":
|
|
541
|
-
params["image_url"] =
|
|
549
|
+
params["image_url"] = validated_url
|
|
542
550
|
elif media_type.lower() == "video":
|
|
543
|
-
params["video_url"] =
|
|
551
|
+
params["video_url"] = validated_url
|
|
544
552
|
params["media_type"] = "VIDEO"
|
|
545
553
|
else:
|
|
546
554
|
raise ValidationError(
|
|
@@ -612,13 +620,15 @@ class InstagramClient(SocialMediaPlatform):
|
|
|
612
620
|
if not self._media_manager:
|
|
613
621
|
raise RuntimeError("Client must be used as async context manager")
|
|
614
622
|
|
|
615
|
-
# Convert to MediaItem objects
|
|
623
|
+
# Convert to MediaItem objects with URL validation
|
|
616
624
|
media_items = []
|
|
617
625
|
for idx, url in enumerate(media_urls):
|
|
626
|
+
# Validate each URL to prevent SSRF attacks
|
|
627
|
+
validated_url = validate_media_url(url, platform=self.platform_name)
|
|
618
628
|
alt_text = None
|
|
619
629
|
if alt_texts and idx < len(alt_texts):
|
|
620
630
|
alt_text = alt_texts[idx]
|
|
621
|
-
media_items.append(MediaItem(url=
|
|
631
|
+
media_items.append(MediaItem(url=validated_url, type="image", alt_text=alt_text))
|
|
622
632
|
|
|
623
633
|
# Create containers
|
|
624
634
|
container_ids = await self._media_manager.create_feed_containers(
|
|
@@ -667,11 +677,19 @@ class InstagramClient(SocialMediaPlatform):
|
|
|
667
677
|
if not self._media_manager:
|
|
668
678
|
raise RuntimeError("Client must be used as async context manager")
|
|
669
679
|
|
|
680
|
+
# Validate URLs to prevent SSRF attacks
|
|
681
|
+
validated_video_url = validate_media_url(video_url, platform=self.platform_name)
|
|
682
|
+
validated_cover_url = None
|
|
683
|
+
if cover_url:
|
|
684
|
+
validated_cover_url = validate_media_url(
|
|
685
|
+
cover_url, platform=self.platform_name
|
|
686
|
+
)
|
|
687
|
+
|
|
670
688
|
# Create reel container
|
|
671
689
|
container_id = await self._media_manager.create_reel_container(
|
|
672
|
-
|
|
690
|
+
validated_video_url,
|
|
673
691
|
caption=caption,
|
|
674
|
-
cover_url=
|
|
692
|
+
cover_url=validated_cover_url,
|
|
675
693
|
share_to_feed=share_to_feed,
|
|
676
694
|
wait_for_processing=True,
|
|
677
695
|
)
|
|
@@ -710,9 +728,12 @@ class InstagramClient(SocialMediaPlatform):
|
|
|
710
728
|
if not self._media_manager:
|
|
711
729
|
raise RuntimeError("Client must be used as async context manager")
|
|
712
730
|
|
|
731
|
+
# Validate URL to prevent SSRF attacks
|
|
732
|
+
validated_url = validate_media_url(media_url, platform=self.platform_name)
|
|
733
|
+
|
|
713
734
|
# Create story container
|
|
714
735
|
container_id = await self._media_manager.create_story_container(
|
|
715
|
-
|
|
736
|
+
validated_url,
|
|
716
737
|
media_type,
|
|
717
738
|
wait_for_processing=(media_type == "video"),
|
|
718
739
|
)
|
|
@@ -33,9 +33,9 @@ from marqetive.platforms.tiktok.exceptions import TikTokErrorCode, map_tiktok_er
|
|
|
33
33
|
from marqetive.platforms.tiktok.media import (
|
|
34
34
|
CreatorInfo,
|
|
35
35
|
MediaUploadResult,
|
|
36
|
-
PrivacyLevel,
|
|
37
36
|
TikTokMediaManager,
|
|
38
37
|
)
|
|
38
|
+
from marqetive.platforms.tiktok.models import PrivacyLevel
|
|
39
39
|
|
|
40
40
|
# TikTok API base URL
|
|
41
41
|
TIKTOK_API_BASE = "https://open.tiktokapis.com/v2"
|
|
@@ -107,6 +107,7 @@ class TikTokClient(SocialMediaPlatform):
|
|
|
107
107
|
open_id=self.credentials.additional_data["open_id"],
|
|
108
108
|
timeout=self.timeout,
|
|
109
109
|
)
|
|
110
|
+
await self._media_manager.__aenter__()
|
|
110
111
|
|
|
111
112
|
async def _cleanup_managers(self) -> None:
|
|
112
113
|
"""Cleanup media manager."""
|
|
@@ -213,7 +214,7 @@ class TikTokClient(SocialMediaPlatform):
|
|
|
213
214
|
await self.query_creator_info()
|
|
214
215
|
|
|
215
216
|
# 2. Determine privacy level
|
|
216
|
-
privacy_level = PrivacyLevel.
|
|
217
|
+
privacy_level = PrivacyLevel.PRIVATE # Default for unaudited apps
|
|
217
218
|
requested_privacy = request.additional_data.get("privacy_level")
|
|
218
219
|
if requested_privacy and self._creator_info:
|
|
219
220
|
# Check if requested privacy is available
|
|
@@ -230,15 +231,26 @@ class TikTokClient(SocialMediaPlatform):
|
|
|
230
231
|
wait_for_publish=True,
|
|
231
232
|
)
|
|
232
233
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
platform=self.platform_name,
|
|
238
|
-
)
|
|
234
|
+
# 4. Return post - either fetch full details or create minimal Post
|
|
235
|
+
if upload_result.video_id:
|
|
236
|
+
# Fetch the created post to return full Post object
|
|
237
|
+
return await self.get_post(upload_result.video_id)
|
|
239
238
|
|
|
240
|
-
#
|
|
241
|
-
|
|
239
|
+
# For private/SELF_ONLY posts, TikTok may not return video_id
|
|
240
|
+
# Return a minimal Post object with publish_id
|
|
241
|
+
return Post(
|
|
242
|
+
post_id=upload_result.publish_id,
|
|
243
|
+
platform=self.platform_name,
|
|
244
|
+
content=request.content,
|
|
245
|
+
status=PostStatus.PUBLISHED,
|
|
246
|
+
created_at=datetime.now(),
|
|
247
|
+
raw_data={
|
|
248
|
+
"publish_id": upload_result.publish_id,
|
|
249
|
+
"upload_status": upload_result.status,
|
|
250
|
+
"privacy_level": privacy_level.value,
|
|
251
|
+
"note": "Video published but video_id not returned (common for private posts)",
|
|
252
|
+
},
|
|
253
|
+
)
|
|
242
254
|
|
|
243
255
|
async def get_post(self, post_id: str) -> Post:
|
|
244
256
|
"""Retrieve a TikTok video by its ID.
|
|
@@ -293,7 +305,7 @@ class TikTokClient(SocialMediaPlatform):
|
|
|
293
305
|
|
|
294
306
|
async def update_post(
|
|
295
307
|
self,
|
|
296
|
-
post_id: str,
|
|
308
|
+
post_id: str, # noqa: ARG002
|
|
297
309
|
request: PostUpdateRequest, # noqa: ARG002
|
|
298
310
|
) -> Post:
|
|
299
311
|
"""Update a TikTok video.
|
|
@@ -340,7 +352,7 @@ class TikTokClient(SocialMediaPlatform):
|
|
|
340
352
|
|
|
341
353
|
async def create_comment(
|
|
342
354
|
self,
|
|
343
|
-
post_id: str,
|
|
355
|
+
post_id: str, # noqa: ARG002
|
|
344
356
|
content: str, # noqa: ARG002
|
|
345
357
|
) -> Comment:
|
|
346
358
|
"""Create a comment on a TikTok video.
|
|
@@ -11,6 +11,7 @@ Reference: https://developers.tiktok.com/doc/content-posting-api-reference-direc
|
|
|
11
11
|
|
|
12
12
|
import asyncio
|
|
13
13
|
import inspect
|
|
14
|
+
import json
|
|
14
15
|
import logging
|
|
15
16
|
import math
|
|
16
17
|
import os
|
|
@@ -19,14 +20,17 @@ from dataclasses import dataclass
|
|
|
19
20
|
from enum import Enum
|
|
20
21
|
from typing import Any, Literal
|
|
21
22
|
|
|
23
|
+
import aiofiles
|
|
22
24
|
import httpx
|
|
23
25
|
|
|
24
26
|
from marqetive.core.exceptions import (
|
|
25
27
|
InvalidFileTypeError,
|
|
26
28
|
MediaUploadError,
|
|
29
|
+
PlatformError,
|
|
27
30
|
)
|
|
28
31
|
from marqetive.core.models import ProgressEvent, ProgressStatus
|
|
29
32
|
from marqetive.platforms.tiktok.exceptions import TikTokErrorCode, map_tiktok_error
|
|
33
|
+
from marqetive.platforms.tiktok.models import PrivacyLevel
|
|
30
34
|
from marqetive.utils.file_handlers import download_file
|
|
31
35
|
from marqetive.utils.media import detect_mime_type, format_file_size
|
|
32
36
|
|
|
@@ -69,14 +73,6 @@ class PublishStatus(str, Enum):
|
|
|
69
73
|
FAILED = "FAILED"
|
|
70
74
|
|
|
71
75
|
|
|
72
|
-
class PrivacyLevel(str, Enum):
|
|
73
|
-
"""Privacy level options for TikTok posts."""
|
|
74
|
-
|
|
75
|
-
PUBLIC_TO_EVERYONE = "PUBLIC_TO_EVERYONE"
|
|
76
|
-
MUTUAL_FOLLOW_FRIENDS = "MUTUAL_FOLLOW_FRIENDS"
|
|
77
|
-
SELF_ONLY = "SELF_ONLY" # Private - only for unaudited apps or private posts
|
|
78
|
-
|
|
79
|
-
|
|
80
76
|
@dataclass
|
|
81
77
|
class UploadProgress:
|
|
82
78
|
"""Progress information for a media upload.
|
|
@@ -196,6 +192,27 @@ class TikTokMediaManager:
|
|
|
196
192
|
await self._client.aclose()
|
|
197
193
|
await self._upload_client.aclose()
|
|
198
194
|
|
|
195
|
+
def _parse_json_response(self, response: httpx.Response) -> dict[str, Any]:
|
|
196
|
+
"""Parse JSON from HTTP response with proper error handling.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
response: HTTP response object.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Parsed JSON data as dictionary.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
PlatformError: If response is not valid JSON.
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
return response.json()
|
|
209
|
+
except json.JSONDecodeError as e:
|
|
210
|
+
raise PlatformError(
|
|
211
|
+
f"Invalid JSON response from TikTok API: {e}",
|
|
212
|
+
platform="tiktok",
|
|
213
|
+
status_code=response.status_code,
|
|
214
|
+
) from e
|
|
215
|
+
|
|
199
216
|
async def _emit_progress(
|
|
200
217
|
self,
|
|
201
218
|
status: ProgressStatus,
|
|
@@ -249,7 +266,7 @@ class TikTokMediaManager:
|
|
|
249
266
|
url = f"{TIKTOK_API_BASE}/post/publish/creator_info/query/"
|
|
250
267
|
|
|
251
268
|
response = await self._client.post(url)
|
|
252
|
-
data =
|
|
269
|
+
data = self._parse_json_response(response)
|
|
253
270
|
|
|
254
271
|
self._check_response_error(response.status_code, data)
|
|
255
272
|
|
|
@@ -272,7 +289,7 @@ class TikTokMediaManager:
|
|
|
272
289
|
file_path: str,
|
|
273
290
|
*,
|
|
274
291
|
title: str = "",
|
|
275
|
-
privacy_level: PrivacyLevel = PrivacyLevel.
|
|
292
|
+
privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
|
|
276
293
|
disable_duet: bool = False,
|
|
277
294
|
disable_comment: bool = False,
|
|
278
295
|
disable_stitch: bool = False,
|
|
@@ -321,7 +338,7 @@ class TikTokMediaManager:
|
|
|
321
338
|
}
|
|
322
339
|
|
|
323
340
|
response = await self._client.post(url, json=payload)
|
|
324
|
-
data =
|
|
341
|
+
data = self._parse_json_response(response)
|
|
325
342
|
|
|
326
343
|
self._check_response_error(response.status_code, data)
|
|
327
344
|
|
|
@@ -343,7 +360,7 @@ class TikTokMediaManager:
|
|
|
343
360
|
video_url: str,
|
|
344
361
|
*,
|
|
345
362
|
title: str = "",
|
|
346
|
-
privacy_level: PrivacyLevel = PrivacyLevel.
|
|
363
|
+
privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
|
|
347
364
|
disable_duet: bool = False,
|
|
348
365
|
disable_comment: bool = False,
|
|
349
366
|
disable_stitch: bool = False,
|
|
@@ -386,7 +403,7 @@ class TikTokMediaManager:
|
|
|
386
403
|
}
|
|
387
404
|
|
|
388
405
|
response = await self._client.post(url, json=payload)
|
|
389
|
-
data =
|
|
406
|
+
data = self._parse_json_response(response)
|
|
390
407
|
|
|
391
408
|
self._check_response_error(response.status_code, data)
|
|
392
409
|
|
|
@@ -439,10 +456,10 @@ class TikTokMediaManager:
|
|
|
439
456
|
|
|
440
457
|
bytes_uploaded = 0
|
|
441
458
|
|
|
442
|
-
with open(file_path, "rb") as f:
|
|
459
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
443
460
|
chunk_index = 0
|
|
444
461
|
while True:
|
|
445
|
-
chunk_data = f.read(chunk_size)
|
|
462
|
+
chunk_data = await f.read(chunk_size)
|
|
446
463
|
if not chunk_data:
|
|
447
464
|
break
|
|
448
465
|
|
|
@@ -507,19 +524,29 @@ class TikTokMediaManager:
|
|
|
507
524
|
url = f"{TIKTOK_API_BASE}/post/publish/status/fetch/"
|
|
508
525
|
|
|
509
526
|
response = await self._client.post(url, json={"publish_id": publish_id})
|
|
510
|
-
data =
|
|
527
|
+
data = self._parse_json_response(response)
|
|
511
528
|
|
|
512
529
|
self._check_response_error(response.status_code, data)
|
|
513
530
|
|
|
514
531
|
result_data = data.get("data", {})
|
|
515
532
|
status = result_data.get("status", PublishStatus.FAILED)
|
|
516
533
|
|
|
534
|
+
logger.debug(f"TikTok publish status response: {data}")
|
|
535
|
+
|
|
517
536
|
video_id = None
|
|
518
537
|
if status == PublishStatus.PUBLISH_COMPLETE:
|
|
519
|
-
# Video IDs are in publicaly_available_post_id array
|
|
538
|
+
# Video IDs are in publicaly_available_post_id array (note TikTok's typo)
|
|
539
|
+
# For private/SELF_ONLY posts, this may be empty
|
|
520
540
|
video_ids = result_data.get("publicaly_available_post_id", [])
|
|
521
541
|
if video_ids:
|
|
522
542
|
video_id = str(video_ids[0])
|
|
543
|
+
else:
|
|
544
|
+
# Try alternative field names that TikTok might use
|
|
545
|
+
video_id = result_data.get("video_id") or result_data.get("item_id")
|
|
546
|
+
logger.warning(
|
|
547
|
+
f"No video ID in publicaly_available_post_id, "
|
|
548
|
+
f"tried alternatives: video_id={video_id}"
|
|
549
|
+
)
|
|
523
550
|
|
|
524
551
|
return MediaUploadResult(
|
|
525
552
|
publish_id=publish_id,
|
|
@@ -614,7 +641,7 @@ class TikTokMediaManager:
|
|
|
614
641
|
file_path: str,
|
|
615
642
|
*,
|
|
616
643
|
title: str = "",
|
|
617
|
-
privacy_level: PrivacyLevel = PrivacyLevel.
|
|
644
|
+
privacy_level: PrivacyLevel = PrivacyLevel.PRIVATE,
|
|
618
645
|
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
619
646
|
wait_for_publish: bool = True,
|
|
620
647
|
) -> MediaUploadResult:
|
|
@@ -642,47 +669,65 @@ class TikTokMediaManager:
|
|
|
642
669
|
InvalidFileTypeError: If the file is not a supported video format.
|
|
643
670
|
MediaUploadError: If upload or processing fails.
|
|
644
671
|
"""
|
|
645
|
-
#
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
file_path
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
672
|
+
# Track if we downloaded a temp file that needs cleanup
|
|
673
|
+
temp_file_path: str | None = None
|
|
674
|
+
|
|
675
|
+
try:
|
|
676
|
+
# Handle URL downloads
|
|
677
|
+
if file_path.startswith(("http://", "https://")):
|
|
678
|
+
logger.info(f"Downloading media from URL: {file_path}")
|
|
679
|
+
file_path = await download_file(file_path)
|
|
680
|
+
temp_file_path = file_path # Mark for cleanup
|
|
681
|
+
|
|
682
|
+
if not os.path.exists(file_path):
|
|
683
|
+
raise FileNotFoundError(f"Media file not found: {file_path}")
|
|
684
|
+
|
|
685
|
+
# Validate file
|
|
686
|
+
mime_type = detect_mime_type(file_path)
|
|
687
|
+
file_size = os.path.getsize(file_path)
|
|
688
|
+
self._validate_media(mime_type, file_size)
|
|
689
|
+
|
|
690
|
+
# Initialize upload
|
|
691
|
+
init_result = await self.init_video_upload(
|
|
692
|
+
file_path,
|
|
693
|
+
title=title,
|
|
694
|
+
privacy_level=privacy_level,
|
|
695
|
+
chunk_size=chunk_size,
|
|
696
|
+
)
|
|
665
697
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
698
|
+
# Upload chunks
|
|
699
|
+
await self.upload_video_chunks(
|
|
700
|
+
init_result.upload_url,
|
|
701
|
+
file_path,
|
|
702
|
+
init_result.publish_id,
|
|
703
|
+
chunk_size=chunk_size,
|
|
704
|
+
)
|
|
673
705
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
706
|
+
# Wait for publish
|
|
707
|
+
if wait_for_publish:
|
|
708
|
+
return await self.wait_for_publish(init_result.publish_id, file_path)
|
|
677
709
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
710
|
+
return MediaUploadResult(
|
|
711
|
+
publish_id=init_result.publish_id,
|
|
712
|
+
status=PublishStatus.PROCESSING_UPLOAD,
|
|
713
|
+
)
|
|
714
|
+
finally:
|
|
715
|
+
# Clean up downloaded temp file
|
|
716
|
+
if temp_file_path and os.path.exists(temp_file_path):
|
|
717
|
+
try:
|
|
718
|
+
os.remove(temp_file_path)
|
|
719
|
+
logger.debug(f"Cleaned up temp file: {temp_file_path}")
|
|
720
|
+
except OSError as e:
|
|
721
|
+
logger.warning(f"Failed to clean up temp file {temp_file_path}: {e}")
|
|
682
722
|
|
|
683
723
|
def _normalize_chunk_size(self, chunk_size: int, file_size: int) -> int:
|
|
684
724
|
"""Normalize chunk size to TikTok's requirements.
|
|
685
725
|
|
|
726
|
+
TikTok chunk requirements:
|
|
727
|
+
- Minimum chunk size: 5MB (except for files smaller than 5MB)
|
|
728
|
+
- Maximum chunk size: 64MB (final chunk can be up to 128MB)
|
|
729
|
+
- All non-final chunks must be at least MIN_CHUNK_SIZE
|
|
730
|
+
|
|
686
731
|
Args:
|
|
687
732
|
chunk_size: Requested chunk size.
|
|
688
733
|
file_size: Total file size.
|
|
@@ -690,13 +735,27 @@ class TikTokMediaManager:
|
|
|
690
735
|
Returns:
|
|
691
736
|
Normalized chunk size within TikTok limits.
|
|
692
737
|
"""
|
|
693
|
-
# Files smaller than
|
|
694
|
-
|
|
738
|
+
# Files smaller than MAX_CHUNK_SIZE (64MB) should be uploaded as single chunk
|
|
739
|
+
# This avoids issues with the final chunk being smaller than MIN_CHUNK_SIZE
|
|
740
|
+
if file_size <= MAX_CHUNK_SIZE:
|
|
695
741
|
return file_size
|
|
696
742
|
|
|
697
|
-
#
|
|
743
|
+
# For larger files, ensure chunk size is within limits
|
|
698
744
|
chunk_size = max(MIN_CHUNK_SIZE, min(chunk_size, MAX_CHUNK_SIZE))
|
|
699
745
|
|
|
746
|
+
# Ensure the last chunk won't be smaller than MIN_CHUNK_SIZE
|
|
747
|
+
# If it would be, increase chunk size to make fewer, larger chunks
|
|
748
|
+
total_chunks = math.ceil(file_size / chunk_size)
|
|
749
|
+
last_chunk_size = file_size - (chunk_size * (total_chunks - 1))
|
|
750
|
+
|
|
751
|
+
if last_chunk_size < MIN_CHUNK_SIZE and total_chunks > 1:
|
|
752
|
+
# Recalculate to have fewer chunks with larger size
|
|
753
|
+
# Use ceiling division to ensure last chunk is large enough
|
|
754
|
+
total_chunks = math.ceil(file_size / MAX_CHUNK_SIZE)
|
|
755
|
+
chunk_size = math.ceil(file_size / total_chunks)
|
|
756
|
+
# Ensure still within limits
|
|
757
|
+
chunk_size = max(MIN_CHUNK_SIZE, min(chunk_size, MAX_CHUNK_SIZE))
|
|
758
|
+
|
|
700
759
|
return chunk_size
|
|
701
760
|
|
|
702
761
|
def _validate_media(self, mime_type: str, file_size: int) -> None:
|
|
@@ -24,7 +24,10 @@ class PrivacyLevel(StrEnum):
|
|
|
24
24
|
|
|
25
25
|
|
|
26
26
|
class TikTokPostRequest(BaseModel):
|
|
27
|
-
"""TikTok-specific post creation request.
|
|
27
|
+
"""TikTok-specific post creation request with full API options.
|
|
28
|
+
|
|
29
|
+
This model provides a type-safe way to configure TikTok-specific post settings.
|
|
30
|
+
It can be converted to the universal PostCreateRequest for use with TikTokClient.
|
|
28
31
|
|
|
29
32
|
TikTok only supports video posts. Videos must be between 3 seconds
|
|
30
33
|
and 10 minutes, and under 4GB in size.
|
|
@@ -43,30 +46,24 @@ class TikTokPostRequest(BaseModel):
|
|
|
43
46
|
schedule_time: Unix timestamp to schedule post (10 mins to 10 days ahead)
|
|
44
47
|
|
|
45
48
|
Example:
|
|
46
|
-
>>>
|
|
47
|
-
>>>
|
|
49
|
+
>>> from marqetive.core.models import PostCreateRequest
|
|
50
|
+
>>>
|
|
51
|
+
>>> # Create TikTok-specific request
|
|
52
|
+
>>> tiktok_request = TikTokPostRequest(
|
|
48
53
|
... title="Check out this dance!",
|
|
49
54
|
... video_url="https://example.com/dance.mp4",
|
|
50
|
-
... privacy_level=PrivacyLevel.PUBLIC
|
|
51
|
-
... )
|
|
52
|
-
|
|
53
|
-
>>> # Private video with features disabled
|
|
54
|
-
>>> request = TikTokPostRequest(
|
|
55
|
-
... title="Personal video",
|
|
56
|
-
... video_url="https://example.com/video.mp4",
|
|
57
|
-
... privacy_level=PrivacyLevel.PRIVATE,
|
|
58
|
-
... disable_comment=True,
|
|
55
|
+
... privacy_level=PrivacyLevel.PUBLIC,
|
|
59
56
|
... disable_duet=True,
|
|
60
|
-
... disable_stitch=True
|
|
61
57
|
... )
|
|
62
|
-
|
|
63
|
-
>>> #
|
|
64
|
-
>>> request =
|
|
65
|
-
... title
|
|
66
|
-
...
|
|
67
|
-
...
|
|
68
|
-
... brand_content_toggle=True
|
|
58
|
+
>>>
|
|
59
|
+
>>> # Convert to universal PostCreateRequest for client
|
|
60
|
+
>>> request = PostCreateRequest(
|
|
61
|
+
... content=tiktok_request.title,
|
|
62
|
+
... media_urls=[tiktok_request.video_url] if tiktok_request.video_url else [],
|
|
63
|
+
... additional_data=tiktok_request.model_dump(exclude_none=True),
|
|
69
64
|
... )
|
|
65
|
+
>>> async with TikTokClient(credentials) as client:
|
|
66
|
+
... post = await client.create_post(request)
|
|
70
67
|
"""
|
|
71
68
|
|
|
72
69
|
title: str | None = None
|
|
@@ -17,6 +17,7 @@ from dataclasses import dataclass
|
|
|
17
17
|
from enum import Enum
|
|
18
18
|
from typing import Any, Literal
|
|
19
19
|
|
|
20
|
+
import aiofiles
|
|
20
21
|
import httpx
|
|
21
22
|
|
|
22
23
|
from marqetive.core.exceptions import (
|
|
@@ -337,9 +338,9 @@ class TwitterMediaManager:
|
|
|
337
338
|
|
|
338
339
|
@retry_async(config=STANDARD_BACKOFF)
|
|
339
340
|
async def _do_upload() -> MediaUploadResult:
|
|
340
|
-
# Read file
|
|
341
|
-
with open(file_path, "rb") as f:
|
|
342
|
-
file_data = f.read()
|
|
341
|
+
# Read file asynchronously
|
|
342
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
343
|
+
file_data = await f.read()
|
|
343
344
|
|
|
344
345
|
# Prepare form data
|
|
345
346
|
files = {"media": (os.path.basename(file_path), file_data)}
|
|
@@ -465,9 +466,9 @@ class TwitterMediaManager:
|
|
|
465
466
|
bytes_uploaded = 0
|
|
466
467
|
segment_index = 0
|
|
467
468
|
|
|
468
|
-
with open(file_path, "rb") as f:
|
|
469
|
+
async with aiofiles.open(file_path, "rb") as f:
|
|
469
470
|
while True:
|
|
470
|
-
chunk_data = f.read(chunk_size)
|
|
471
|
+
chunk_data = await f.read(chunk_size)
|
|
471
472
|
if not chunk_data:
|
|
472
473
|
break
|
|
473
474
|
|
marqetive/utils/media.py
CHANGED
|
@@ -5,14 +5,19 @@ This module provides utilities for working with media files including:
|
|
|
5
5
|
- File validation (size, type, format)
|
|
6
6
|
- File chunking for large uploads
|
|
7
7
|
- File hashing for integrity verification
|
|
8
|
+
- URL validation for media URLs
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
import hashlib
|
|
12
|
+
import ipaddress
|
|
11
13
|
import mimetypes
|
|
12
14
|
import os
|
|
13
15
|
from collections.abc import AsyncGenerator
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
from typing import Literal
|
|
18
|
+
from urllib.parse import urlparse
|
|
19
|
+
|
|
20
|
+
from marqetive.core.exceptions import ValidationError
|
|
16
21
|
|
|
17
22
|
# Initialize mimetypes database
|
|
18
23
|
mimetypes.init()
|
|
@@ -397,3 +402,84 @@ def get_chunk_count(file_path: str, chunk_size: int) -> int:
|
|
|
397
402
|
|
|
398
403
|
file_size = os.path.getsize(file_path)
|
|
399
404
|
return (file_size + chunk_size - 1) // chunk_size # Ceiling division
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def validate_media_url(
|
|
408
|
+
url: str,
|
|
409
|
+
*,
|
|
410
|
+
allowed_schemes: list[str] | None = None,
|
|
411
|
+
block_private_ips: bool = True,
|
|
412
|
+
platform: str = "unknown",
|
|
413
|
+
) -> str:
|
|
414
|
+
"""Validate a media URL for security.
|
|
415
|
+
|
|
416
|
+
Validates that the URL uses an allowed scheme (default: http/https) and
|
|
417
|
+
optionally blocks private/internal IP addresses to prevent SSRF attacks.
|
|
418
|
+
|
|
419
|
+
Args:
|
|
420
|
+
url: The URL to validate.
|
|
421
|
+
allowed_schemes: List of allowed URL schemes (default: ['http', 'https']).
|
|
422
|
+
block_private_ips: If True, block private/loopback IP addresses.
|
|
423
|
+
platform: Platform name for error messages.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
The validated URL (unchanged if valid).
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
ValidationError: If the URL is invalid or uses a disallowed scheme/IP.
|
|
430
|
+
|
|
431
|
+
Example:
|
|
432
|
+
>>> url = validate_media_url("https://example.com/image.jpg")
|
|
433
|
+
>>> url = validate_media_url("https://cdn.example.com/video.mp4", platform="instagram")
|
|
434
|
+
"""
|
|
435
|
+
if allowed_schemes is None:
|
|
436
|
+
allowed_schemes = ["http", "https"]
|
|
437
|
+
|
|
438
|
+
try:
|
|
439
|
+
parsed = urlparse(url)
|
|
440
|
+
except Exception as e:
|
|
441
|
+
raise ValidationError(
|
|
442
|
+
f"Invalid URL format: {e}",
|
|
443
|
+
platform=platform,
|
|
444
|
+
field="media_url",
|
|
445
|
+
) from e
|
|
446
|
+
|
|
447
|
+
# Validate scheme
|
|
448
|
+
if not parsed.scheme:
|
|
449
|
+
raise ValidationError(
|
|
450
|
+
"URL must include a scheme (e.g., https://)",
|
|
451
|
+
platform=platform,
|
|
452
|
+
field="media_url",
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
if parsed.scheme.lower() not in allowed_schemes:
|
|
456
|
+
raise ValidationError(
|
|
457
|
+
f"URL scheme '{parsed.scheme}' not allowed. "
|
|
458
|
+
f"Allowed schemes: {', '.join(allowed_schemes)}",
|
|
459
|
+
platform=platform,
|
|
460
|
+
field="media_url",
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Validate hostname exists
|
|
464
|
+
if not parsed.hostname:
|
|
465
|
+
raise ValidationError(
|
|
466
|
+
"URL must include a hostname",
|
|
467
|
+
platform=platform,
|
|
468
|
+
field="media_url",
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Block private/internal IPs if requested
|
|
472
|
+
if block_private_ips:
|
|
473
|
+
try:
|
|
474
|
+
ip = ipaddress.ip_address(parsed.hostname)
|
|
475
|
+
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
|
476
|
+
raise ValidationError(
|
|
477
|
+
"Private, loopback, and reserved IP addresses are not allowed",
|
|
478
|
+
platform=platform,
|
|
479
|
+
field="media_url",
|
|
480
|
+
)
|
|
481
|
+
except ValueError:
|
|
482
|
+
# Not an IP address, it's a hostname - that's fine
|
|
483
|
+
pass
|
|
484
|
+
|
|
485
|
+
return url
|
marqetive/utils/oauth.py
CHANGED
|
@@ -5,6 +5,7 @@ different social media platforms.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import logging
|
|
8
|
+
import re
|
|
8
9
|
from datetime import datetime, timedelta
|
|
9
10
|
from typing import Any
|
|
10
11
|
|
|
@@ -15,6 +16,32 @@ from marqetive.core.models import AuthCredentials
|
|
|
15
16
|
|
|
16
17
|
logger = logging.getLogger(__name__)
|
|
17
18
|
|
|
19
|
+
# Patterns for sensitive data that should be redacted from logs
|
|
20
|
+
_SENSITIVE_PATTERNS = [
|
|
21
|
+
re.compile(r'"access_token"\s*:\s*"[^"]*"', re.IGNORECASE),
|
|
22
|
+
re.compile(r'"refresh_token"\s*:\s*"[^"]*"', re.IGNORECASE),
|
|
23
|
+
re.compile(r'"client_secret"\s*:\s*"[^"]*"', re.IGNORECASE),
|
|
24
|
+
re.compile(r'"api_key"\s*:\s*"[^"]*"', re.IGNORECASE),
|
|
25
|
+
re.compile(r'"token"\s*:\s*"[^"]*"', re.IGNORECASE),
|
|
26
|
+
re.compile(r"access_token=[^&\s]+", re.IGNORECASE),
|
|
27
|
+
re.compile(r"refresh_token=[^&\s]+", re.IGNORECASE),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _sanitize_response_text(text: str) -> str:
|
|
32
|
+
"""Sanitize response text to remove sensitive credentials.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
text: Raw response text that may contain credentials.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Sanitized text with sensitive values redacted.
|
|
39
|
+
"""
|
|
40
|
+
result = text
|
|
41
|
+
for pattern in _SENSITIVE_PATTERNS:
|
|
42
|
+
result = pattern.sub("[REDACTED]", result)
|
|
43
|
+
return result
|
|
44
|
+
|
|
18
45
|
|
|
19
46
|
async def refresh_oauth2_token(
|
|
20
47
|
refresh_token: str,
|
|
@@ -73,7 +100,7 @@ async def refresh_oauth2_token(
|
|
|
73
100
|
except httpx.HTTPStatusError as e:
|
|
74
101
|
logger.error(f"HTTP error refreshing token: {e.response.status_code}")
|
|
75
102
|
raise PlatformAuthError(
|
|
76
|
-
f"Failed to refresh token: {e.response.text}",
|
|
103
|
+
f"Failed to refresh token: {_sanitize_response_text(e.response.text)}",
|
|
77
104
|
platform="oauth2",
|
|
78
105
|
status_code=e.response.status_code,
|
|
79
106
|
) from e
|
|
@@ -252,7 +279,7 @@ async def refresh_instagram_token(
|
|
|
252
279
|
except httpx.HTTPStatusError as e:
|
|
253
280
|
logger.error(f"HTTP error refreshing Instagram token: {e.response.status_code}")
|
|
254
281
|
raise PlatformAuthError(
|
|
255
|
-
f"Failed to refresh Instagram token: {e.response.text}",
|
|
282
|
+
f"Failed to refresh Instagram token: {_sanitize_response_text(e.response.text)}",
|
|
256
283
|
platform="instagram",
|
|
257
284
|
status_code=e.response.status_code,
|
|
258
285
|
) from e
|
|
@@ -312,7 +339,7 @@ async def refresh_tiktok_token(
|
|
|
312
339
|
except httpx.HTTPStatusError as e:
|
|
313
340
|
logger.error(f"HTTP error refreshing tiktok token: {e.response.status_code}")
|
|
314
341
|
raise PlatformAuthError(
|
|
315
|
-
f"Failed to refresh token: {e.response.text}",
|
|
342
|
+
f"Failed to refresh token: {_sanitize_response_text(e.response.text)}",
|
|
316
343
|
platform="tiktok",
|
|
317
344
|
status_code=e.response.status_code,
|
|
318
345
|
) from e
|
|
@@ -387,7 +414,7 @@ async def fetch_tiktok_token(
|
|
|
387
414
|
except httpx.HTTPStatusError as e:
|
|
388
415
|
logger.error(f"HTTP error fetching tiktok token: {e.response.status_code}")
|
|
389
416
|
raise PlatformAuthError(
|
|
390
|
-
f"Failed to fetch token: {e.response.text}",
|
|
417
|
+
f"Failed to fetch token: {_sanitize_response_text(e.response.text)}",
|
|
391
418
|
platform="tiktok",
|
|
392
419
|
status_code=e.response.status_code,
|
|
393
420
|
) from e
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
marqetive/__init__.py,sha256=pW77CUnzOQ0X1pb-GTcRgrrvsSaJBdVhGZLnvCD_4q4,3032
|
|
2
2
|
marqetive/core/__init__.py,sha256=0_0vzxJ619YIJkz1yzSvhnGDJRkrErs_QSg2q3Bloss,1172
|
|
3
3
|
marqetive/core/base.py,sha256=J5iYXpa2C371zVLNMx3eFnC0fdLWqTrjbVcqJQdGyrU,16147
|
|
4
|
-
marqetive/core/client.py,sha256=
|
|
4
|
+
marqetive/core/client.py,sha256=eCtvL100dkxYQHC_TJzHbs3dGjgsa_me9VTHo-CUN2M,3900
|
|
5
5
|
marqetive/core/exceptions.py,sha256=Xyj0bzNiZm5VTErmzXgVW8T6IQnOpF92-HJiKPKjIio,7076
|
|
6
6
|
marqetive/core/models.py,sha256=L2gA4FhW0feAXQFsz2ce1ttd0vScMRhatoTclhDGCU0,14727
|
|
7
7
|
marqetive/factory.py,sha256=irZ5oN8a__kXZH70UN2uI7TzqTXu66d4QZ1FoxSoiK8,14092
|
|
8
8
|
marqetive/platforms/__init__.py,sha256=RBxlQSGyELsulSnwf5uaE1ohxFc7jC61OO9CrKaZp48,1312
|
|
9
9
|
marqetive/platforms/instagram/__init__.py,sha256=c1Gs0ozG6D7Z-Uz_UQ7S3joL0qUTT9eUZPWcePyESk8,229
|
|
10
|
-
marqetive/platforms/instagram/client.py,sha256=
|
|
10
|
+
marqetive/platforms/instagram/client.py,sha256=vOx5HpgrxanBIFFC9VgmCNguH-njRGChnyp6Rr1r1Xc,26191
|
|
11
11
|
marqetive/platforms/instagram/exceptions.py,sha256=TcD_pX4eSx_k4yW8DgfA6SGPiAz3VW7cMqM8DmiXIhg,8978
|
|
12
12
|
marqetive/platforms/instagram/media.py,sha256=0ZbUbpwJ025_hccL9X8qced_-LJGoL_-NdS84Op97VE,23228
|
|
13
13
|
marqetive/platforms/instagram/models.py,sha256=20v3m1037y3b_WlsKF8zAOgV23nFu63tfmmUN1CefOI,2769
|
|
@@ -17,23 +17,23 @@ marqetive/platforms/linkedin/exceptions.py,sha256=i5fARUkZik46bS3htZBwUInVzetsZx
|
|
|
17
17
|
marqetive/platforms/linkedin/media.py,sha256=iWXUfqDYGsrTqeM6CGZ7a8xjpbdJ5qESolQL8Fv2PIg,20341
|
|
18
18
|
marqetive/platforms/linkedin/models.py,sha256=n7DqwVxYSbGYBmeEJ1woCZ6XhUIHcLx8Gpm8uCBACzI,12620
|
|
19
19
|
marqetive/platforms/tiktok/__init__.py,sha256=BqjkXTZDyBlcY3lvREy13yP9h3RcDga8E6Rl6f5KPp8,238
|
|
20
|
-
marqetive/platforms/tiktok/client.py,sha256=
|
|
20
|
+
marqetive/platforms/tiktok/client.py,sha256=ErhJOskyWVJP1nfLeJl09fGEyVh3QEhjYCgRlVLV-JY,17704
|
|
21
21
|
marqetive/platforms/tiktok/exceptions.py,sha256=vxwyAKujMGZJh0LetG1QsLF95QfUs_kR6ujsWSHGqL0,10124
|
|
22
|
-
marqetive/platforms/tiktok/media.py,sha256=
|
|
23
|
-
marqetive/platforms/tiktok/models.py,sha256=
|
|
22
|
+
marqetive/platforms/tiktok/media.py,sha256=aa47EDRA7woKGqKZkl_5XWu7kcRp2nT93Ol2skEQJpY,28592
|
|
23
|
+
marqetive/platforms/tiktok/models.py,sha256=WWdjuFqhTIR8SnHkz-8UaNc5Mm2PrGomwQ3W7pJcQFg,2962
|
|
24
24
|
marqetive/platforms/twitter/__init__.py,sha256=dvcgVT-v-JOtjSz-OUvxGrn_43OI6w_ep42Wx_nHTSM,217
|
|
25
25
|
marqetive/platforms/twitter/client.py,sha256=08jV2hQVmGOpnG3C05u7bCqL7KapWn7bSsG0wbN_t5M,23270
|
|
26
26
|
marqetive/platforms/twitter/exceptions.py,sha256=eZ-dJKOXH_-bAMg29zWKbEqMFud29piEJ5IWfC9wFts,8926
|
|
27
|
-
marqetive/platforms/twitter/media.py,sha256=
|
|
27
|
+
marqetive/platforms/twitter/media.py,sha256=9j7JQpdlOhkMfQkDH0dLpp6HmlYkeB6SvNosRx5Oab8,27152
|
|
28
28
|
marqetive/platforms/twitter/models.py,sha256=yPQlx40SlNmz7YGasXUqdx7rEDEgrQ64aYovlPKo6oc,2126
|
|
29
29
|
marqetive/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
30
30
|
marqetive/utils/__init__.py,sha256=bSrNajbxYBSKQayrPviLz8JeGjplnyK8y_NGDtgb7yQ,977
|
|
31
31
|
marqetive/utils/file_handlers.py,sha256=4TP5kmWofNTSZmlS683CM1UYP83WvRd_NubMbqtXv-g,12568
|
|
32
32
|
marqetive/utils/helpers.py,sha256=8-ljhL47SremKcQO2GF8DIHOPODEv1rSioVNuSPCbec,2634
|
|
33
|
-
marqetive/utils/media.py,sha256=
|
|
34
|
-
marqetive/utils/oauth.py,sha256=
|
|
33
|
+
marqetive/utils/media.py,sha256=O1rISYdaP3CuuPxso7kqvxWXNfe2jjioNkaBc4cpwkY,14668
|
|
34
|
+
marqetive/utils/oauth.py,sha256=1SkYCE6dcyPvcDqbjRFSSBcKTwLJy8u3jAANPdftVmo,13108
|
|
35
35
|
marqetive/utils/retry.py,sha256=lAniJLMNWp9XsHrvU0XBNifpNEjfde4MGfd5hlFTPfA,7636
|
|
36
36
|
marqetive/utils/token_validator.py,sha256=dNvDeHs2Du5UyMMH2ZOW6ydR7OwOEKA4c9e-rG0f9-0,6698
|
|
37
|
-
marqetive_lib-0.1.
|
|
38
|
-
marqetive_lib-0.1.
|
|
39
|
-
marqetive_lib-0.1.
|
|
37
|
+
marqetive_lib-0.1.9.dist-info/METADATA,sha256=jdnVbFyp8VBoa2wtc3OMObF0RUDNucFAm6IeezeGYG8,7875
|
|
38
|
+
marqetive_lib-0.1.9.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
39
|
+
marqetive_lib-0.1.9.dist-info/RECORD,,
|
|
File without changes
|