Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pip install late-sdk
## Quick Start

```python
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from late import Late, Platform

client = Late(api_key="your_api_key")
Expand All @@ -29,7 +29,7 @@ accounts = client.accounts.list()
post = client.posts.create(
content="Hello from Late!",
platforms=[{"platform": Platform.TWITTER, "accountId": "your_account_id"}],
scheduled_for=datetime.now() + timedelta(hours=1),
scheduled_for=datetime.now(timezone.utc) + timedelta(hours=1),
)
```

Expand Down
4 changes: 2 additions & 2 deletions examples/01_basic_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"""

import os
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone

from late import Late

Expand Down Expand Up @@ -43,7 +43,7 @@ def main() -> None:
# Create a new post (example - uncomment to use)
# if accounts.get("accounts"):
# account = accounts["accounts"][0]
# scheduled_time = datetime.now() + timedelta(hours=1)
# scheduled_time = datetime.now(timezone.utc) + timedelta(hours=1)
#
# post = client.posts.create(
# content="Hello from Late Python SDK!",
Expand Down
4 changes: 2 additions & 2 deletions examples/publish_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"""

import os
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone

from late import Late

Expand All @@ -29,7 +29,7 @@ def main() -> None:
print(f"\nUsing: {account['platform']} ({account.get('username', '')})")

# 3. Create post - scheduled for 1 hour from now
scheduled_time = datetime.now() + timedelta(hours=1)
scheduled_time = datetime.now(timezone.utc) + timedelta(hours=1)

post = client.posts.create(
content="Hello from Late Python SDK! 🚀",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "late-sdk"
version = "1.1.1"
version = "1.1.2"
description = "Python SDK for Late API - Social Media Scheduling"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
7 changes: 4 additions & 3 deletions src/late/client/rate_limiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import contextlib
from dataclasses import dataclass
from datetime import datetime
from datetime import datetime, timezone
from typing import TYPE_CHECKING

if TYPE_CHECKING:
Expand All @@ -31,7 +31,7 @@ def seconds_until_reset(self) -> float | None:
"""Get seconds until rate limit resets."""
if self.reset is None:
return None
delta = self.reset - datetime.now()
delta = self.reset - datetime.now(timezone.utc)
return max(0.0, delta.total_seconds())


Expand Down Expand Up @@ -90,7 +90,8 @@ def update_from_headers(self, headers: Mapping[str, str]) -> None:
if reset_str is not None:
try:
timestamp = int(reset_str)
self._info.reset = datetime.fromtimestamp(timestamp)
# Use UTC so the reset comparison is timezone-consistent.
self._info.reset = datetime.fromtimestamp(timestamp, tz=timezone.utc)
except (ValueError, OSError):
pass

Expand Down
128 changes: 124 additions & 4 deletions src/late/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,22 @@
from __future__ import annotations

import os
from datetime import datetime, timedelta
import re
from datetime import datetime, timedelta, timezone
from typing import Any

import httpx
from mcp.server.fastmcp import FastMCP

from late import Late, MediaType, PostStatus

from .tool_definitions import use_tool_def

# Cache for documentation content
_docs_cache: dict[str, tuple[str, datetime]] = {}
_DOCS_URL = "https://docs.getlate.dev/llms-full.txt"
_CACHE_TTL_HOURS = 24

# Initialize MCP server
mcp = FastMCP(
"Late",
Expand All @@ -44,6 +51,7 @@
- profiles_* : Manage profiles (groups of accounts)
- posts_* : Create, list, update, delete posts
- media_* : Upload images and videos
- docs_* : Search Late API documentation
""",
)

Expand Down Expand Up @@ -326,7 +334,9 @@ def posts_create(
params["publish_now"] = True
else:
minutes = schedule_minutes if schedule_minutes > 0 else 60
params["scheduled_for"] = datetime.now() + timedelta(minutes=minutes)
# Use UTC so the scheduled time matches what the API expects,
# regardless of the user's local timezone.
params["scheduled_for"] = datetime.now(timezone.utc) + timedelta(minutes=minutes)

response = client.posts.create(**params)
post = response.post
Expand All @@ -344,7 +354,7 @@ def posts_create(
elif publish_now:
return f"✅ Published to {platform} (@{username}){media_info}\nPost ID: {post_id}"
else:
scheduled = params["scheduled_for"].strftime("%Y-%m-%d %H:%M")
scheduled = params["scheduled_for"].strftime("%Y-%m-%d %H:%M UTC")
return f"✅ Scheduled for {platform} (@{username}){media_info}\nPost ID: {post_id}\nScheduled: {scheduled}"


Expand Down Expand Up @@ -414,7 +424,9 @@ def posts_cross_post(
elif publish_now:
params["publish_now"] = True
else:
params["scheduled_for"] = datetime.now() + timedelta(hours=1)
# Use UTC so the scheduled time matches what the API expects,
# regardless of the user's local timezone.
params["scheduled_for"] = datetime.now(timezone.utc) + timedelta(hours=1)

response = client.posts.create(**params)
post = response.post
Expand Down Expand Up @@ -635,6 +647,114 @@ def media_check_upload_status(token: str) -> str:
return f"❌ Failed to check upload status: {e}"


# ============================================================================
# DOCS
# ============================================================================


def _get_docs_content() -> str:
"""Fetch and cache documentation content."""
cache_key = "docs"

# Check cache
if cache_key in _docs_cache:
content, cached_at = _docs_cache[cache_key]
if datetime.now(timezone.utc) - cached_at < timedelta(hours=_CACHE_TTL_HOURS):
return content

# Fetch fresh content
try:
response = httpx.get(_DOCS_URL, timeout=30.0)
response.raise_for_status()
content = response.text
_docs_cache[cache_key] = (content, datetime.now(timezone.utc))
return content
except Exception as e:
# Return cached content if available, even if expired
if cache_key in _docs_cache:
return _docs_cache[cache_key][0]
raise RuntimeError(f"Failed to fetch documentation: {e}") from e


def _search_docs(content: str, query: str, max_results: int = 5) -> list[dict[str, str]]:
"""Search documentation content for relevant sections."""
results: list[dict[str, str]] = []
query_lower = query.lower()
query_terms = query_lower.split()

# Split content into sections (by markdown headers)
sections = re.split(r'\n(?=#{1,3} )', content)

scored_sections: list[tuple[int, str, str]] = []

for section in sections:
if not section.strip():
continue

section_lower = section.lower()

# Calculate relevance score
score = 0

# Exact phrase match (highest priority)
if query_lower in section_lower:
score += 100

# Individual term matches
for term in query_terms:
if term in section_lower:
score += 10
# Bonus for term in header
first_line = section.split('\n')[0].lower()
if term in first_line:
score += 20

if score > 0:
# Extract title from first line
lines = section.strip().split('\n')
title = lines[0].lstrip('#').strip() if lines else "Untitled"
scored_sections.append((score, title, section.strip()))

# Sort by score and take top results
scored_sections.sort(key=lambda x: x[0], reverse=True)

for score, title, section_text in scored_sections[:max_results]:
# Truncate long sections
if len(section_text) > 1500:
section_text = section_text[:1500] + "\n...(truncated)"

results.append({
"title": title,
"content": section_text,
"relevance": str(score),
})

return results


@mcp.tool()
@use_tool_def("docs_search")
def docs_search(query: str) -> str:
try:
content = _get_docs_content()
results = _search_docs(content, query)

if not results:
return f"No documentation found for '{query}'. Try different search terms."

lines = [f"Found {len(results)} relevant section(s) for '{query}':\n"]

for i, result in enumerate(results, 1):
lines.append(f"--- Result {i}: {result['title']} ---")
lines.append(result["content"])
lines.append("")

return "\n".join(lines)

except Exception as e:
return f"❌ Failed to search documentation: {e}"


# ============================================================================
# MAIN
# ============================================================================
Expand Down
25 changes: 25 additions & 0 deletions src/late/mcp/tool_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,28 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
params=[],
)

# =============================================================================
# DOCS TOOLS
# =============================================================================

DOCS_SEARCH = ToolDef(
name="docs_search",
summary="Search the Late API documentation.",
description="""Search across the Late API documentation to find relevant information, code examples, API references, and guides.

Use this tool when you need to answer questions about Late, find specific documentation, understand how features work, or locate implementation details.

The search returns contextual content with section titles and relevant snippets.""",
params=[
ParamDef(
name="query",
type="str",
description="Search query (e.g., 'webhooks', 'create post', 'authentication')",
required=True,
),
],
)

# =============================================================================
# MEDIA TOOLS
# =============================================================================
Expand Down Expand Up @@ -608,6 +630,8 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
# Media
"media_generate_upload_link": MEDIA_GENERATE_UPLOAD_LINK,
"media_check_upload_status": MEDIA_CHECK_UPLOAD_STATUS,
# Docs
"docs_search": DOCS_SEARCH,
}


Expand Down Expand Up @@ -648,6 +672,7 @@ def generate_mdx_tools_reference() -> str:
"posts_retry_all_failed",
],
"Media": ["media_generate_upload_link", "media_check_upload_status"],
"Docs": ["docs_search"],
}

for category, tool_names in categories.items():
Expand Down
8 changes: 5 additions & 3 deletions src/late/pipelines/cross_poster.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Any

from late.enums import Platform
Expand Down Expand Up @@ -111,7 +111,8 @@ async def post(
List of results per platform
"""
results: list[CrossPostResult] = []
current_time = base_time or datetime.now()
# Use UTC so scheduled times match what the API expects.
current_time = base_time or datetime.now(timezone.utc)

for i, config in enumerate(platforms):
try:
Expand Down Expand Up @@ -165,7 +166,8 @@ def post_sync(
) -> list[CrossPostResult]:
"""Synchronous version of cross-posting."""
results: list[CrossPostResult] = []
current_time = kwargs.get("base_time") or datetime.now()
# Use UTC so scheduled times match what the API expects.
current_time = kwargs.get("base_time") or datetime.now(timezone.utc)
adapt = kwargs.get("adapt_content", True)

for i, config in enumerate(platforms):
Expand Down
3 changes: 2 additions & 1 deletion src/late/resources/posts.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class PostsResource(BaseResource[PostsListResponse]):
Resource for managing posts.

Example:
>>> from datetime import datetime, timedelta, timezone
>>> from late import Platform, PostStatus
>>> client = Late(api_key="...")
>>> # List posts
Expand All @@ -37,7 +38,7 @@ class PostsResource(BaseResource[PostsListResponse]):
>>> post = client.posts.create(
... content="Hello!",
... platforms=[{"platform": Platform.TWITTER, "accountId": "..."}],
... scheduled_for=datetime.now() + timedelta(hours=1),
... scheduled_for=datetime.now(timezone.utc) + timedelta(hours=1),
... )
>>> # Update a post
>>> client.posts.update(post_id, content="Updated!")
Expand Down
6 changes: 3 additions & 3 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from __future__ import annotations

import json
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import Any

import httpx
Expand Down Expand Up @@ -163,7 +163,7 @@ def test_create_post_scheduled(self, client: Late, mock_post: dict) -> None:
)
)

scheduled_time = datetime.now() + timedelta(hours=1)
scheduled_time = datetime.now(timezone.utc) + timedelta(hours=1)
result = client.posts.create(
content="Test post",
platforms=[{"platform": "twitter", "accountId": "acc_123"}],
Expand Down Expand Up @@ -277,7 +277,7 @@ def test_update_post(self, client: Late, mock_post: dict) -> None:
client.posts.update(
"post_123",
content="Updated content",
scheduled_for=datetime.now() + timedelta(days=1),
scheduled_for=datetime.now(timezone.utc) + timedelta(days=1),
)

assert route.called
Expand Down