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 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
- container_params["image_url"] = request.media_urls[0]
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"] = media_url
549
+ params["image_url"] = validated_url
542
550
  elif media_type.lower() == "video":
543
- params["video_url"] = media_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=url, type="image", alt_text=alt_text))
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
- video_url,
690
+ validated_video_url,
673
691
  caption=caption,
674
- cover_url=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
- media_url,
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.SELF_ONLY # Default for unaudited apps
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
- if not upload_result.video_id:
234
- raise PlatformError(
235
- "Video upload succeeded but no video ID returned. "
236
- "Video may still be processing.",
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
- # 4. Fetch the created post to return full Post object
241
- return await self.get_post(upload_result.video_id)
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 = response.json()
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.SELF_ONLY,
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 = response.json()
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.SELF_ONLY,
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 = response.json()
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 = response.json()
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.SELF_ONLY,
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
- # Handle URL downloads
646
- if file_path.startswith(("http://", "https://")):
647
- logger.info(f"Downloading media from URL: {file_path}")
648
- file_path = await download_file(file_path)
649
-
650
- if not os.path.exists(file_path):
651
- raise FileNotFoundError(f"Media file not found: {file_path}")
652
-
653
- # Validate file
654
- mime_type = detect_mime_type(file_path)
655
- file_size = os.path.getsize(file_path)
656
- self._validate_media(mime_type, file_size)
657
-
658
- # Initialize upload
659
- init_result = await self.init_video_upload(
660
- file_path,
661
- title=title,
662
- privacy_level=privacy_level,
663
- chunk_size=chunk_size,
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
- # Upload chunks
667
- await self.upload_video_chunks(
668
- init_result.upload_url,
669
- file_path,
670
- init_result.publish_id,
671
- chunk_size=chunk_size,
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
- # Wait for publish
675
- if wait_for_publish:
676
- return await self.wait_for_publish(init_result.publish_id, file_path)
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
- return MediaUploadResult(
679
- publish_id=init_result.publish_id,
680
- status=PublishStatus.PROCESSING_UPLOAD,
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 MIN_CHUNK_SIZE must be uploaded as single chunk
694
- if file_size < MIN_CHUNK_SIZE:
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
- # Ensure within limits
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
- >>> # Public video post
47
- >>> request = TikTokPostRequest(
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
- >>> # Branded content
64
- >>> request = TikTokPostRequest(
65
- ... title="Sponsored review",
66
- ... video_url="https://example.com/review.mp4",
67
- ... privacy_level=PrivacyLevel.PUBLIC,
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: marqetive-lib
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Modern Python utilities for web APIs
5
5
  Keywords: api,utilities,web,http,marqetive
6
6
  Requires-Python: >=3.12
@@ -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=2_FoNpqaRglsWg10i5RTbyDg_kRQKhgWjYs6iDdFxLg,3210
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=C5T9v1Aina8F3_Dk3d_I_RiVt7VvxTwwqdYeizDr0iQ,25187
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=i_nyhVywh4feFu5vv4LrMQOkoLsxfxgpH6aKa9JjdOc,17060
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=nDijJYq89dQfMFwq58P2PXOvZukxgVRzMrfKTOONn_c,25717
23
- marqetive/platforms/tiktok/models.py,sha256=acV-fe_3O6kKbZzqytCNCATAwmFpjt0Ac2uacbjt1u8,2822
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=_7ka0ENlKr6EMxQQvPS_A38_a8MpjDE2zIrXe20QkuI,27079
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=Rvxw9XKU65n-z4G1bEihG3wXZBmjSDZUqClfjGFrg6k,12013
34
- marqetive/utils/oauth.py,sha256=LQLXpThZUe0XbSpO3dJ5oW3sPRJuKjSk3_f5_3baUzA,12095
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.7.dist-info/METADATA,sha256=xsPKt4AbFy44aZtNeft0YeaSWNKrS-xWxsavavh2xtM,7875
38
- marqetive_lib-0.1.7.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
39
- marqetive_lib-0.1.7.dist-info/RECORD,,
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,,