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,335 @@
1
+ """LinkedIn-specific exception handling and error code mapping.
2
+
3
+ This module provides comprehensive error handling for LinkedIn API errors including:
4
+ - HTTP status code mapping
5
+ - LinkedIn-specific service error codes
6
+ - Retry strategies
7
+ - User-friendly error messages
8
+ """
9
+
10
+ from typing import Any
11
+
12
+ from marqetive.platforms.exceptions import (
13
+ MediaUploadError,
14
+ PlatformAuthError,
15
+ PlatformError,
16
+ PostNotFoundError,
17
+ RateLimitError,
18
+ ValidationError,
19
+ )
20
+
21
+
22
+ # LinkedIn API error codes
23
+ # Source: https://learn.microsoft.com/en-us/linkedin/shared/api-guide/concepts/error-handling
24
+ class LinkedInErrorCode:
25
+ """LinkedIn API error codes."""
26
+
27
+ # Authentication errors (401)
28
+ INVALID_TOKEN = 401
29
+ EXPIRED_TOKEN = 401
30
+ REVOKED_ACCESS = 401
31
+
32
+ # Authorization errors (403)
33
+ ACCESS_DENIED = 403
34
+ INSUFFICIENT_PERMISSIONS = 403
35
+
36
+ # Resource errors (404)
37
+ RESOURCE_NOT_FOUND = 404
38
+ ENTITY_NOT_FOUND = 404
39
+
40
+ # Validation errors (400)
41
+ BAD_REQUEST = 400
42
+ MALFORMED_REQUEST = 400
43
+ INVALID_PARAMETERS = 400
44
+
45
+ # Rate limiting (429)
46
+ RATE_LIMIT_EXCEEDED = 429
47
+ THROTTLE_LIMIT_REACHED = 429
48
+
49
+ # Server errors (500+)
50
+ INTERNAL_SERVER_ERROR = 500
51
+ SERVICE_UNAVAILABLE = 503
52
+ GATEWAY_TIMEOUT = 504
53
+
54
+ # Protocol errors
55
+ METHOD_NOT_ALLOWED = 405
56
+ LENGTH_REQUIRED = 411
57
+ VERSION_DEPRECATED = 426
58
+
59
+
60
+ # Mapping of status codes to user-friendly messages
61
+ ERROR_MESSAGES: dict[int, str] = {
62
+ # Authentication
63
+ 401: "Invalid or expired access token. Please re-authenticate.",
64
+ # Authorization
65
+ 403: "Access denied. Insufficient permissions to access this resource.",
66
+ # Resources
67
+ 404: "The requested resource or entity does not exist.",
68
+ 405: "HTTP method not allowed for this endpoint.",
69
+ # Validation
70
+ 400: "Bad request. Please check your request parameters.",
71
+ 411: "Content-Length header is required for this request.",
72
+ 426: "API version header is deprecated. Please update to latest version.",
73
+ # Rate limiting
74
+ 429: "Rate limit exceeded. Please reduce request frequency.",
75
+ # Server errors
76
+ 500: "LinkedIn server error. Please try again later.",
77
+ 503: "LinkedIn service temporarily unavailable. Please try again later.",
78
+ 504: "Gateway timeout. LinkedIn servers took too long to respond.",
79
+ }
80
+
81
+
82
+ # Retryable HTTP status codes
83
+ RETRYABLE_STATUS_CODES = {
84
+ 408, # Request timeout
85
+ 429, # Too many requests
86
+ 500, # Internal server error
87
+ 502, # Bad gateway
88
+ 503, # Service unavailable
89
+ 504, # Gateway timeout
90
+ }
91
+
92
+ # Non-retryable HTTP status codes
93
+ NON_RETRYABLE_STATUS_CODES = {
94
+ 400, # Bad request
95
+ 401, # Unauthorized
96
+ 403, # Forbidden
97
+ 404, # Not found
98
+ 405, # Method not allowed
99
+ 411, # Length required
100
+ 426, # Version deprecated
101
+ }
102
+
103
+
104
+ def map_linkedin_error(
105
+ status_code: int | None,
106
+ service_error_code: int | None = None,
107
+ error_message: str | None = None,
108
+ response_data: dict[str, Any] | None = None,
109
+ ) -> PlatformError:
110
+ """Map LinkedIn API error to appropriate exception.
111
+
112
+ Args:
113
+ status_code: HTTP status code.
114
+ service_error_code: LinkedIn-specific service error code.
115
+ error_message: Error message from API.
116
+ response_data: Full response data from API.
117
+
118
+ Returns:
119
+ Appropriate PlatformError subclass.
120
+
121
+ Example:
122
+ >>> error = map_linkedin_error(401, error_message="Invalid token")
123
+ >>> print(type(error).__name__)
124
+ PlatformAuthError
125
+ """
126
+ # Extract error details from response if provided
127
+ if response_data:
128
+ if not service_error_code:
129
+ service_error_code = response_data.get("serviceErrorCode")
130
+ if not error_message:
131
+ error_message = response_data.get("message")
132
+ if not status_code:
133
+ status_code = response_data.get("status")
134
+
135
+ # Get user-friendly message
136
+ friendly_message = ERROR_MESSAGES.get(
137
+ status_code or 0, error_message or "Unknown error"
138
+ )
139
+
140
+ # Determine retry-after for rate limits
141
+ retry_after = None
142
+ if status_code == 429:
143
+ # LinkedIn typically uses 1-hour windows
144
+ retry_after = 3600 # 1 hour in seconds
145
+
146
+ # Map to appropriate exception type
147
+ # Authentication errors
148
+ if status_code == 401:
149
+ return PlatformAuthError(
150
+ friendly_message,
151
+ platform="linkedin",
152
+ status_code=status_code,
153
+ )
154
+
155
+ # Authorization errors
156
+ if status_code == 403:
157
+ return PlatformAuthError(
158
+ friendly_message,
159
+ platform="linkedin",
160
+ status_code=status_code,
161
+ )
162
+
163
+ # Rate limit errors
164
+ if status_code == 429:
165
+ return RateLimitError(
166
+ friendly_message,
167
+ platform="linkedin",
168
+ status_code=status_code,
169
+ retry_after=retry_after,
170
+ )
171
+
172
+ # Resource not found
173
+ if status_code == 404:
174
+ # Try to extract post/resource ID if available
175
+ resource_id = None
176
+ if response_data and isinstance(response_data, dict):
177
+ # Common patterns for resource IDs in LinkedIn responses
178
+ resource_id = response_data.get("id") or response_data.get("entityUrn")
179
+
180
+ if resource_id:
181
+ return PostNotFoundError(
182
+ post_id=str(resource_id),
183
+ platform="linkedin",
184
+ status_code=status_code,
185
+ )
186
+ return PlatformError(
187
+ friendly_message,
188
+ platform="linkedin",
189
+ status_code=status_code,
190
+ )
191
+
192
+ # Validation errors
193
+ if status_code in (400, 411, 426):
194
+ return ValidationError(
195
+ friendly_message,
196
+ platform="linkedin",
197
+ )
198
+
199
+ # Media upload errors (detected by context or specific codes)
200
+ # LinkedIn doesn't have specific codes, but we can detect from message
201
+ if error_message and any(
202
+ keyword in error_message.lower()
203
+ for keyword in ["upload", "media", "asset", "file", "video", "image"]
204
+ ):
205
+ return MediaUploadError(
206
+ friendly_message,
207
+ platform="linkedin",
208
+ status_code=status_code,
209
+ )
210
+
211
+ # Generic platform error
212
+ return PlatformError(
213
+ friendly_message,
214
+ platform="linkedin",
215
+ status_code=status_code,
216
+ )
217
+
218
+
219
+ def is_retryable_linkedin_error(
220
+ status_code: int | None,
221
+ service_error_code: int | None = None, # noqa: ARG001
222
+ ) -> bool:
223
+ """Determine if a LinkedIn error is retryable.
224
+
225
+ Args:
226
+ status_code: HTTP status code.
227
+ service_error_code: LinkedIn-specific service error code.
228
+
229
+ Returns:
230
+ True if error is retryable, False otherwise.
231
+
232
+ Example:
233
+ >>> is_retryable_linkedin_error(503)
234
+ True
235
+ >>> is_retryable_linkedin_error(401)
236
+ False
237
+ """
238
+ # Check explicit non-retryable codes first
239
+ if status_code in NON_RETRYABLE_STATUS_CODES:
240
+ return False
241
+
242
+ # Check retryable codes
243
+ if status_code in RETRYABLE_STATUS_CODES:
244
+ return True
245
+
246
+ # Check HTTP status codes
247
+ if status_code:
248
+ # 5xx errors are generally retryable
249
+ if 500 <= status_code < 600:
250
+ return True
251
+ # 4xx errors (except 429, 408) are generally not retryable
252
+ if 400 <= status_code < 500:
253
+ return False
254
+
255
+ # Default to not retryable for safety
256
+ return False
257
+
258
+
259
+ def get_retry_delay(
260
+ status_code: int | None,
261
+ service_error_code: int | None = None, # noqa: ARG001
262
+ attempt: int = 1,
263
+ ) -> float:
264
+ """Get recommended retry delay for LinkedIn error.
265
+
266
+ Args:
267
+ status_code: HTTP status code.
268
+ service_error_code: LinkedIn-specific service error code.
269
+ attempt: Current retry attempt number.
270
+
271
+ Returns:
272
+ Recommended delay in seconds.
273
+
274
+ Example:
275
+ >>> get_retry_delay(503, attempt=1)
276
+ 5.0
277
+ >>> get_retry_delay(429)
278
+ 3600.0
279
+ """
280
+ # Rate limit errors - wait full window
281
+ if status_code == 429:
282
+ return 3600.0 # 1 hour
283
+
284
+ # Server errors - exponential backoff
285
+ if status_code and 500 <= status_code < 600:
286
+ base_delay = 5.0
287
+ return min(base_delay * (2 ** (attempt - 1)), 120.0)
288
+
289
+ # Gateway timeout - longer delay
290
+ if status_code == 504:
291
+ return 30.0
292
+
293
+ # Default delay
294
+ return 10.0
295
+
296
+
297
+ class LinkedInAPIError(PlatformError):
298
+ """LinkedIn API specific error with detailed information.
299
+
300
+ Attributes:
301
+ service_error_code: LinkedIn-specific service error code.
302
+ is_retryable: Whether the error is retryable.
303
+ retry_delay: Recommended retry delay in seconds.
304
+ """
305
+
306
+ def __init__(
307
+ self,
308
+ message: str,
309
+ *,
310
+ status_code: int | None = None,
311
+ service_error_code: int | None = None,
312
+ response_data: dict[str, Any] | None = None,
313
+ ) -> None:
314
+ """Initialize LinkedIn API error.
315
+
316
+ Args:
317
+ message: Error message.
318
+ status_code: HTTP status code.
319
+ service_error_code: LinkedIn-specific service error code.
320
+ response_data: Full response data from API.
321
+ """
322
+ super().__init__(message, platform="linkedin", status_code=status_code)
323
+ self.service_error_code = service_error_code
324
+ self.response_data = response_data
325
+ self.is_retryable = is_retryable_linkedin_error(status_code, service_error_code)
326
+ self.retry_delay = get_retry_delay(status_code, service_error_code)
327
+
328
+ def __repr__(self) -> str:
329
+ """String representation of error."""
330
+ return (
331
+ f"LinkedInAPIError(message={self.message!r}, "
332
+ f"status_code={self.status_code}, "
333
+ f"service_error_code={self.service_error_code}, "
334
+ f"retryable={self.is_retryable})"
335
+ )
@@ -0,0 +1,130 @@
1
+ """LinkedIn account factory for managing credentials and client creation."""
2
+
3
+ import logging
4
+ import os
5
+ from collections.abc import Callable
6
+
7
+ from marqetive.core.account_factory import BaseAccountFactory
8
+ from marqetive.platforms.exceptions import PlatformAuthError
9
+ from marqetive.platforms.linkedin.client import LinkedInClient
10
+ from marqetive.platforms.models import AccountStatus, AuthCredentials
11
+ from marqetive.utils.oauth import refresh_linkedin_token
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class LinkedInAccountFactory(BaseAccountFactory):
17
+ """Factory for creating and managing LinkedIn accounts and clients.
18
+
19
+ Example:
20
+ >>> factory = LinkedInAccountFactory(
21
+ ... client_id="your_client_id",
22
+ ... client_secret="your_client_secret"
23
+ ... )
24
+ >>> credentials = AuthCredentials(
25
+ ... platform="linkedin",
26
+ ... access_token="token",
27
+ ... refresh_token="refresh"
28
+ ... )
29
+ >>> client = await factory.create_authenticated_client(credentials)
30
+ >>> async with client:
31
+ ... post = await client.create_post(request)
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ client_id: str | None = None,
37
+ client_secret: str | None = None,
38
+ on_status_update: Callable[[str, AccountStatus], None] | None = None,
39
+ ) -> None:
40
+ """Initialize LinkedIn account factory.
41
+
42
+ Args:
43
+ client_id: LinkedIn OAuth client ID (uses LINKEDIN_CLIENT_ID env if None).
44
+ client_secret: LinkedIn OAuth client secret (uses LINKEDIN_CLIENT_SECRET env if None).
45
+ on_status_update: Optional callback when account status changes.
46
+ """
47
+ super().__init__(on_status_update=on_status_update)
48
+ self.client_id = client_id or os.getenv("LINKEDIN_CLIENT_ID")
49
+ self.client_secret = client_secret or os.getenv("LINKEDIN_CLIENT_SECRET")
50
+
51
+ if not self.client_id or not self.client_secret:
52
+ logger.warning(
53
+ "LinkedIn client_id/client_secret not provided. "
54
+ "Token refresh will not work."
55
+ )
56
+
57
+ @property
58
+ def platform_name(self) -> str:
59
+ """Get platform name."""
60
+ return "linkedin"
61
+
62
+ async def refresh_token(self, credentials: AuthCredentials) -> AuthCredentials:
63
+ """Refresh LinkedIn OAuth2 access token.
64
+
65
+ Args:
66
+ credentials: Current credentials with refresh token.
67
+
68
+ Returns:
69
+ Updated credentials with new access token.
70
+
71
+ Raises:
72
+ PlatformAuthError: If refresh fails or credentials missing.
73
+ """
74
+ if not self.client_id or not self.client_secret:
75
+ raise PlatformAuthError(
76
+ "LinkedIn client_id and client_secret are required for token refresh",
77
+ platform=self.platform_name,
78
+ )
79
+
80
+ if not credentials.refresh_token:
81
+ raise PlatformAuthError(
82
+ "No refresh token available",
83
+ platform=self.platform_name,
84
+ )
85
+
86
+ logger.info("Refreshing LinkedIn access token...")
87
+ return await refresh_linkedin_token(
88
+ credentials,
89
+ self.client_id,
90
+ self.client_secret,
91
+ )
92
+
93
+ async def create_client(self, credentials: AuthCredentials) -> LinkedInClient:
94
+ """Create LinkedIn API client.
95
+
96
+ Args:
97
+ credentials: Valid LinkedIn credentials.
98
+
99
+ Returns:
100
+ LinkedInClient instance.
101
+
102
+ Raises:
103
+ PlatformAuthError: If credentials are invalid.
104
+ """
105
+ if not credentials.access_token:
106
+ raise PlatformAuthError(
107
+ "Access token is required",
108
+ platform=self.platform_name,
109
+ )
110
+
111
+ return LinkedInClient(credentials=credentials)
112
+
113
+ async def validate_credentials(self, credentials: AuthCredentials) -> bool:
114
+ """Validate LinkedIn credentials by making a test API call.
115
+
116
+ Args:
117
+ credentials: Credentials to validate.
118
+
119
+ Returns:
120
+ True if credentials are valid, False otherwise.
121
+ """
122
+ try:
123
+ client = await self.create_client(credentials)
124
+ async with client:
125
+ # Try to verify credentials by getting current user
126
+ # This would need to be implemented in the client
127
+ return await client.is_authenticated()
128
+ except Exception as e:
129
+ logger.error(f"Error validating LinkedIn credentials: {e}")
130
+ return False
@@ -0,0 +1,119 @@
1
+ """LinkedIn post manager for handling post operations."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from marqetive.core.base_manager import BasePostManager
7
+ from marqetive.platforms.linkedin.client import LinkedInClient
8
+ from marqetive.platforms.linkedin.factory import LinkedInAccountFactory
9
+ from marqetive.platforms.models import AuthCredentials, Post, PostCreateRequest
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class LinkedInPostManager(BasePostManager):
15
+ """Manager for LinkedIn post operations.
16
+
17
+ Coordinates post creation, media uploads, and progress tracking for LinkedIn.
18
+
19
+ Example:
20
+ >>> manager = LinkedInPostManager()
21
+ >>> credentials = AuthCredentials(
22
+ ... platform="linkedin",
23
+ ... access_token="token",
24
+ ... refresh_token="refresh"
25
+ ... )
26
+ >>> request = PostCreateRequest(content="Hello LinkedIn!")
27
+ >>> post = await manager.execute_post(credentials, request)
28
+ >>> print(f"Post URN: {post.post_id}")
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ account_factory: LinkedInAccountFactory | None = None,
34
+ client_id: str | None = None,
35
+ client_secret: str | None = None,
36
+ ) -> None:
37
+ """Initialize LinkedIn post manager.
38
+
39
+ Args:
40
+ account_factory: LinkedIn account factory (creates default if None).
41
+ client_id: LinkedIn OAuth client ID (for default factory).
42
+ client_secret: LinkedIn OAuth client secret (for default factory).
43
+ """
44
+ if account_factory is None:
45
+ account_factory = LinkedInAccountFactory(
46
+ client_id=client_id,
47
+ client_secret=client_secret,
48
+ )
49
+
50
+ super().__init__(account_factory=account_factory)
51
+
52
+ @property
53
+ def platform_name(self) -> str:
54
+ """Get platform name."""
55
+ return "linkedin"
56
+
57
+ async def _execute_post_impl(
58
+ self,
59
+ client: Any,
60
+ request: PostCreateRequest,
61
+ credentials: AuthCredentials, # noqa: ARG002
62
+ ) -> Post:
63
+ """Execute LinkedIn post creation.
64
+
65
+ Args:
66
+ client: LinkedInClient instance.
67
+ request: Post creation request.
68
+ credentials: LinkedIn credentials.
69
+
70
+ Returns:
71
+ Created Post object.
72
+ """
73
+ if not isinstance(client, LinkedInClient):
74
+ raise TypeError(f"Expected LinkedInClient, got {type(client)}")
75
+
76
+ # Handle media uploads with progress tracking
77
+ media_ids: list[str] = []
78
+ if request.media_urls:
79
+ self._progress_tracker.emit_start(
80
+ "upload_media",
81
+ total=len(request.media_urls),
82
+ message=f"Uploading {len(request.media_urls)} media files...",
83
+ )
84
+
85
+ for idx, media_url in enumerate(request.media_urls):
86
+ if self.is_cancelled():
87
+ raise InterruptedError("Post creation was cancelled")
88
+
89
+ self._progress_tracker.emit_progress(
90
+ "upload_media",
91
+ progress=idx,
92
+ total=len(request.media_urls),
93
+ message=f"Uploading media {idx + 1}/{len(request.media_urls)}...",
94
+ )
95
+
96
+ media_attachment = await client.upload_media(
97
+ media_url=media_url,
98
+ media_type="image", # Default to image
99
+ alt_text=None,
100
+ )
101
+ media_ids.append(media_attachment.media_id)
102
+
103
+ self._progress_tracker.emit_complete(
104
+ "upload_media",
105
+ message="All media uploaded successfully",
106
+ )
107
+
108
+ # Create post with progress tracking
109
+ self._progress_tracker.emit_progress(
110
+ "execute_post",
111
+ progress=50,
112
+ total=100,
113
+ message="Creating LinkedIn post...",
114
+ )
115
+
116
+ # Use the client to create the post
117
+ post = await client.create_post(request)
118
+
119
+ return post