marqetive-lib 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. marqetive/__init__.py +113 -0
  2. marqetive/core/__init__.py +5 -0
  3. marqetive/core/account_factory.py +212 -0
  4. marqetive/core/base_manager.py +303 -0
  5. marqetive/core/client.py +108 -0
  6. marqetive/core/progress.py +291 -0
  7. marqetive/core/registry.py +257 -0
  8. marqetive/platforms/__init__.py +55 -0
  9. marqetive/platforms/base.py +390 -0
  10. marqetive/platforms/exceptions.py +238 -0
  11. marqetive/platforms/instagram/__init__.py +7 -0
  12. marqetive/platforms/instagram/client.py +786 -0
  13. marqetive/platforms/instagram/exceptions.py +311 -0
  14. marqetive/platforms/instagram/factory.py +106 -0
  15. marqetive/platforms/instagram/manager.py +112 -0
  16. marqetive/platforms/instagram/media.py +669 -0
  17. marqetive/platforms/linkedin/__init__.py +7 -0
  18. marqetive/platforms/linkedin/client.py +733 -0
  19. marqetive/platforms/linkedin/exceptions.py +335 -0
  20. marqetive/platforms/linkedin/factory.py +130 -0
  21. marqetive/platforms/linkedin/manager.py +119 -0
  22. marqetive/platforms/linkedin/media.py +549 -0
  23. marqetive/platforms/models.py +345 -0
  24. marqetive/platforms/tiktok/__init__.py +0 -0
  25. marqetive/platforms/twitter/__init__.py +7 -0
  26. marqetive/platforms/twitter/client.py +647 -0
  27. marqetive/platforms/twitter/exceptions.py +311 -0
  28. marqetive/platforms/twitter/factory.py +151 -0
  29. marqetive/platforms/twitter/manager.py +121 -0
  30. marqetive/platforms/twitter/media.py +779 -0
  31. marqetive/platforms/twitter/threads.py +442 -0
  32. marqetive/py.typed +0 -0
  33. marqetive/registry_init.py +66 -0
  34. marqetive/utils/__init__.py +45 -0
  35. marqetive/utils/file_handlers.py +438 -0
  36. marqetive/utils/helpers.py +99 -0
  37. marqetive/utils/media.py +399 -0
  38. marqetive/utils/oauth.py +265 -0
  39. marqetive/utils/retry.py +239 -0
  40. marqetive/utils/token_validator.py +240 -0
  41. marqetive_lib-0.1.0.dist-info/METADATA +261 -0
  42. marqetive_lib-0.1.0.dist-info/RECORD +43 -0
  43. marqetive_lib-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,549 @@
1
+ """LinkedIn media upload manager with support for images, videos, and documents.
2
+
3
+ LinkedIn uses a multi-step upload process:
4
+ 1. Register upload (get upload URL)
5
+ 2. Upload file to the URL
6
+ 3. Complete upload (finalize)
7
+
8
+ This module supports:
9
+ - Image uploads (single and multiple)
10
+ - Video uploads with processing monitoring
11
+ - Document/PDF uploads
12
+ """
13
+
14
+ import asyncio
15
+ import logging
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass
18
+ from enum import Enum
19
+ from typing import Any, Literal
20
+
21
+ import httpx
22
+
23
+ from marqetive.platforms.exceptions import (
24
+ MediaUploadError,
25
+ ValidationError,
26
+ )
27
+ from marqetive.utils.file_handlers import download_file, read_file_bytes
28
+ from marqetive.utils.media import detect_mime_type, format_file_size
29
+ from marqetive.utils.retry import STANDARD_BACKOFF, retry_async
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # LinkedIn limits
34
+ MAX_IMAGE_SIZE = 10 * 1024 * 1024 # 10MB
35
+ MAX_VIDEO_SIZE = 200 * 1024 * 1024 # 200MB
36
+ MAX_DOCUMENT_SIZE = 10 * 1024 * 1024 # 10MB
37
+ MAX_VIDEO_DURATION = 600 # 10 minutes
38
+
39
+ # Processing timeouts
40
+ VIDEO_PROCESSING_TIMEOUT = 600 # 10 minutes
41
+
42
+
43
+ class VideoProcessingState(str, Enum):
44
+ """LinkedIn video processing states."""
45
+
46
+ PROCESSING = "PROCESSING"
47
+ READY = "READY"
48
+ FAILED = "FAILED"
49
+ AVAILABLE = "AVAILABLE"
50
+
51
+
52
+ @dataclass
53
+ class UploadProgress:
54
+ """Progress information for media upload."""
55
+
56
+ asset_id: str
57
+ bytes_uploaded: int
58
+ total_bytes: int
59
+ status: Literal["registering", "uploading", "processing", "completed", "failed"]
60
+
61
+ @property
62
+ def percentage(self) -> float:
63
+ """Calculate upload percentage."""
64
+ if self.total_bytes == 0:
65
+ return 0.0
66
+ return (self.bytes_uploaded / self.total_bytes) * 100
67
+
68
+
69
+ @dataclass
70
+ class MediaAsset:
71
+ """LinkedIn media asset result.
72
+
73
+ Attributes:
74
+ asset_id: LinkedIn asset URN.
75
+ download_url: URL to download the media (if available).
76
+ status: Current status of the asset.
77
+ """
78
+
79
+ asset_id: str
80
+ download_url: str | None = None
81
+ status: str | None = None
82
+
83
+
84
+ class LinkedInMediaManager:
85
+ """Manager for LinkedIn media uploads.
86
+
87
+ Supports images, videos, and documents with progress tracking.
88
+
89
+ Example:
90
+ >>> manager = LinkedInMediaManager(person_urn, access_token)
91
+ >>> asset = await manager.upload_image("/path/to/image.jpg")
92
+ >>> print(f"Uploaded: {asset.asset_id}")
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ person_urn: str,
98
+ access_token: str,
99
+ *,
100
+ api_version: str = "v2",
101
+ timeout: float = 60.0,
102
+ progress_callback: Callable[[UploadProgress], None] | None = None,
103
+ ) -> None:
104
+ """Initialize LinkedIn media manager.
105
+
106
+ Args:
107
+ person_urn: LinkedIn person URN (e.g., "urn:li:person:ABC123").
108
+ access_token: LinkedIn OAuth access token.
109
+ api_version: LinkedIn API version.
110
+ timeout: Request timeout in seconds.
111
+ progress_callback: Optional callback for progress updates.
112
+ """
113
+ self.person_urn = person_urn
114
+ self.access_token = access_token
115
+ self.api_version = api_version
116
+ self.timeout = timeout
117
+ self.progress_callback = progress_callback
118
+
119
+ self.base_url = f"https://api.linkedin.com/{api_version}"
120
+ self.client = httpx.AsyncClient(
121
+ timeout=httpx.Timeout(timeout),
122
+ headers={
123
+ "Authorization": f"Bearer {access_token}",
124
+ "X-Restli-Protocol-Version": "2.0.0",
125
+ },
126
+ )
127
+
128
+ async def __aenter__(self) -> "LinkedInMediaManager":
129
+ """Enter async context."""
130
+ return self
131
+
132
+ async def __aexit__(self, *args: Any) -> None:
133
+ """Exit async context and cleanup."""
134
+ await self.client.aclose()
135
+
136
+ async def upload_image(
137
+ self,
138
+ file_path: str,
139
+ *,
140
+ alt_text: str | None = None, # noqa: ARG002
141
+ ) -> MediaAsset:
142
+ """Upload an image to LinkedIn.
143
+
144
+ Args:
145
+ file_path: Path to image file or URL.
146
+ alt_text: Alternative text for accessibility.
147
+
148
+ Returns:
149
+ MediaAsset with asset ID.
150
+
151
+ Raises:
152
+ MediaUploadError: If upload fails.
153
+ ValidationError: If file is invalid.
154
+
155
+ Example:
156
+ >>> asset = await manager.upload_image("photo.jpg")
157
+ >>> print(f"Asset ID: {asset.asset_id}")
158
+ """
159
+ # Download if URL
160
+ if file_path.startswith(("http://", "https://")):
161
+ file_path = await download_file(file_path)
162
+
163
+ # Validate
164
+ mime_type = detect_mime_type(file_path)
165
+ if not mime_type.startswith("image/"):
166
+ raise ValidationError(
167
+ f"File is not an image: {mime_type}",
168
+ platform="linkedin",
169
+ )
170
+
171
+ # Read file
172
+ file_bytes = await read_file_bytes(file_path)
173
+ file_size = len(file_bytes)
174
+
175
+ if file_size > MAX_IMAGE_SIZE:
176
+ raise ValidationError(
177
+ f"Image exceeds {format_file_size(MAX_IMAGE_SIZE)} limit",
178
+ platform="linkedin",
179
+ )
180
+
181
+ # Register upload
182
+ register_data = {
183
+ "registerUploadRequest": {
184
+ "recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
185
+ "owner": self.person_urn,
186
+ "serviceRelationships": [
187
+ {
188
+ "relationshipType": "OWNER",
189
+ "identifier": "urn:li:userGeneratedContent",
190
+ }
191
+ ],
192
+ }
193
+ }
194
+
195
+ asset_id = await self._register_upload(register_data)
196
+
197
+ # Notify start
198
+ if self.progress_callback:
199
+ self.progress_callback(
200
+ UploadProgress(asset_id, 0, file_size, "registering")
201
+ )
202
+
203
+ # Get upload URL
204
+ upload_url = await self._get_upload_url(asset_id)
205
+
206
+ # Upload file
207
+ await self._upload_to_url(upload_url, file_bytes, file_size, asset_id)
208
+
209
+ # Notify completion
210
+ if self.progress_callback:
211
+ self.progress_callback(
212
+ UploadProgress(asset_id, file_size, file_size, "completed")
213
+ )
214
+
215
+ logger.info(f"Image uploaded successfully: {asset_id}")
216
+ return MediaAsset(asset_id=asset_id, status="READY")
217
+
218
+ async def upload_video(
219
+ self,
220
+ file_path: str,
221
+ *,
222
+ title: str | None = None, # noqa: ARG002
223
+ wait_for_processing: bool = True,
224
+ ) -> MediaAsset:
225
+ """Upload a video to LinkedIn.
226
+
227
+ Args:
228
+ file_path: Path to video file or URL.
229
+ title: Video title.
230
+ wait_for_processing: Wait for video processing to complete.
231
+
232
+ Returns:
233
+ MediaAsset with asset ID.
234
+
235
+ Raises:
236
+ MediaUploadError: If upload or processing fails.
237
+ ValidationError: If file is invalid.
238
+
239
+ Example:
240
+ >>> asset = await manager.upload_video(
241
+ ... "video.mp4",
242
+ ... title="My Video",
243
+ ... wait_for_processing=True
244
+ ... )
245
+ """
246
+ # Download if URL
247
+ if file_path.startswith(("http://", "https://")):
248
+ file_path = await download_file(file_path)
249
+
250
+ # Validate
251
+ mime_type = detect_mime_type(file_path)
252
+ if not mime_type.startswith("video/"):
253
+ raise ValidationError(
254
+ f"File is not a video: {mime_type}",
255
+ platform="linkedin",
256
+ )
257
+
258
+ # Read file
259
+ file_bytes = await read_file_bytes(file_path)
260
+ file_size = len(file_bytes)
261
+
262
+ if file_size > MAX_VIDEO_SIZE:
263
+ raise ValidationError(
264
+ f"Video exceeds {format_file_size(MAX_VIDEO_SIZE)} limit",
265
+ platform="linkedin",
266
+ )
267
+
268
+ # Register upload
269
+ register_data = {
270
+ "registerUploadRequest": {
271
+ "recipes": ["urn:li:digitalmediaRecipe:feedshare-video"],
272
+ "owner": self.person_urn,
273
+ "serviceRelationships": [
274
+ {
275
+ "relationshipType": "OWNER",
276
+ "identifier": "urn:li:userGeneratedContent",
277
+ }
278
+ ],
279
+ }
280
+ }
281
+
282
+ asset_id = await self._register_upload(register_data)
283
+
284
+ # Notify start
285
+ if self.progress_callback:
286
+ self.progress_callback(
287
+ UploadProgress(asset_id, 0, file_size, "registering")
288
+ )
289
+
290
+ # Get upload URL
291
+ upload_url = await self._get_upload_url(asset_id)
292
+
293
+ # Upload file
294
+ await self._upload_to_url(upload_url, file_bytes, file_size, asset_id)
295
+
296
+ # Wait for processing if requested
297
+ if wait_for_processing:
298
+ await self._wait_for_video_processing(asset_id)
299
+
300
+ # Notify completion
301
+ if self.progress_callback:
302
+ status = "completed" if wait_for_processing else "processing"
303
+ self.progress_callback(
304
+ UploadProgress(asset_id, file_size, file_size, status)
305
+ )
306
+
307
+ logger.info(f"Video uploaded successfully: {asset_id}")
308
+ return MediaAsset(
309
+ asset_id=asset_id, status="READY" if wait_for_processing else "PROCESSING"
310
+ )
311
+
312
+ async def upload_document(
313
+ self,
314
+ file_path: str,
315
+ *,
316
+ title: str | None = None, # noqa: ARG002
317
+ ) -> MediaAsset:
318
+ """Upload a document/PDF to LinkedIn.
319
+
320
+ Args:
321
+ file_path: Path to document file or URL.
322
+ title: Document title.
323
+
324
+ Returns:
325
+ MediaAsset with asset ID.
326
+
327
+ Raises:
328
+ MediaUploadError: If upload fails.
329
+ ValidationError: If file is invalid.
330
+
331
+ Example:
332
+ >>> asset = await manager.upload_document(
333
+ ... "presentation.pdf",
334
+ ... title="Q4 Report"
335
+ ... )
336
+ """
337
+ # Download if URL
338
+ if file_path.startswith(("http://", "https://")):
339
+ file_path = await download_file(file_path)
340
+
341
+ # Validate
342
+ mime_type = detect_mime_type(file_path)
343
+ if mime_type != "application/pdf":
344
+ raise ValidationError(
345
+ f"Only PDF documents are supported. Got: {mime_type}",
346
+ platform="linkedin",
347
+ )
348
+
349
+ # Read file
350
+ file_bytes = await read_file_bytes(file_path)
351
+ file_size = len(file_bytes)
352
+
353
+ if file_size > MAX_DOCUMENT_SIZE:
354
+ raise ValidationError(
355
+ f"Document exceeds {format_file_size(MAX_DOCUMENT_SIZE)} limit",
356
+ platform="linkedin",
357
+ )
358
+
359
+ # Register upload
360
+ register_data = {
361
+ "registerUploadRequest": {
362
+ "recipes": ["urn:li:digitalmediaRecipe:feedshare-document"],
363
+ "owner": self.person_urn,
364
+ "serviceRelationships": [
365
+ {
366
+ "relationshipType": "OWNER",
367
+ "identifier": "urn:li:userGeneratedContent",
368
+ }
369
+ ],
370
+ }
371
+ }
372
+
373
+ asset_id = await self._register_upload(register_data)
374
+
375
+ # Notify start
376
+ if self.progress_callback:
377
+ self.progress_callback(
378
+ UploadProgress(asset_id, 0, file_size, "registering")
379
+ )
380
+
381
+ # Get upload URL
382
+ upload_url = await self._get_upload_url(asset_id)
383
+
384
+ # Upload file
385
+ await self._upload_to_url(upload_url, file_bytes, file_size, asset_id)
386
+
387
+ # Notify completion
388
+ if self.progress_callback:
389
+ self.progress_callback(
390
+ UploadProgress(asset_id, file_size, file_size, "completed")
391
+ )
392
+
393
+ logger.info(f"Document uploaded successfully: {asset_id}")
394
+ return MediaAsset(asset_id=asset_id, status="READY")
395
+
396
+ async def get_video_status(self, asset_id: str) -> dict[str, Any]:
397
+ """Get processing status of a video asset.
398
+
399
+ Args:
400
+ asset_id: LinkedIn asset URN.
401
+
402
+ Returns:
403
+ Dictionary with video status information.
404
+
405
+ Example:
406
+ >>> status = await manager.get_video_status(asset_id)
407
+ >>> print(f"Status: {status['status']}")
408
+ """
409
+
410
+ @retry_async(config=STANDARD_BACKOFF)
411
+ async def _get_status() -> dict[str, Any]:
412
+ response = await self.client.get(
413
+ f"{self.base_url}/assets/{asset_id}",
414
+ )
415
+ response.raise_for_status()
416
+ return response.json()
417
+
418
+ return await _get_status()
419
+
420
+ async def _register_upload(self, register_data: dict[str, Any]) -> str:
421
+ """Register an upload and get asset ID."""
422
+
423
+ @retry_async(config=STANDARD_BACKOFF)
424
+ async def _register() -> str:
425
+ response = await self.client.post(
426
+ f"{self.base_url}/assets?action=registerUpload",
427
+ json=register_data,
428
+ )
429
+ response.raise_for_status()
430
+ result = response.json()
431
+ return result["value"]["asset"]
432
+
433
+ try:
434
+ return await _register()
435
+ except httpx.HTTPError as e:
436
+ raise MediaUploadError(
437
+ f"Failed to register upload: {e}",
438
+ platform="linkedin",
439
+ ) from e
440
+
441
+ async def _get_upload_url(self, asset_id: str) -> str:
442
+ """Get upload URL for an asset."""
443
+
444
+ @retry_async(config=STANDARD_BACKOFF)
445
+ async def _get_url() -> str:
446
+ response = await self.client.get(
447
+ f"{self.base_url}/assets/{asset_id}",
448
+ )
449
+ response.raise_for_status()
450
+ result = response.json()
451
+ return result["uploadMechanism"][
452
+ "com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"
453
+ ]["uploadUrl"]
454
+
455
+ try:
456
+ return await _get_url()
457
+ except httpx.HTTPError as e:
458
+ raise MediaUploadError(
459
+ f"Failed to get upload URL: {e}",
460
+ platform="linkedin",
461
+ ) from e
462
+
463
+ async def _upload_to_url(
464
+ self,
465
+ upload_url: str,
466
+ file_bytes: bytes,
467
+ file_size: int,
468
+ asset_id: str,
469
+ ) -> None:
470
+ """Upload file bytes to the upload URL."""
471
+
472
+ @retry_async(config=STANDARD_BACKOFF)
473
+ async def _upload() -> None:
474
+ # LinkedIn requires specific headers for upload
475
+ headers = {
476
+ "Content-Type": "application/octet-stream",
477
+ }
478
+
479
+ # Notify upload start
480
+ if self.progress_callback:
481
+ self.progress_callback(
482
+ UploadProgress(asset_id, 0, file_size, "uploading")
483
+ )
484
+
485
+ response = await self.client.put(
486
+ upload_url,
487
+ content=file_bytes,
488
+ headers=headers,
489
+ )
490
+ response.raise_for_status()
491
+
492
+ # Notify upload complete
493
+ if self.progress_callback:
494
+ self.progress_callback(
495
+ UploadProgress(asset_id, file_size, file_size, "uploading")
496
+ )
497
+
498
+ try:
499
+ await _upload()
500
+ except httpx.HTTPError as e:
501
+ raise MediaUploadError(
502
+ f"Failed to upload file: {e}",
503
+ platform="linkedin",
504
+ ) from e
505
+
506
+ async def _wait_for_video_processing(
507
+ self,
508
+ asset_id: str,
509
+ *,
510
+ timeout: int = VIDEO_PROCESSING_TIMEOUT,
511
+ check_interval: int = 5,
512
+ ) -> None:
513
+ """Wait for video processing to complete."""
514
+ elapsed = 0
515
+ logger.info(f"Waiting for video {asset_id} to process...")
516
+
517
+ while elapsed < timeout:
518
+ status_data = await self.get_video_status(asset_id)
519
+ status = status_data.get("status")
520
+
521
+ if status in (
522
+ VideoProcessingState.READY.value,
523
+ VideoProcessingState.AVAILABLE.value,
524
+ ):
525
+ logger.info(f"Video {asset_id} processing complete")
526
+ return
527
+
528
+ if status == VideoProcessingState.FAILED.value:
529
+ raise MediaUploadError(
530
+ f"Video processing failed for {asset_id}",
531
+ platform="linkedin",
532
+ media_type="video",
533
+ )
534
+
535
+ # Notify progress
536
+ if self.progress_callback:
537
+ progress_pct = min(int((elapsed / timeout) * 90), 90)
538
+ self.progress_callback(
539
+ UploadProgress(asset_id, progress_pct, 100, "processing")
540
+ )
541
+
542
+ await asyncio.sleep(check_interval)
543
+ elapsed += check_interval
544
+
545
+ raise MediaUploadError(
546
+ f"Video processing timeout after {timeout}s",
547
+ platform="linkedin",
548
+ media_type="video",
549
+ )