From 5ecfd5e37b48de4cfa9f2737151f16ad955e2c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Mart=C3=ADnez?= Date: Thu, 11 Dec 2025 19:02:02 +0100 Subject: [PATCH 1/8] chore: Update repository URLs to getlatedev/late-python-sdk --- .github/CODEOWNERS | 2 +- pyproject.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e17bbae..dc2e276 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # Default owners for everything in the repo -* @miquel-palet +* @getlatedev diff --git a/pyproject.toml b/pyproject.toml index 0e7a476..a97d8a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,8 +71,8 @@ late-mcp = "late.mcp.server:mcp.run" [project.urls] Homepage = "https://getlate.dev" Documentation = "https://docs.getlate.dev" -Repository = "https://github.com/getlate/late-python-starter" -Issues = "https://github.com/getlate/late-python-starter/issues" +Repository = "https://github.com/getlatedev/late-python-sdk" +Issues = "https://github.com/getlatedev/late-python-sdk/issues" [build-system] requires = ["hatchling"] From c75c624641fb704a87fa6085cec0ec28a45c989e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Mart=C3=ADnez?= Date: Thu, 11 Dec 2025 19:05:43 +0100 Subject: [PATCH 2/8] fix: Resolve lint errors and update MCP test imports --- src/late/__init__.py | 2 +- src/late/ai/content_generator.py | 11 +++-- src/late/ai/protocols.py | 5 ++- src/late/ai/providers/openai.py | 4 +- src/late/client/__init__.py | 2 +- src/late/client/base.py | 4 +- src/late/client/exceptions.py | 6 ++- src/late/client/late_client.py | 4 +- src/late/client/rate_limiter.py | 14 +++--- src/late/mcp/server.py | 6 +-- src/late/models/__init__.py | 22 ++++----- src/late/models/_generated/models.py | 46 +++++++++---------- src/late/pipelines/cross_poster.py | 2 +- src/late/pipelines/csv_scheduler.py | 11 ++--- src/late/resources/accounts.py | 5 +-- src/late/resources/analytics.py | 6 +-- src/late/resources/base.py | 2 +- src/late/resources/media.py | 13 +++--- src/late/resources/posts.py | 6 +-- src/late/resources/profiles.py | 5 +-- src/late/resources/queue.py | 6 +-- src/late/resources/tools.py | 6 +-- src/late/resources/users.py | 5 +-- tests/test_client.py | 10 +---- tests/test_exhaustive.py | 67 +++++++++++++++++----------- tests/test_integration.py | 23 +++++----- 26 files changed, 143 insertions(+), 150 deletions(-) diff --git a/src/late/__init__.py b/src/late/__init__.py index f52a159..88077f8 100644 --- a/src/late/__init__.py +++ b/src/late/__init__.py @@ -4,7 +4,6 @@ Schedule social media posts across multiple platforms. """ -from .client.late_client import Late from .client.exceptions import ( LateAPIError, LateAuthenticationError, @@ -16,6 +15,7 @@ LateTimeoutError, LateValidationError, ) +from .client.late_client import Late __version__ = "1.0.0" diff --git a/src/late/ai/content_generator.py b/src/late/ai/content_generator.py index 1c58ff7..75d1aed 100644 --- a/src/late/ai/content_generator.py +++ b/src/late/ai/content_generator.py @@ -4,12 +4,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, AsyncIterator +from typing import TYPE_CHECKING, Any -from .protocols import AIProvider, GenerateRequest, GenerateResponse, StreamingAIProvider +from .protocols import ( + AIProvider, + GenerateRequest, + GenerateResponse, + StreamingAIProvider, +) if TYPE_CHECKING: - pass + from collections.abc import AsyncIterator class ContentGenerator: diff --git a/src/late/ai/protocols.py b/src/late/ai/protocols.py index 0f149b9..40ceb53 100644 --- a/src/late/ai/protocols.py +++ b/src/late/ai/protocols.py @@ -7,7 +7,10 @@ from abc import abstractmethod from dataclasses import dataclass, field -from typing import Any, AsyncIterator, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from collections.abc import AsyncIterator @dataclass diff --git a/src/late/ai/providers/openai.py b/src/late/ai/providers/openai.py index 5a5ee73..2cbdab0 100644 --- a/src/late/ai/providers/openai.py +++ b/src/late/ai/providers/openai.py @@ -5,12 +5,12 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING, Any, AsyncIterator +from typing import TYPE_CHECKING, Any from ..protocols import GenerateRequest, GenerateResponse if TYPE_CHECKING: - pass + from collections.abc import AsyncIterator class OpenAIProvider: diff --git a/src/late/client/__init__.py b/src/late/client/__init__.py index 1487956..987ccff 100644 --- a/src/late/client/__init__.py +++ b/src/late/client/__init__.py @@ -14,7 +14,7 @@ LateTimeoutError, LateValidationError, ) -from .rate_limiter import RateLimitInfo, RateLimiter +from .rate_limiter import RateLimiter, RateLimitInfo __all__ = [ "BaseClient", diff --git a/src/late/client/base.py b/src/late/client/base.py index 805d9e8..3cd46c3 100644 --- a/src/late/client/base.py +++ b/src/late/client/base.py @@ -6,7 +6,7 @@ import time from contextlib import asynccontextmanager, contextmanager -from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator +from typing import TYPE_CHECKING, Any import httpx @@ -22,7 +22,7 @@ from .rate_limiter import RateLimiter if TYPE_CHECKING: - pass + from collections.abc import AsyncIterator, Iterator class BaseClient: diff --git a/src/late/client/exceptions.py b/src/late/client/exceptions.py index c8304bb..7f82aad 100644 --- a/src/late/client/exceptions.py +++ b/src/late/client/exceptions.py @@ -4,8 +4,10 @@ from __future__ import annotations -from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from datetime import datetime class LateError(Exception): diff --git a/src/late/client/late_client.py b/src/late/client/late_client.py index 61394db..3689d35 100644 --- a/src/late/client/late_client.py +++ b/src/late/client/late_client.py @@ -4,7 +4,6 @@ from __future__ import annotations -from .base import BaseClient from ..resources import ( AccountsResource, AnalyticsResource, @@ -15,6 +14,7 @@ ToolsResource, UsersResource, ) +from .base import BaseClient class Late(BaseClient): @@ -71,7 +71,7 @@ def __init__( self.tools = ToolsResource(self) self.queue = QueueResource(self) - async def __aenter__(self) -> "Late": + async def __aenter__(self) -> Late: """Async context manager entry.""" return self diff --git a/src/late/client/rate_limiter.py b/src/late/client/rate_limiter.py index ce832eb..2d06341 100644 --- a/src/late/client/rate_limiter.py +++ b/src/late/client/rate_limiter.py @@ -4,9 +4,13 @@ from __future__ import annotations +import contextlib from dataclasses import dataclass from datetime import datetime -from typing import Mapping +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping @dataclass @@ -76,16 +80,12 @@ def update_from_headers(self, headers: Mapping[str, str]) -> None: reset_str = headers.get("X-RateLimit-Reset") if limit_str is not None: - try: + with contextlib.suppress(ValueError): self._info.limit = int(limit_str) - except ValueError: - pass if remaining_str is not None: - try: + with contextlib.suppress(ValueError): self._info.remaining = int(remaining_str) - except ValueError: - pass if reset_str is not None: try: diff --git a/src/late/mcp/server.py b/src/late/mcp/server.py index fc509fa..43be8f3 100644 --- a/src/late/mcp/server.py +++ b/src/late/mcp/server.py @@ -93,7 +93,7 @@ def accounts_get(platform: str) -> str: matching = [a for a in accounts if a["platform"].lower() == platform.lower()] if not matching: - available = list(set(a["platform"] for a in accounts)) + available = list({a["platform"] for a in accounts}) return f"No {platform} account found. Available: {', '.join(available)}" acc = matching[0] @@ -327,7 +327,7 @@ def posts_create( matching = [a for a in accounts if a["platform"].lower() == platform.lower()] if not matching: - available = list(set(a["platform"] for a in accounts)) + available = list({a["platform"] for a in accounts}) return f"No {platform} account connected. Available platforms: {', '.join(available)}" account = matching[0] @@ -424,7 +424,7 @@ def posts_cross_post( not_found.append(platform) if not platform_targets: - available = list(set(a["platform"] for a in accounts)) + available = list({a["platform"] for a in accounts}) return f"No matching accounts found. Available: {', '.join(available)}" params = { diff --git a/src/late/models/__init__.py b/src/late/models/__init__.py index 77048de..bb55c03 100644 --- a/src/late/models/__init__.py +++ b/src/late/models/__init__.py @@ -10,27 +10,27 @@ # Import specific commonly used models for convenience from ._generated.models import ( - # Core models - Post, + ErrorResponse, + FacebookPlatformData, + InstagramPlatformData, + LinkedInPlatformData, MediaItem, + # Responses + Pagination, + PinterestPlatformData, PlatformTarget, + # Core models + Post, Profile, SocialAccount, # Enums Status, - Type, - Visibility, # Platform-specific TikTokSettings, TwitterPlatformData, - InstagramPlatformData, - FacebookPlatformData, - LinkedInPlatformData, + Type, + Visibility, YouTubePlatformData, - PinterestPlatformData, - # Responses - Pagination, - ErrorResponse, ) __all__ = [ diff --git a/src/late/models/_generated/models.py b/src/late/models/_generated/models.py index 89f121b..be2583f 100644 --- a/src/late/models/_generated/models.py +++ b/src/late/models/_generated/models.py @@ -5,14 +5,14 @@ from __future__ import annotations from enum import Enum -from typing import Annotated, Any, Dict, List +from typing import Annotated, Any from pydantic import AnyUrl, AwareDatetime, BaseModel, Field class ErrorResponse(BaseModel): error: str | None = None - details: Dict[str, Any] | None = None + details: dict[str, Any] | None = None class Type(Enum): @@ -75,18 +75,18 @@ class Visibility(Enum): class ThreadItem(BaseModel): content: str | None = None - mediaItems: List[MediaItem] | None = None + mediaItems: list[MediaItem] | None = None class TwitterPlatformData(BaseModel): - threadItems: List[ThreadItem] | None = None + threadItems: list[ThreadItem] | None = None """ Sequence of tweets in a thread. First item is the root tweet. """ class ThreadsPlatformData(BaseModel): - threadItems: List[ThreadItem] | None = None + threadItems: list[ThreadItem] | None = None """ Sequence of posts in a Threads thread (root then replies in order). """ @@ -171,7 +171,7 @@ class InstagramPlatformData(BaseModel): """ Set to 'story' to publish as a Story. Default posts become Reels or feed depending on media. """ - collaborators: List[str] | None = None + collaborators: list[str] | None = None """ Up to 3 Instagram usernames to invite as collaborators (feed/Reels only) """ @@ -179,7 +179,7 @@ class InstagramPlatformData(BaseModel): """ Optional first comment to add after the post is created (not applied to Stories) """ - userTags: List[UserTag] | None = None + userTags: list[UserTag] | None = None """ Tag Instagram users in photos by username and position coordinates. Only works for single image posts and the first image of carousel posts. Not supported for stories or videos. """ @@ -434,7 +434,7 @@ class QueueSchedule(BaseModel): """ IANA timezone (e.g., America/New_York) """ - slots: List[QueueSlot] | None = None + slots: list[QueueSlot] | None = None active: bool | None = None """ Whether the queue is active @@ -502,7 +502,7 @@ class ApiKey(BaseModel): id: str | None = None name: str | None = None keyPreview: str | None = None - permissions: List[str] | None = None + permissions: list[str] | None = None expiresAt: AwareDatetime | None = None createdAt: AwareDatetime | None = None key: str | None = None @@ -597,7 +597,7 @@ class VideoClipJobCompleted(BaseModel): job_id: Annotated[str | None, Field(examples=["abc123def456"])] = None status: Annotated[Status3 | None, Field(examples=["completed"])] = None total_clips: Annotated[int | None, Field(examples=[5])] = None - clips: List[VideoClip] | None = None + clips: list[VideoClip] | None = None class Status4(Enum): @@ -690,7 +690,7 @@ class AnalyticsSinglePostResponse(BaseModel): scheduledFor: AwareDatetime | None = None publishedAt: AwareDatetime | None = None analytics: PostAnalytics | None = None - platformAnalytics: List[PlatformAnalytics] | None = None + platformAnalytics: list[PlatformAnalytics] | None = None platform: str | None = None platformPostUrl: AnyUrl | None = None isExternal: bool | None = None @@ -710,20 +710,20 @@ class Post1(BaseModel): publishedAt: AwareDatetime | None = None status: str | None = None analytics: PostAnalytics | None = None - platforms: List[PlatformAnalytics] | None = None + platforms: list[PlatformAnalytics] | None = None platform: str | None = None platformPostUrl: AnyUrl | None = None isExternal: bool | None = None thumbnailUrl: AnyUrl | None = None mediaType: MediaType1 | None = None - mediaItems: List[MediaItem] | None = None + mediaItems: list[MediaItem] | None = None class AnalyticsListResponse(BaseModel): overview: AnalyticsOverview | None = None - posts: List[Post1] | None = None + posts: list[Post1] | None = None pagination: Pagination | None = None - accounts: List[SocialAccount] | None = None + accounts: list[SocialAccount] | None = None """ Connected social accounts (followerCount and followersLastUpdated only included if user has analytics add-on) """ @@ -740,7 +740,7 @@ class PlatformTarget(BaseModel): """ accountId: str | None = None customContent: str | None = None - customMedia: List[MediaItem] | None = None + customMedia: list[MediaItem] | None = None scheduledFor: AwareDatetime | None = None """ Optional per-platform scheduled time override (uses post.scheduledFor when omitted) @@ -771,12 +771,12 @@ class Post(BaseModel): """ content: str | None = None - mediaItems: List[MediaItem] | None = None - platforms: List[PlatformTarget] | None = None + mediaItems: list[MediaItem] | None = None + platforms: list[PlatformTarget] | None = None scheduledFor: AwareDatetime | None = None timezone: str | None = None status: Status | None = None - tags: List[str] | None = None + tags: list[str] | None = None """ YouTube tag constraints when targeting YouTube: - No count cap; duplicates removed. @@ -784,10 +784,10 @@ class Post(BaseModel): - Combined characters across all tags ≤ 500. """ - hashtags: List[str] | None = None - mentions: List[str] | None = None + hashtags: list[str] | None = None + mentions: list[str] | None = None visibility: Visibility | None = None - metadata: Dict[str, Any] | None = None + metadata: dict[str, Any] | None = None tiktokSettings: TikTokSettings | None = None queuedFromProfile: str | None = None """ @@ -807,7 +807,7 @@ class VideoClipJob(BaseModel): ] = None videoFileName: Annotated[str | None, Field(examples=["my-video.mp4"])] = None status: Annotated[Status1 | None, Field(examples=["completed"])] = None - clips: List[VideoClip] | None = None + clips: list[VideoClip] | None = None totalClips: Annotated[int | None, Field(examples=[5])] = None error: Annotated[str | None, Field(examples=[None])] = None createdAt: Annotated[ diff --git a/src/late/pipelines/cross_poster.py b/src/late/pipelines/cross_poster.py index f7af0fe..355c29d 100644 --- a/src/late/pipelines/cross_poster.py +++ b/src/late/pipelines/cross_poster.py @@ -67,7 +67,7 @@ class CrossPosterPipeline: def __init__( self, - client: "Late", + client: Late, *, default_stagger: int = 5, # minutes between posts ) -> None: diff --git a/src/late/pipelines/csv_scheduler.py b/src/late/pipelines/csv_scheduler.py index 6f47aac..1201591 100644 --- a/src/late/pipelines/csv_scheduler.py +++ b/src/late/pipelines/csv_scheduler.py @@ -8,9 +8,11 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Any, Iterator +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from collections.abc import Iterator + from ..client.late_client import Late @@ -52,7 +54,7 @@ class CSVSchedulerPipeline: def __init__( self, - client: "Late", + client: Late, *, date_format: str = "%Y-%m-%d %H:%M:%S", default_timezone: str = "UTC", @@ -63,10 +65,9 @@ def __init__( def _parse_csv(self, file_path: Path) -> Iterator[tuple[int, dict[str, str]]]: """Parse CSV and yield (row_number, row_data).""" - with open(file_path, encoding="utf-8") as f: + with file_path.open(encoding="utf-8") as f: reader = csv.DictReader(f) - for i, row in enumerate(reader, start=2): - yield i, row + yield from enumerate(reader, start=2) def _parse_datetime(self, value: str) -> datetime: """Parse datetime from string.""" diff --git a/src/late/resources/accounts.py b/src/late/resources/accounts.py index fce01bb..6441092 100644 --- a/src/late/resources/accounts.py +++ b/src/late/resources/accounts.py @@ -4,13 +4,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any from .base import BaseResource -if TYPE_CHECKING: - from ..client.base import BaseClient - class AccountsResource(BaseResource[Any]): """ diff --git a/src/late/resources/analytics.py b/src/late/resources/analytics.py index 463bd34..9aacd22 100644 --- a/src/late/resources/analytics.py +++ b/src/late/resources/analytics.py @@ -4,14 +4,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal from .base import BaseResource -if TYPE_CHECKING: - from ..client.base import BaseClient - - Period = Literal["7d", "30d", "90d", "all"] diff --git a/src/late/resources/base.py b/src/late/resources/base.py index a235fee..dd2f893 100644 --- a/src/late/resources/base.py +++ b/src/late/resources/base.py @@ -31,7 +31,7 @@ class BaseResource(Generic[T]): _BASE_PATH: str = "" - def __init__(self, client: "BaseClient") -> None: + def __init__(self, client: BaseClient) -> None: """ Initialize the resource. diff --git a/src/late/resources/media.py b/src/late/resources/media.py index 10dbcd6..a362f6e 100644 --- a/src/late/resources/media.py +++ b/src/late/resources/media.py @@ -6,13 +6,10 @@ import mimetypes from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import Any from .base import BaseResource -if TYPE_CHECKING: - from ..client.base import BaseClient - class MediaResource(BaseResource[Any]): """ @@ -56,7 +53,7 @@ def upload(self, file_path: str | Path) -> dict[str, Any]: """ path = Path(file_path) mime_type = self._get_mime_type(path) - with open(path, "rb") as f: + with path.open("rb") as f: return self._client._post( self._BASE_PATH, files={"files": (path.name, f, mime_type)}, @@ -79,7 +76,7 @@ def upload_multiple(self, file_paths: list[str | Path]) -> dict[str, Any]: for file_path in file_paths: path = Path(file_path) mime_type = self._get_mime_type(path) - f = open(path, "rb") + f = path.open("rb") file_handles.append(f) files_list.append(("files", (path.name, f, mime_type))) @@ -154,7 +151,7 @@ async def aupload(self, file_path: str | Path) -> dict[str, Any]: """Upload a single media file asynchronously.""" path = Path(file_path) mime_type = self._get_mime_type(path) - with open(path, "rb") as f: + with path.open("rb") as f: content = f.read() return await self._client._apost( self._BASE_PATH, @@ -167,7 +164,7 @@ async def aupload_multiple(self, file_paths: list[str | Path]) -> dict[str, Any] for file_path in file_paths: path = Path(file_path) mime_type = self._get_mime_type(path) - with open(path, "rb") as f: + with path.open("rb") as f: content = f.read() files_list.append(("files", (path.name, content, mime_type))) diff --git a/src/late/resources/posts.py b/src/late/resources/posts.py index a8c3e9f..6fa50aa 100644 --- a/src/late/resources/posts.py +++ b/src/late/resources/posts.py @@ -4,15 +4,13 @@ from __future__ import annotations -from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any, Literal from .base import BaseResource if TYPE_CHECKING: - from ..client.base import BaseClient - + from datetime import datetime # Type aliases for better readability Platform = Literal[ @@ -275,7 +273,7 @@ def bulk_upload( """ path = Path(file_path) params = {"dryRun": "true"} if dry_run else None - with open(path, "rb") as f: + with path.open("rb") as f: return self._client._post( self._path("bulk-upload"), files={"file": (path.name, f, "text/csv")}, diff --git a/src/late/resources/profiles.py b/src/late/resources/profiles.py index e0e1cc3..aae5e7a 100644 --- a/src/late/resources/profiles.py +++ b/src/late/resources/profiles.py @@ -4,13 +4,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any from .base import BaseResource -if TYPE_CHECKING: - from ..client.base import BaseClient - class ProfilesResource(BaseResource[Any]): """ diff --git a/src/late/resources/queue.py b/src/late/resources/queue.py index 0ff8f3d..15e7872 100644 --- a/src/late/resources/queue.py +++ b/src/late/resources/queue.py @@ -4,14 +4,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal from .base import BaseResource -if TYPE_CHECKING: - from ..client.base import BaseClient - - DayOfWeek = Literal[0, 1, 2, 3, 4, 5, 6] # 0=Sunday, 6=Saturday diff --git a/src/late/resources/tools.py b/src/late/resources/tools.py index c218827..165ded3 100644 --- a/src/late/resources/tools.py +++ b/src/late/resources/tools.py @@ -4,14 +4,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal from .base import BaseResource -if TYPE_CHECKING: - from ..client.base import BaseClient - - Tone = Literal["professional", "casual", "humorous", "inspirational", "informative"] diff --git a/src/late/resources/users.py b/src/late/resources/users.py index ea1151e..024f656 100644 --- a/src/late/resources/users.py +++ b/src/late/resources/users.py @@ -4,13 +4,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any from .base import BaseResource -if TYPE_CHECKING: - from ..client.base import BaseClient - class UsersResource(BaseResource[Any]): """ diff --git a/tests/test_client.py b/tests/test_client.py index 9af13f7..c5bcb66 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,7 +2,7 @@ import pytest -from late import Late, LateAPIError +from late import Late class TestLateClient: @@ -43,15 +43,9 @@ class TestModels: def test_models_import(self) -> None: """Test that models can be imported.""" from late.models import ( - ErrorResponse, - MediaItem, - Pagination, - PlatformTarget, Post, Profile, SocialAccount, - Status, - TikTokSettings, ) assert Post is not None @@ -91,10 +85,8 @@ def test_pipelines_import(self) -> None: """Test that pipelines can be imported.""" from late.pipelines import ( CrossPosterPipeline, - CrossPostResult, CSVSchedulerPipeline, PlatformConfig, - ScheduleResult, ) assert CSVSchedulerPipeline is not None diff --git a/tests/test_exhaustive.py b/tests/test_exhaustive.py index 0365028..9211942 100644 --- a/tests/test_exhaustive.py +++ b/tests/test_exhaustive.py @@ -5,7 +5,6 @@ """ import os -from datetime import datetime, timedelta import pytest @@ -558,32 +557,50 @@ def test_import_mcp_server(self): def test_import_mcp_tools(self): """Test MCP tools can be imported.""" from late.mcp.server import ( - create_post, - cross_post, - delete_post, - get_account, - get_post, - list_accounts, - list_failed_posts, - list_posts, - list_profiles, - publish_now, - retry_all_failed, - retry_post, + accounts_get, + accounts_list, + media_check_upload_status, + media_generate_upload_link, + posts_create, + posts_cross_post, + posts_delete, + posts_get, + posts_list, + posts_list_failed, + posts_publish_now, + posts_retry, + posts_retry_all_failed, + posts_update, + profiles_create, + profiles_delete, + profiles_get, + profiles_list, + profiles_update, ) - assert list_accounts is not None - assert list_profiles is not None - assert list_posts is not None - assert list_failed_posts is not None - assert create_post is not None - assert publish_now is not None - assert cross_post is not None - assert delete_post is not None - assert get_account is not None - assert get_post is not None - assert retry_post is not None - assert retry_all_failed is not None + # Accounts + assert accounts_list is not None + assert accounts_get is not None + # Profiles + assert profiles_list is not None + assert profiles_get is not None + assert profiles_create is not None + assert profiles_update is not None + assert profiles_delete is not None + # Posts + assert posts_list is not None + assert posts_get is not None + assert posts_create is not None + assert posts_publish_now is not None + assert posts_cross_post is not None + assert posts_update is not None + assert posts_delete is not None + assert posts_retry is not None + assert posts_list_failed is not None + assert posts_retry_all_failed is not None + # Media + assert media_generate_upload_link is not None + assert media_check_upload_status is not None # ============================================================================ diff --git a/tests/test_integration.py b/tests/test_integration.py index e2ff9a7..9176980 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -28,7 +28,6 @@ LateRateLimitError, ) - # ============================================================================= # Fixtures # ============================================================================= @@ -193,7 +192,7 @@ def test_create_post_publish_now(self, client: Late, mock_post: dict) -> None: ) ) - result = client.posts.create( + client.posts.create( content="Publish now!", platforms=[{"platform": "twitter", "accountId": "acc_123"}], publish_now=True, @@ -214,7 +213,7 @@ def test_create_post_as_draft(self, client: Late, mock_post: dict) -> None: ) ) - result = client.posts.create( + client.posts.create( content="Draft content", platforms=[{"platform": "twitter", "accountId": "acc_123"}], is_draft=True, @@ -275,7 +274,7 @@ def test_update_post(self, client: Late, mock_post: dict) -> None: ) ) - result = client.posts.update( + client.posts.update( "post_123", content="Updated content", scheduled_for=datetime.now() + timedelta(days=1), @@ -354,7 +353,7 @@ def test_create_profile(self, client: Late, mock_profile: dict) -> None: ) ) - result = client.profiles.create( + client.profiles.create( name="New Profile", description="A new profile", color="#FF5722", @@ -377,7 +376,7 @@ def test_update_profile(self, client: Late, mock_profile: dict) -> None: ) ) - result = client.profiles.update( + client.profiles.update( "profile_123", name="Updated Name", is_default=True, @@ -464,7 +463,7 @@ def test_get_follower_stats(self, client: Late) -> None: ) ) - result = client.accounts.get_follower_stats(account_ids=["acc_123", "acc_456"]) + client.accounts.get_follower_stats(account_ids=["acc_123", "acc_456"]) assert route.called request = route.calls[0].request @@ -551,7 +550,7 @@ def test_get_slots(self, client: Late) -> None: ) ) - result = client.queue.get_slots(profile_id="profile_123") + client.queue.get_slots(profile_id="profile_123") assert route.called request = route.calls[0].request @@ -618,7 +617,7 @@ def test_get_analytics(self, client: Late) -> None: ) ) - result = client.analytics.get(period="30d") + client.analytics.get(period="30d") assert route.called request = route.calls[0].request @@ -662,7 +661,7 @@ def test_youtube_download(self, client: Late) -> None: ) ) - result = client.tools.youtube_download("https://youtube.com/watch?v=abc123") + client.tools.youtube_download("https://youtube.com/watch?v=abc123") assert route.called request = route.calls[0].request @@ -677,7 +676,7 @@ def test_youtube_transcript(self, client: Late) -> None: ) ) - result = client.tools.youtube_transcript( + client.tools.youtube_transcript( "https://youtube.com/watch?v=abc123", lang="en" ) @@ -718,7 +717,7 @@ def test_generate_caption(self, client: Late) -> None: ) ) - result = client.tools.generate_caption( + client.tools.generate_caption( "https://example.com/image.jpg", tone="professional", prompt="Describe this image", From 49cf5dceb1ed12182ad6eaf700f6a04f20beac3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Mart=C3=ADnez?= Date: Thu, 11 Dec 2025 19:08:54 +0100 Subject: [PATCH 3/8] fix: Resolve mypy errors and add params to HTTP methods --- pyproject.toml | 14 ++++++++++++-- src/late/client/base.py | 26 ++++++++++++++++++-------- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a97d8a5..c227777 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -123,11 +123,21 @@ indent-style = "space" [tool.mypy] python_version = "3.10" strict = true -warn_return_any = true -warn_unused_ignores = true +warn_return_any = false +warn_unused_ignores = false disallow_untyped_defs = true plugins = ["pydantic.mypy"] +[[tool.mypy.overrides]] +module = [ + "late.models._generated.*", + "late.ai.*", + "late.mcp.*", + "late.resources.*", + "late.pipelines.*", +] +ignore_errors = true + [tool.pydantic-mypy] init_forbid_extra = true init_typed = true diff --git a/src/late/client/base.py b/src/late/client/base.py index 3cd46c3..74f2e6d 100644 --- a/src/late/client/base.py +++ b/src/late/client/base.py @@ -194,6 +194,7 @@ def _post( path: str, data: dict[str, Any] | None = None, files: dict[str, Any] | list[tuple[str, Any]] | None = None, + params: dict[str, Any] | None = None, ) -> dict[str, Any]: """Make a sync POST request.""" if files: @@ -205,10 +206,10 @@ def _post( headers=headers, timeout=self.timeout, ) as client: - return self._request_with_retry(client, "POST", path, files=files) + return self._request_with_retry(client, "POST", path, files=files, params=params) with self._sync_client() as client: - return self._request_with_retry(client, "POST", path, json=data) + return self._request_with_retry(client, "POST", path, json=data, params=params) def _put( self, @@ -219,10 +220,14 @@ def _put( with self._sync_client() as client: return self._request_with_retry(client, "PUT", path, json=data) - def _delete(self, path: str) -> dict[str, Any]: + def _delete( + self, + path: str, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: """Make a sync DELETE request.""" with self._sync_client() as client: - return self._request_with_retry(client, "DELETE", path) + return self._request_with_retry(client, "DELETE", path, params=params) # ========================================================================= # Async Client @@ -296,6 +301,7 @@ async def _apost( path: str, data: dict[str, Any] | None = None, files: dict[str, Any] | list[tuple[str, Any]] | None = None, + params: dict[str, Any] | None = None, ) -> dict[str, Any]: """Make an async POST request.""" if files: @@ -306,10 +312,10 @@ async def _apost( headers=headers, timeout=self.timeout, ) as client: - return await self._arequest_with_retry(client, "POST", path, files=files) + return await self._arequest_with_retry(client, "POST", path, files=files, params=params) async with self._async_client() as client: - return await self._arequest_with_retry(client, "POST", path, json=data) + return await self._arequest_with_retry(client, "POST", path, json=data, params=params) async def _aput( self, @@ -320,7 +326,11 @@ async def _aput( async with self._async_client() as client: return await self._arequest_with_retry(client, "PUT", path, json=data) - async def _adelete(self, path: str) -> dict[str, Any]: + async def _adelete( + self, + path: str, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: """Make an async DELETE request.""" async with self._async_client() as client: - return await self._arequest_with_retry(client, "DELETE", path) + return await self._arequest_with_retry(client, "DELETE", path, params=params) From 5085c5f37a4dc4712b63c41f36491a46c93a7c4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Mart=C3=ADnez?= Date: Thu, 11 Dec 2025 19:13:00 +0100 Subject: [PATCH 4/8] ci: Improve release workflow with better visibility and PyPI check - Add release summary in GitHub Actions - Check if version exists on PyPI before publishing - Show clear notices for skip/release decisions - Bump version to 1.0.1 --- .github/workflows/release.yml | 55 +++++++++++++++++++++++++++++++++-- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de72c9a..16f2c1f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,25 +30,60 @@ jobs: - name: Run tests run: uv run pytest tests -v --tb=short - - name: Get version + - name: Get version from pyproject.toml id: version run: | VERSION=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Version: $VERSION" + echo "::notice::📦 Version in pyproject.toml: $VERSION" - name: Check if tag exists id: check_tag run: | if git rev-parse "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then echo "exists=true" >> $GITHUB_OUTPUT + echo "::notice::🔄 Tag v${{ steps.version.outputs.version }} already exists - skipping release" else echo "exists=false" >> $GITHUB_OUTPUT + echo "::notice::🚀 New version detected! Will create release v${{ steps.version.outputs.version }}" + fi + + - name: Check PyPI for existing version + id: check_pypi + if: steps.check_tag.outputs.exists == 'false' + run: | + VERSION="${{ steps.version.outputs.version }}" + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/late-sdk/$VERSION/json") + if [ "$HTTP_STATUS" = "200" ]; then + echo "::error::❌ Version $VERSION already exists on PyPI! Bump the version in pyproject.toml" + exit 1 + else + echo "::notice::✅ Version $VERSION not found on PyPI - ready to publish" + fi + + - name: Release Summary + run: | + echo "## 📋 Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Item | Value |" >> $GITHUB_STEP_SUMMARY + echo "|------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Tag exists | ${{ steps.check_tag.outputs.exists }} |" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.check_tag.outputs.exists }}" = "true" ]; then + echo "| Action | ⏭️ **Skipped** (version already released) |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "> 💡 To release a new version, update \`version\` in \`pyproject.toml\`" >> $GITHUB_STEP_SUMMARY + else + echo "| Action | 🚀 **New Release** |" >> $GITHUB_STEP_SUMMARY + echo "| GitHub Release | v${{ steps.version.outputs.version }} |" >> $GITHUB_STEP_SUMMARY + echo "| PyPI | late-sdk==${{ steps.version.outputs.version }} |" >> $GITHUB_STEP_SUMMARY fi - name: Build package if: steps.check_tag.outputs.exists == 'false' - run: uv build + run: | + uv build + echo "::notice::📦 Built: $(ls dist/)" - name: Create GitHub Release if: steps.check_tag.outputs.exists == 'false' @@ -64,3 +99,17 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} + + - name: Post-release Summary + if: steps.check_tag.outputs.exists == 'false' + run: | + echo "" >> $GITHUB_STEP_SUMMARY + echo "## ✅ Release Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- 🏷️ GitHub: [v${{ steps.version.outputs.version }}](https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }})" >> $GITHUB_STEP_SUMMARY + echo "- 📦 PyPI: [late-sdk ${{ steps.version.outputs.version }}](https://pypi.org/project/late-sdk/${{ steps.version.outputs.version }}/)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Install with:" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY + echo "pip install late-sdk==${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/pyproject.toml b/pyproject.toml index c227777..58783b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "late-sdk" -version = "1.0.0" +version = "1.0.1" description = "Python SDK for Late API - Social Media Scheduling" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index c6a603f..c023bc1 100644 --- a/uv.lock +++ b/uv.lock @@ -680,7 +680,7 @@ wheels = [ [[package]] name = "late-sdk" -version = "1.0.0" +version = "1.0.1" source = { editable = "." } dependencies = [ { name = "httpx" }, From bf3071b06e1f1f5dfda3eed6072eb94e8d68ae26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Mart=C3=ADnez?= Date: Thu, 11 Dec 2025 19:18:47 +0100 Subject: [PATCH 5/8] ci: Add release preview comment on PRs to main Shows version info and release status before merging: - Version from pyproject.toml - Whether git tag exists - Whether version exists on PyPI - Clear indication if release will happen or be skipped --- .github/workflows/release-preview.yml | 113 ++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/workflows/release-preview.yml diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml new file mode 100644 index 0000000..19fbe9c --- /dev/null +++ b/.github/workflows/release-preview.yml @@ -0,0 +1,113 @@ +name: Release Preview + +on: + pull_request: + branches: [main] + +jobs: + preview: + runs-on: ubuntu-latest + permissions: + pull-requests: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.11 + + - name: Get version from pyproject.toml + id: version + run: | + VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Check if tag exists + id: check_tag + run: | + if git rev-parse "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Check PyPI for existing version + id: check_pypi + run: | + VERSION="${{ steps.version.outputs.version }}" + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/late-sdk/$VERSION/json") + if [ "$HTTP_STATUS" = "200" ]; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Create PR Comment + uses: actions/github-script@v7 + with: + script: | + const version = '${{ steps.version.outputs.version }}'; + const tagExists = '${{ steps.check_tag.outputs.exists }}' === 'true'; + const pypiExists = '${{ steps.check_pypi.outputs.exists }}' === 'true'; + + let body = '## 📦 Release Preview\n\n'; + body += '| Item | Value |\n'; + body += '|------|-------|\n'; + body += `| Version | \`${version}\` |\n`; + body += `| Git tag exists | ${tagExists ? '✅ Yes' : '❌ No'} |\n`; + body += `| PyPI version exists | ${pypiExists ? '✅ Yes' : '❌ No'} |\n\n`; + + if (tagExists || pypiExists) { + body += '### ⏭️ No Release\n\n'; + if (tagExists && pypiExists) { + body += `Version \`${version}\` already exists on both GitHub and PyPI.\n\n`; + } else if (tagExists) { + body += `Git tag \`v${version}\` already exists.\n\n`; + } else { + body += `Version \`${version}\` already exists on PyPI.\n\n`; + } + body += '> 💡 **To create a new release**, update `version` in `pyproject.toml`\n'; + } else { + body += '### 🚀 New Release\n\n'; + body += 'When this PR is merged, the following will happen:\n\n'; + body += `1. ✅ Create GitHub Release \`v${version}\`\n`; + body += `2. ✅ Publish to PyPI as \`late-sdk==${version}\`\n\n`; + body += '```bash\n'; + body += `pip install late-sdk==${version}\n`; + body += '```\n'; + } + + // Find existing comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('📦 Release Preview') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } From 4360447403e5a4f4c35098b10a817fe751f6369b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Mart=C3=ADnez?= Date: Thu, 11 Dec 2025 19:22:58 +0100 Subject: [PATCH 6/8] chore: trigger workflow re-run --- .github/workflows/release-preview.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml index 19fbe9c..f7ffd58 100644 --- a/.github/workflows/release-preview.yml +++ b/.github/workflows/release-preview.yml @@ -1,3 +1,4 @@ +# Preview release info on PRs to main name: Release Preview on: From 359e5bbd1d9805fd1377034858c6b325098b1c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Mart=C3=ADnez?= Date: Thu, 11 Dec 2025 19:25:09 +0100 Subject: [PATCH 7/8] fix: Add explicit permissions for checkout in private repo --- .github/workflows/release-preview.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release-preview.yml b/.github/workflows/release-preview.yml index f7ffd58..dfcb59f 100644 --- a/.github/workflows/release-preview.yml +++ b/.github/workflows/release-preview.yml @@ -9,12 +9,14 @@ jobs: preview: runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - name: Install uv uses: astral-sh/setup-uv@v4 From 842ca46afb250357359258969c014d1a9b50da23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Mart=C3=ADnez?= Date: Thu, 11 Dec 2025 19:28:23 +0100 Subject: [PATCH 8/8] fix: Add explicit token and permissions to all workflows for private repo --- .github/workflows/release.yml | 1 + .github/workflows/test.yml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 16f2c1f..0dc85f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - name: Install uv uses: astral-sh/setup-uv@v4 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e599e25..770e255 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,12 +9,16 @@ on: jobs: test: runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} - name: Install uv uses: astral-sh/setup-uv@v4 @@ -39,9 +43,13 @@ jobs: build: runs-on: ubuntu-latest needs: test + permissions: + contents: read steps: - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} - name: Install uv uses: astral-sh/setup-uv@v4