diff --git a/README.md b/README.md index 64a4a0b..32cd7b4 100644 --- a/README.md +++ b/README.md @@ -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") @@ -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), ) ``` diff --git a/examples/01_basic_usage.py b/examples/01_basic_usage.py index 071445f..e2b0319 100644 --- a/examples/01_basic_usage.py +++ b/examples/01_basic_usage.py @@ -8,7 +8,7 @@ """ import os -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from late import Late @@ -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!", diff --git a/examples/publish_post.py b/examples/publish_post.py index 017d796..30e4fa6 100644 --- a/examples/publish_post.py +++ b/examples/publish_post.py @@ -3,7 +3,7 @@ """ import os -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from late import Late @@ -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! 🚀", diff --git a/pyproject.toml b/pyproject.toml index 8e7b443..c466735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/late/client/rate_limiter.py b/src/late/client/rate_limiter.py index 2d06341..75a7fdd 100644 --- a/src/late/client/rate_limiter.py +++ b/src/late/client/rate_limiter.py @@ -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: @@ -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()) @@ -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 diff --git a/src/late/mcp/server.py b/src/late/mcp/server.py index 47956c5..1176135 100644 --- a/src/late/mcp/server.py +++ b/src/late/mcp/server.py @@ -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", @@ -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 """, ) @@ -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 @@ -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}" @@ -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 @@ -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 # ============================================================================ diff --git a/src/late/mcp/tool_definitions.py b/src/late/mcp/tool_definitions.py index a50f61e..4577afe 100644 --- a/src/late/mcp/tool_definitions.py +++ b/src/late/mcp/tool_definitions.py @@ -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 # ============================================================================= @@ -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, } @@ -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(): diff --git a/src/late/pipelines/cross_poster.py b/src/late/pipelines/cross_poster.py index 20861f9..fff4ea4 100644 --- a/src/late/pipelines/cross_poster.py +++ b/src/late/pipelines/cross_poster.py @@ -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 @@ -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: @@ -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): diff --git a/src/late/resources/posts.py b/src/late/resources/posts.py index 31a412c..b514a10 100644 --- a/src/late/resources/posts.py +++ b/src/late/resources/posts.py @@ -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 @@ -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!") diff --git a/tests/test_integration.py b/tests/test_integration.py index 2e84a16..b9421cb 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -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 @@ -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"}], @@ -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