diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 39f00a2..8f18bfa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,10 +39,10 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} - name: Install uv package manager - run: task install_uv + uses: astral-sh/setup-uv@v5 - name: Install dependencies - run: task sync_deps + run: task deps:sync - name: Display environment information run: | diff --git a/.gitignore b/.gitignore index 35f37a4..a42479a 100644 --- a/.gitignore +++ b/.gitignore @@ -176,3 +176,4 @@ src/requirements.txt .structurizr/ docs/c4/index/ docs/c4/logs/ +.tmp/ diff --git a/pyproject.toml b/pyproject.toml index 9426983..b4e1b6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [ ] requires-python = ">=3.11" dependencies = [ - "functions-framework==3.4.0", + "functions-framework==3.10.0", "Flask~=2.3.2", "python-telegram-bot==22.3", "PyGithub==1.59.1", diff --git a/src/actions/base_post_to_org_file.py b/src/actions/base_post_to_org_file.py index c3cf43c..c5db9a2 100644 --- a/src/actions/base_post_to_org_file.py +++ b/src/actions/base_post_to_org_file.py @@ -34,30 +34,81 @@ def __init__(self, github_token=None, repo_name=None, file_path=None, org_api=No # Use provided org_api or create a new one self.org_api = org_api if org_api is not None else OrgApi(self.repo) - def run(self, message: Message, file_path=None): + def run(self, message: Message, file_path=None, file_paths=None): """ - Adds a message to a file on github. File should exists on the github. - :param message: incoming telegram message - :return: status of operation + Adds a message to a file on github. File should exist on github. + + Args: + message: incoming telegram message + file_path: (deprecated) single file path for backward compatibility + file_paths: list of file paths for media groups + + Returns: + status of operation """ - filename = None - if file_path: - # we got a file. Now it has to be uploaded to the repo as bytes - with open(file_path, "rb") as file: - file_bytes = file.read() - filename = "pics/telegram/" + file_path.split("/")[-1] - self.org_api.create_file( - file_path=filename, - content=file_bytes, - commit_message="Image from telegram", - ) + # Handle backward compatibility + if file_path and not file_paths: + file_paths = [file_path] + + if not file_paths: + file_paths = [] + # Build message metadata message_id = message.message_id chat_id = message.chat.id commit_message = f"Message {message_id} from chat {chat_id}" new_text = self._get_new_org_item(message) - self.org_api.append_text_to_file( - self.file_path, new_text, commit_message, image_filename=filename + + # If no files, use old simple path for backward compatibility + if not file_paths: + logger.info( + "Creating single-file commit (text only)", + extra={"message_id": message_id, "chat_id": chat_id} + ) + self.org_api.append_text_to_file( + self.file_path, + new_text, + commit_message, + image_filename=None + ) + return True + + # Have files - use atomic commit for all files + org entry + file_changes = [] + image_filenames = [] + + # Add all photos to file_changes + for fp in file_paths: + with open(fp, "rb") as file: + file_bytes = file.read() + filename = "pics/telegram/" + fp.split("/")[-1] + file_changes.append((filename, file_bytes)) + image_filenames.append(filename) + + # Get current org file content + contents = self.repo.get_contents(self.file_path, ref="main") + decoded_content = contents.decoded_content.decode("utf-8") + + # Add image links + image_text = "\n".join([ + f"#+attr_html: :width 600px\n[[file:{fn}]]" + for fn in image_filenames + ]) + new_content = "\n".join([decoded_content, new_text, image_text]) + + # Add org file to changes + file_changes.append((self.file_path, new_content)) + + # Create atomic commit + logger.info( + f"Creating atomic commit for {len(file_changes)} files", + extra={ + "message_id": message_id, + "chat_id": chat_id, + "photo_count": len(image_filenames) + } ) + self.org_api.create_atomic_commit(file_changes, commit_message) + return True diff --git a/src/actions/post_reply.py b/src/actions/post_reply.py index fe4c169..5df52be 100644 --- a/src/actions/post_reply.py +++ b/src/actions/post_reply.py @@ -32,22 +32,28 @@ def __init__( super().__init__(github_token, repo_name, file_path, org_api=org_api) self.todo_file_path = todo_file_path - def run(self, message: Message, file_path=None): + def run(self, message: Message, file_path=None, file_paths=None): """ Handles a reply message by finding the original entry and adding this as a subheader. Falls back to regular journal entry if original message is not found. :param message: The reply message from Telegram - :param file_path: Optional file path for attachments + :param file_path: Optional single attachment path (deprecated) + :param file_paths: Optional list of attachment paths :return: Status of operation """ + if file_path and not file_paths: + file_paths = [file_path] + + attachment = file_paths[0] if file_paths else None + # Get the original message that this is replying to original_message = message.reply_to_message if not original_message: # Not a reply, fall back to regular journal entry logger.warning("PostReplyToEntry called without reply_to_message") return PostToGitJournal(self.token, self.repo_name, self.file_path).run( - message, file_path + message, attachment ) # Build the link to the original message @@ -85,7 +91,7 @@ def run(self, message: Message, file_path=None): "Original entry not found, falling back to regular journal entry" ) return PostToGitJournal(self.token, self.repo_name, self.file_path).run( - message, file_path + message, attachment ) line_number, org_level = entry_location diff --git a/src/bot.py b/src/bot.py index a90a917..d112026 100644 --- a/src/bot.py +++ b/src/bot.py @@ -11,7 +11,7 @@ import logging import asyncio -from typing import Optional +from typing import List, Optional from telegram import Bot, Message from telegram.request import HTTPXRequest from telegram.error import TimedOut, NetworkError @@ -60,14 +60,6 @@ def __init__( self.github_settings = github_settings or GitHubSettings() self.org_settings = org_settings or OrgSettings() - # Configure HTTP client for bot - self.request = HTTPXRequest( - pool_timeout=30, - connection_pool_size=10, - read_timeout=30, - write_timeout=30, - ) - # Initialize commands and actions self.commands = create_commands(self._get_bot) self.actions = create_actions(self.github_settings, self.org_settings) @@ -79,8 +71,19 @@ def __init__( ) def _get_bot(self) -> Bot: - """Create a fresh bot instance for each request.""" - return Bot(token=self.bot_settings.token, request=self.request) + """ + Create a fresh bot instance with a new HTTPXRequest for each request. + + This ensures the httpx client is tied to the current event loop, + preventing "Event loop is closed" errors in serverless environments. + """ + request = HTTPXRequest( + pool_timeout=30, + connection_pool_size=10, + read_timeout=30, + write_timeout=30, + ) + return Bot(token=self.bot_settings.token, request=request) async def handle_update(self, message: Message) -> None: """ @@ -101,6 +104,53 @@ async def handle_update(self, message: Message) -> None: if response: await self._send_response(message, response) + async def handle_media_group(self, messages: List[Message]) -> None: + """ + Handle a media group (album with multiple photos). + + Args: + messages: List of messages from same media group + """ + if not messages: + logger.warning("Empty media group received") + return + + # Use first message for auth and response + primary_message = messages[0] + + # Check authorization + if not await auth_check(primary_message, self.bot_settings, self._get_bot): + await self._send_unauthorized_response(primary_message) + return + + # Save all photos + file_paths = await self._save_photos(messages) + + logger.info( + f"Media group: {len(messages)} messages, {len(file_paths)} photos", + extra={"media_group_id": primary_message.media_group_id} + ) + + # Get text from first message with caption + message_text = "" + text_message = primary_message + for msg in messages: + text = get_text_from_message(msg) + if text: + message_text = text + text_message = msg + break + + # Process as action (media groups can't be commands) + response = await self._handle_action( + text_message, + message_text, + file_paths=file_paths + ) + + if response: + await self._send_response(text_message, response) + async def _process_message(self, message: Message) -> Optional[str]: """ Process a message and return response text. @@ -122,7 +172,9 @@ async def _process_message(self, message: Message) -> Optional[str]: if message_text.startswith("/"): return await self._handle_command(message, message_text) else: - return await self._handle_action(message, message_text, temp_file_path) + # Pass as list for consistency with media group handling + file_paths = [temp_file_path] if temp_file_path else None + return await self._handle_action(message, message_text, file_paths) async def _handle_command(self, message: Message, message_text: str) -> str: """ @@ -149,7 +201,7 @@ async def _handle_action( self, message: Message, message_text: str, - file_path: Optional[str] = None, + file_paths: Optional[List[str]] = None, ) -> Optional[str]: """ Route message to appropriate action handler. @@ -157,7 +209,7 @@ async def _handle_action( Args: message: Telegram message message_text: Text content of the message - file_path: Optional path to attached file + file_paths: Optional list of paths to attached files Returns: Response text or None if chat is ignored @@ -174,7 +226,8 @@ async def _handle_action( try: action_config = self.actions.get(action_key) if action_config: - action_config.function(message, file_path=file_path) + # Call with file_paths (new signature) + action_config.function(message, file_paths=file_paths) return action_config.response_message else: logger.error(f"Action not found: {action_key}") @@ -230,6 +283,37 @@ async def _save_photo(self, message: Message) -> str: logger.info(f"Photo saved to {temp_file_path}") return temp_file_path + async def _save_photos(self, messages: List[Message]) -> List[str]: + """ + Save multiple photos from media group messages. + + Args: + messages: List of messages, each may contain photo + + Returns: + List of temp file paths + """ + file_paths = [] + + for message in messages: + if message.photo: + # Get highest resolution photo + photo_file_id = message.photo[-1].file_id + temp_file_path = f"/tmp/{photo_file_id}.jpg" + + bot = self._get_bot() + file_obj = await bot.get_file(photo_file_id) + file_bytes = await file_obj.download_as_bytearray() + + with open(temp_file_path, "wb") as file: + file.write(file_bytes) + + file_paths.append(temp_file_path) + logger.debug(f"Saved photo to {temp_file_path}") + + logger.info(f"Saved {len(file_paths)} photos from media group") + return file_paths + async def _send_response(self, message: Message, text: str) -> None: """ Send a response message with retry logic. @@ -260,9 +344,6 @@ async def _send_response(self, message: Message, text: str) -> None: logger.error("Failed after 3 timeout attempts") raise except NetworkError as e: - if "Event loop is closed" in str(e): - logger.info("Event loop closed, request likely completed") - return if attempt < 2: logger.warning(f"Network retry {attempt + 1}/3: {type(e).__name__}") await asyncio.sleep(attempt + 1) diff --git a/src/commands/info.py b/src/commands/info.py index bda43f6..0e2f9b8 100644 --- a/src/commands/info.py +++ b/src/commands/info.py @@ -1,12 +1,19 @@ import json +import logging from telegram import Message from ..base_command import BaseCommand +logger = logging.getLogger(__name__) + class InfoCommand(BaseCommand): async def execute(self, message: Message) -> str: - response_data = await self.bot.get_me() - response = json.dumps(response_data, indent=1).replace("\\", "\\\\") - return f"""``` + try: + response_data = await self.bot.get_me() + response = json.dumps(response_data.to_dict(), indent=1).replace("\\", "\\\\") + return f"""``` {response} ```""" + except Exception as e: + logger.error(f"Error getting info: {e}") + return f"Error retrieving bot information: {str(e)}" diff --git a/src/commands/webhook.py b/src/commands/webhook.py index 22080c0..70e7cb5 100644 --- a/src/commands/webhook.py +++ b/src/commands/webhook.py @@ -28,9 +28,9 @@ async def execute(self, message: Message) -> str: } response = json.dumps(response_data, indent=1).replace("\\", "\\\\") return f"""Webhook data - + ``` - + {response} ```""" diff --git a/src/main.py b/src/main.py index 784d321..beb647b 100644 --- a/src/main.py +++ b/src/main.py @@ -7,9 +7,15 @@ import logging import asyncio -import functions_framework -from flask import Request, abort -from telegram import Update +import random +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +import functions_framework.aio +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from telegram import Update, Message import sentry_sdk from sentry_sdk.integrations.gcp import GcpIntegration @@ -36,8 +42,154 @@ org_bot = OrgBot() -@functions_framework.http -def http_entrypoint(request: Request): +# Media group handling infrastructure +@dataclass +class MediaGroupBuffer: + """Buffer for collecting media group messages.""" + media_group_id: str + messages: List[Message] = field(default_factory=list) + message_ids: set[int] = field(default_factory=set) + first_seen: float = 0.0 + last_seen: float = 0.0 + finalize_task: Optional[asyncio.Task[None]] = None + done_event: asyncio.Event = field(default_factory=asyncio.Event) + + +# Module-level state (persists across function invocations within same instance) +_media_group_buffers: Dict[str, MediaGroupBuffer] = {} +_media_group_lock = asyncio.Lock() +_processing_lock = asyncio.Lock() + +# Configuration +MEDIA_GROUP_TIMEOUT = 2.0 # seconds to wait after last photo +MAX_MEDIA_GROUP_WAIT = 10.0 # max total wait time +MAX_PHOTOS_PER_GROUP = 10 # safety limit + + +async def _collect_media_group(message: Message) -> MediaGroupBuffer: + """Collect a media group message and ensure finalization is scheduled.""" + media_group_id = message.media_group_id + if not media_group_id: + raise ValueError("message.media_group_id is required") + + async with _media_group_lock: + current_time = time.time() + + if media_group_id not in _media_group_buffers: + _media_group_buffers[media_group_id] = MediaGroupBuffer( + media_group_id=media_group_id, + first_seen=current_time, + last_seen=current_time, + ) + + buffer = _media_group_buffers[media_group_id] + + if message.message_id not in buffer.message_ids: + buffer.messages.append(message) + buffer.message_ids.add(message.message_id) + + buffer.last_seen = current_time + + time_since_first = current_time - buffer.first_seen + should_force_process = ( + time_since_first >= MAX_MEDIA_GROUP_WAIT + or len(buffer.messages) >= MAX_PHOTOS_PER_GROUP + ) + + if should_force_process: + logger.info( + "Media group forced complete", + extra={ + "media_group_id": media_group_id, + "message_count": len(buffer.messages), + "wait_time": time_since_first, + }, + ) + # Process immediately (do not rely on background work after response). + await _process_media_group_by_id(media_group_id) + return buffer + + # Debounce finalization: cancel prior task and reschedule. + if buffer.finalize_task is not None and not buffer.finalize_task.done(): + buffer.finalize_task.cancel() + + buffer.finalize_task = asyncio.create_task( + _finalize_media_group_after_timeout(media_group_id) + ) + + return buffer + + +async def _finalize_media_group_after_timeout(media_group_id: str) -> None: + try: + await asyncio.sleep(MEDIA_GROUP_TIMEOUT) + await _process_media_group_by_id(media_group_id) + except asyncio.CancelledError: + return + except Exception as e: + sentry_sdk.capture_exception(e) + logger.exception( + "Error finalizing media group", + extra={"media_group_id": media_group_id}, + ) + + +async def _process_media_group_by_id(media_group_id: str) -> None: + """Finalize and process a media group, then release all awaiting requests.""" + buffer: Optional[MediaGroupBuffer] = None + try: + async with _media_group_lock: + buffer = _media_group_buffers.pop(media_group_id, None) + + if not buffer: + return + + messages = sorted(buffer.messages, key=lambda m: m.message_id or 0) + + logger.info( + "Processing media group", + extra={ + "media_group_id": media_group_id, + "message_count": len(messages), + "wait_time": time.time() - buffer.first_seen, + }, + ) + + async with _processing_lock: + await org_bot.handle_media_group(messages) + except Exception as e: + sentry_sdk.capture_exception(e) + logger.exception( + "Error processing media group", + extra={"media_group_id": media_group_id}, + ) + finally: + if buffer is not None: + buffer.done_event.set() + + +async def _cleanup_stale_buffers() -> None: + """Remove buffers older than 60 seconds.""" + async with _media_group_lock: + current_time = time.time() + stale_ids = [ + mid + for mid, buf in _media_group_buffers.items() + if current_time - buf.first_seen > 60 + ] + for mid in stale_ids: + logger.warning( + "Cleaning up stale media group", + extra={ + "media_group_id": mid, + "age_seconds": current_time - _media_group_buffers[mid].first_seen, + }, + ) + _media_group_buffers.pop(mid, None) + + +@functions_framework.aio.http +async def http_entrypoint(request: Request) -> Response: """ HTTP webhook handler for Telegram updates. @@ -53,11 +205,11 @@ def http_entrypoint(request: Request): try: # Health check endpoint if request.method == "GET": - return {"statusCode": 200} + return JSONResponse({"statusCode": 200}) # Process webhook if request.method == "POST": - incoming_data = request.get_json() + incoming_data = await request.json() logger.debug(f"Incoming data: {incoming_data}") # Parse Telegram update @@ -66,14 +218,36 @@ def http_entrypoint(request: Request): # Process message if present if message: - asyncio.run(org_bot.handle_update(message)) - - return {"statusCode": 200} + # Cleanup stale buffers periodically (10% of requests) + if random.random() < 0.1: + await _cleanup_stale_buffers() + + # Check for media group + if message.media_group_id: + logger.debug( + f"Media group message received: {message.media_group_id}", + extra={"media_group_id": message.media_group_id, "message_id": message.message_id} + ) + + buffer = await _collect_media_group(message) + + # IMPORTANT (Cloud Run functions constraint): do not return until + # all work for this update is complete. + await asyncio.wait_for( + buffer.done_event.wait(), + timeout=MAX_MEDIA_GROUP_WAIT + MEDIA_GROUP_TIMEOUT + 5.0, + ) + else: + # Single message (no media group) + async with _processing_lock: + await org_bot.handle_update(message) + + return JSONResponse({"statusCode": 200}) except Exception as e: sentry_sdk.capture_exception(e) logger.exception("Error processing webhook") - return {"statusCode": 200} # Always return 200 to Telegram + return JSONResponse({"statusCode": 200}) # Always return 200 to Telegram # Invalid request - abort(422) + return JSONResponse({"statusCode": 422}, status_code=422) diff --git a/src/org_api.py b/src/org_api.py index d1edd35..2b64a2f 100644 --- a/src/org_api.py +++ b/src/org_api.py @@ -7,9 +7,12 @@ - Inserting replies at the correct position in the org hierarchy """ +import base64 import logging import re -from typing import Optional, Tuple +from typing import List, Optional, Tuple, Union + +from github import InputGitTreeElement logger = logging.getLogger(__name__) @@ -25,6 +28,88 @@ def __init__(self, repo): """ self.repo = repo + def create_atomic_commit( + self, + file_changes: List[Tuple[str, Union[str, bytes]]], + commit_message: str, + ) -> str: + """ + Create a single commit with multiple file changes. + + Uses the GitHub Git Tree API to create an atomic commit containing + multiple file changes (both text and binary). This is useful for + committing multiple photos + org file update in one transaction. + + Args: + file_changes: List of (file_path, content) tuples. + Content can be str (for text files) or bytes (for binary files). + commit_message: Git commit message + + Returns: + Commit SHA string + + Example: + file_changes = [ + ("pics/telegram/photo1.jpg", photo1_bytes), + ("pics/telegram/photo2.jpg", photo2_bytes), + ("journal.org", updated_org_content_str) + ] + sha = org_api.create_atomic_commit(file_changes, "Message 123 from chat 456") + """ + repo = self.repo + branch = repo.get_branch("main") + + logger.info( + f"Creating atomic commit with {len(file_changes)} file(s)", + extra={ + "action": "atomic_commit", + "file_count": len(file_changes), + "commit_message": commit_message, + }, + ) + + # Create blobs and tree elements for each file + tree_elements = [] + for file_path, content in file_changes: + # Convert content to proper format for blob creation + if isinstance(content, bytes): + # For binary content, encode as base64 + blob_content = base64.b64encode(content).decode("ascii") + blob = repo.create_git_blob(blob_content, "base64") + else: + # For text content, use as-is + blob = repo.create_git_blob(content, "utf-8") + + # Create tree element + tree_element = InputGitTreeElement( + path=file_path, + mode="100644", # Regular file + type="blob", + sha=blob.sha, + ) + tree_elements.append(tree_element) + + # Create tree with base + base_tree = repo.get_git_tree(sha=branch.commit.sha) + new_tree = repo.create_git_tree(tree=tree_elements, base_tree=base_tree) + + # Create commit + parent = repo.get_git_commit(branch.commit.sha) + commit = repo.create_git_commit( + message=commit_message, tree=new_tree, parents=[parent] + ) + + # Update branch ref + ref = repo.get_git_ref("heads/main") + ref.edit(sha=commit.sha) + + logger.info( + f"Atomic commit created: {commit.sha}", + extra={"commit_sha": commit.sha, "files": [fc[0] for fc in file_changes]}, + ) + + return commit.sha + def find_original_entry( self, original_message_link: str, file_path: str ) -> Optional[Tuple[int, int]]: diff --git a/terraform/main.tf b/terraform/main.tf index e5bde34..162f159 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -5,6 +5,11 @@ terraform { version = ">= 4.34.0" } } + + backend "gcs" { + bucket = "org-bot-tf-state" + prefix = "terraform/state" + } } provider "google" { @@ -64,6 +69,8 @@ resource "google_cloudfunctions2_function" "bot" { service_config { max_instance_count = 1 + max_instance_request_concurrency = 80 + available_cpu = "1" available_memory = "256M" timeout_seconds = 60 ingress_settings = "ALLOW_ALL" diff --git a/tests/test_atomic_commit.py b/tests/test_atomic_commit.py new file mode 100644 index 0000000..48a2205 --- /dev/null +++ b/tests/test_atomic_commit.py @@ -0,0 +1,365 @@ +"""Tests for atomic commit functionality.""" +import pytest +from unittest.mock import Mock, MagicMock +from src.org_api import OrgApi + + +def test_create_atomic_commit_multiple_files(): + """Test creating a single commit with multiple files.""" + # Mock GitHub repo + mock_repo = MagicMock() + + # Mock branch + mock_branch = MagicMock() + mock_branch.commit.sha = "base_commit_sha" + mock_repo.get_branch.return_value = mock_branch + + # Mock blob creation + mock_blob = MagicMock() + mock_blob.sha = "blob_sha" + mock_repo.create_git_blob.return_value = mock_blob + + # Mock tree creation + mock_base_tree = MagicMock() + mock_base_tree.sha = "base_tree_sha" + mock_repo.get_git_tree.return_value = mock_base_tree + + mock_new_tree = MagicMock() + mock_new_tree.sha = "new_tree_sha" + mock_repo.create_git_tree.return_value = mock_new_tree + + # Mock commit creation + mock_parent_commit = MagicMock() + mock_parent_commit.sha = "parent_sha" + mock_repo.get_git_commit.return_value = mock_parent_commit + + mock_commit = MagicMock() + mock_commit.sha = "new_commit_sha" + mock_repo.create_git_commit.return_value = mock_commit + + # Mock ref update + mock_ref = MagicMock() + mock_repo.get_git_ref.return_value = mock_ref + + # Create OrgApi and test + org_api = OrgApi(mock_repo) + + file_changes = [ + ("pics/telegram/photo1.jpg", b"fake_image_1_data"), + ("pics/telegram/photo2.jpg", b"fake_image_2_data"), + ("journal.org", "* New entry\n** Subentry\n") + ] + + commit_sha = org_api.create_atomic_commit( + file_changes, + "Message 123 from chat 456" + ) + + # Assertions + assert commit_sha == "new_commit_sha" + assert mock_repo.create_git_blob.call_count == 3 + assert mock_repo.create_git_tree.called + assert mock_repo.create_git_commit.called + assert mock_ref.edit.called + + # Verify commit message + mock_repo.create_git_commit.assert_called_once() + call_args = mock_repo.create_git_commit.call_args + assert call_args[1]["message"] == "Message 123 from chat 456" + + +def test_create_atomic_commit_text_only(): + """Test atomic commit with text files only.""" + # Mock GitHub repo + mock_repo = MagicMock() + + # Setup mocks + mock_branch = MagicMock() + mock_branch.commit.sha = "base_sha" + mock_repo.get_branch.return_value = mock_branch + + mock_blob = MagicMock() + mock_blob.sha = "text_blob_sha" + mock_repo.create_git_blob.return_value = mock_blob + + mock_base_tree = MagicMock() + mock_repo.get_git_tree.return_value = mock_base_tree + + mock_new_tree = MagicMock() + mock_new_tree.sha = "tree_sha" + mock_repo.create_git_tree.return_value = mock_new_tree + + mock_parent = MagicMock() + mock_repo.get_git_commit.return_value = mock_parent + + mock_commit = MagicMock() + mock_commit.sha = "text_commit_sha" + mock_repo.create_git_commit.return_value = mock_commit + + mock_ref = MagicMock() + mock_repo.get_git_ref.return_value = mock_ref + + # Create OrgApi + org_api = OrgApi(mock_repo) + + file_changes = [ + ("journal.org", "* Entry 1\n"), + ("todo.org", "** TODO Task\n") + ] + + commit_sha = org_api.create_atomic_commit( + file_changes, + "Update org files" + ) + + assert commit_sha == "text_commit_sha" + assert mock_repo.create_git_blob.call_count == 2 + + +def test_create_atomic_commit_binary_only(): + """Test atomic commit with binary files only.""" + # Mock GitHub repo + mock_repo = MagicMock() + + # Setup mocks + mock_branch = MagicMock() + mock_branch.commit.sha = "base_sha" + mock_repo.get_branch.return_value = mock_branch + + mock_blob = MagicMock() + mock_blob.sha = "binary_blob_sha" + mock_repo.create_git_blob.return_value = mock_blob + + mock_base_tree = MagicMock() + mock_repo.get_git_tree.return_value = mock_base_tree + + mock_new_tree = MagicMock() + mock_new_tree.sha = "tree_sha" + mock_repo.create_git_tree.return_value = mock_new_tree + + mock_parent = MagicMock() + mock_repo.get_git_commit.return_value = mock_parent + + mock_commit = MagicMock() + mock_commit.sha = "binary_commit_sha" + mock_repo.create_git_commit.return_value = mock_commit + + mock_ref = MagicMock() + mock_repo.get_git_ref.return_value = mock_ref + + # Create OrgApi + org_api = OrgApi(mock_repo) + + file_changes = [ + ("pics/photo1.jpg", b"\x89PNG\r\n\x1a\n...fake_png_data"), + ("pics/photo2.jpg", b"\xff\xd8\xff...fake_jpeg_data") + ] + + commit_sha = org_api.create_atomic_commit( + file_changes, + "Add photos" + ) + + assert commit_sha == "binary_commit_sha" + assert mock_repo.create_git_blob.call_count == 2 + + # Verify binary content was base64 encoded + for call in mock_repo.create_git_blob.call_args_list: + encoding = call[0][1] + assert encoding == "base64" + + +def test_create_atomic_commit_mixed_content(): + """Test atomic commit with both text and binary files.""" + # Mock GitHub repo + mock_repo = MagicMock() + + # Setup mocks + mock_branch = MagicMock() + mock_branch.commit.sha = "base_sha" + mock_repo.get_branch.return_value = mock_branch + + mock_blob = MagicMock() + mock_blob.sha = "blob_sha" + mock_repo.create_git_blob.return_value = mock_blob + + mock_base_tree = MagicMock() + mock_repo.get_git_tree.return_value = mock_base_tree + + mock_new_tree = MagicMock() + mock_new_tree.sha = "tree_sha" + mock_repo.create_git_tree.return_value = mock_new_tree + + mock_parent = MagicMock() + mock_repo.get_git_commit.return_value = mock_parent + + mock_commit = MagicMock() + mock_commit.sha = "mixed_commit_sha" + mock_repo.create_git_commit.return_value = mock_commit + + mock_ref = MagicMock() + mock_repo.get_git_ref.return_value = mock_ref + + # Create OrgApi + org_api = OrgApi(mock_repo) + + file_changes = [ + ("pics/photo.jpg", b"binary_data"), + ("journal.org", "text content") + ] + + commit_sha = org_api.create_atomic_commit( + file_changes, + "Mixed commit" + ) + + assert commit_sha == "mixed_commit_sha" + assert mock_repo.create_git_blob.call_count == 2 + + # Verify first call was base64 (binary), second was utf-8 (text) + calls = mock_repo.create_git_blob.call_args_list + assert calls[0][0][1] == "base64" + assert calls[1][0][1] == "utf-8" + + +def test_create_atomic_commit_single_file(): + """Test atomic commit with a single file (edge case).""" + # Mock GitHub repo + mock_repo = MagicMock() + + # Setup mocks + mock_branch = MagicMock() + mock_branch.commit.sha = "base_sha" + mock_repo.get_branch.return_value = mock_branch + + mock_blob = MagicMock() + mock_blob.sha = "blob_sha" + mock_repo.create_git_blob.return_value = mock_blob + + mock_base_tree = MagicMock() + mock_repo.get_git_tree.return_value = mock_base_tree + + mock_new_tree = MagicMock() + mock_new_tree.sha = "tree_sha" + mock_repo.create_git_tree.return_value = mock_new_tree + + mock_parent = MagicMock() + mock_repo.get_git_commit.return_value = mock_parent + + mock_commit = MagicMock() + mock_commit.sha = "single_commit_sha" + mock_repo.create_git_commit.return_value = mock_commit + + mock_ref = MagicMock() + mock_repo.get_git_ref.return_value = mock_ref + + # Create OrgApi + org_api = OrgApi(mock_repo) + + file_changes = [ + ("journal.org", "* Single entry\n") + ] + + commit_sha = org_api.create_atomic_commit( + file_changes, + "Single file commit" + ) + + assert commit_sha == "single_commit_sha" + assert mock_repo.create_git_blob.call_count == 1 + + +def test_create_atomic_commit_verifies_tree_elements(): + """Test that tree elements are created correctly.""" + # Mock GitHub repo + mock_repo = MagicMock() + + # Setup mocks + mock_branch = MagicMock() + mock_branch.commit.sha = "base_sha" + mock_repo.get_branch.return_value = mock_branch + + mock_blob = MagicMock() + mock_blob.sha = "blob_sha_123" + mock_repo.create_git_blob.return_value = mock_blob + + mock_base_tree = MagicMock() + mock_repo.get_git_tree.return_value = mock_base_tree + + mock_new_tree = MagicMock() + mock_new_tree.sha = "tree_sha" + mock_repo.create_git_tree.return_value = mock_new_tree + + mock_parent = MagicMock() + mock_repo.get_git_commit.return_value = mock_parent + + mock_commit = MagicMock() + mock_commit.sha = "commit_sha" + mock_repo.create_git_commit.return_value = mock_commit + + mock_ref = MagicMock() + mock_repo.get_git_ref.return_value = mock_ref + + # Create OrgApi + org_api = OrgApi(mock_repo) + + file_changes = [ + ("test/file.txt", "content") + ] + + org_api.create_atomic_commit(file_changes, "Test commit") + + # Verify create_git_tree was called with proper arguments + assert mock_repo.create_git_tree.called + call_args = mock_repo.create_git_tree.call_args + + # Check that tree_elements were passed + tree_elements = call_args[1]["tree"] + assert len(tree_elements) == 1 + + # Check that base_tree was passed + base_tree = call_args[1]["base_tree"] + assert base_tree == mock_base_tree + + +def test_create_atomic_commit_updates_ref(): + """Test that the branch ref is updated correctly.""" + # Mock GitHub repo + mock_repo = MagicMock() + + # Setup mocks + mock_branch = MagicMock() + mock_branch.commit.sha = "base_sha" + mock_repo.get_branch.return_value = mock_branch + + mock_blob = MagicMock() + mock_blob.sha = "blob_sha" + mock_repo.create_git_blob.return_value = mock_blob + + mock_base_tree = MagicMock() + mock_repo.get_git_tree.return_value = mock_base_tree + + mock_new_tree = MagicMock() + mock_new_tree.sha = "tree_sha" + mock_repo.create_git_tree.return_value = mock_new_tree + + mock_parent = MagicMock() + mock_repo.get_git_commit.return_value = mock_parent + + mock_commit = MagicMock() + mock_commit.sha = "final_commit_sha" + mock_repo.create_git_commit.return_value = mock_commit + + mock_ref = MagicMock() + mock_repo.get_git_ref.return_value = mock_ref + + # Create OrgApi + org_api = OrgApi(mock_repo) + + file_changes = [("test.txt", "test")] + + org_api.create_atomic_commit(file_changes, "Test") + + # Verify ref was fetched and edited + mock_repo.get_git_ref.assert_called_once_with("heads/main") + mock_ref.edit.assert_called_once_with(sha="final_commit_sha") diff --git a/tests/test_bot.py b/tests/test_bot.py index 94c5d5e..b211493 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -239,7 +239,7 @@ async def test_handle_action_journal(self, org_bot): response = await org_bot._handle_action(message, "Regular entry", None) assert response == "Added to journal!" - mock_func.assert_called_once_with(message, file_path=None) + mock_func.assert_called_once_with(message, file_paths=None) @pytest.mark.asyncio async def test_handle_action_todo(self, org_bot): @@ -258,7 +258,7 @@ async def test_handle_action_todo(self, org_bot): response = await org_bot._handle_action(message, "todo write tests", None) assert response == "Added to todo list!" - mock_func.assert_called_once_with(message, file_path=None) + mock_func.assert_called_once_with(message, file_paths=None) @pytest.mark.asyncio async def test_handle_action_reply(self, org_bot): @@ -277,7 +277,7 @@ async def test_handle_action_reply(self, org_bot): response = await org_bot._handle_action(message, "This is a reply", None) assert response == "Added reply to entry!" - mock_func.assert_called_once_with(message, file_path=None) + mock_func.assert_called_once_with(message, file_paths=None) @pytest.mark.asyncio async def test_handle_action_with_file(self, org_bot): @@ -290,11 +290,11 @@ async def test_handle_action_with_file(self, org_bot): org_bot.actions["journal"], "function", return_value=None ) as mock_func: response = await org_bot._handle_action( - message, "Entry with photo", file_path="/tmp/test.jpg" + message, "Entry with photo", file_paths=["/tmp/test.jpg"] ) assert response == "Added to journal!" - mock_func.assert_called_once_with(message, file_path="/tmp/test.jpg") + mock_func.assert_called_once_with(message, file_paths=["/tmp/test.jpg"]) @pytest.mark.asyncio async def test_handle_action_error(self, org_bot): diff --git a/tests/test_journal_posting.py b/tests/test_journal_posting.py index e75d74e..d987eaa 100644 --- a/tests/test_journal_posting.py +++ b/tests/test_journal_posting.py @@ -48,6 +48,34 @@ def mock_github_client(self) -> MagicMock: mock_repo.update_file.return_value = {"commit": {"sha": "new_commit_sha"}} mock_repo.create_file.return_value = {"commit": {"sha": "file_commit_sha"}} + # Mock Git Tree API for atomic commits + mock_branch = MagicMock() + mock_branch.commit.sha = "base_commit_sha" + mock_repo.get_branch.return_value = mock_branch + + mock_blob = MagicMock() + mock_blob.sha = "blob_sha_123" + mock_repo.create_git_blob.return_value = mock_blob + + mock_base_tree = MagicMock() + mock_base_tree.sha = "base_tree_sha" + mock_repo.get_git_tree.return_value = mock_base_tree + + mock_new_tree = MagicMock() + mock_new_tree.sha = "new_tree_sha" + mock_repo.create_git_tree.return_value = mock_new_tree + + mock_parent_commit = MagicMock() + mock_parent_commit.sha = "parent_commit_sha" + mock_repo.get_git_commit.return_value = mock_parent_commit + + mock_commit = MagicMock() + mock_commit.sha = "atomic_commit_sha" + mock_repo.create_git_commit.return_value = mock_commit + + mock_ref = MagicMock() + mock_repo.get_git_ref.return_value = mock_ref + logger.debug(f"Mock GitHub client configured with repo: {mock_repo}") return client @@ -177,7 +205,12 @@ def test_post_photo_message_to_journal( try: # Execute the method logger.info("Executing journal_instance.run() with photo message") - result = journal_instance.run(message=message, file_path=temp_image_path) + with patch.object( + journal_instance.org_api, + "create_atomic_commit", + wraps=journal_instance.org_api.create_atomic_commit, + ) as atomic_commit: + result = journal_instance.run(message=message, file_path=temp_image_path) # Verify result logger.info(f"Result: {result}") @@ -188,18 +221,19 @@ def test_post_photo_message_to_journal( # Verify GitHub interactions logger.info("Verifying GitHub API interactions") - # Should have called create_file to upload the image - journal_instance.repo.create_file.assert_called_once() - create_call_args = journal_instance.repo.create_file.call_args - logger.debug(f"create_file called with: {create_call_args}") + atomic_commit.assert_called_once() + file_changes, commit_message = atomic_commit.call_args.args + + assert isinstance(commit_message, str) + assert len(file_changes) == 2, "Expected photo + org file update in one commit" - # Should have called update_file to append journal entry with image reference - journal_instance.repo.update_file.assert_called_once() - update_call_args = journal_instance.repo.update_file.call_args - logger.debug(f"update_file called with: {update_call_args}") + paths = {path for path, _ in file_changes} + assert any(p.startswith("pics/telegram/") for p in paths) + assert journal_instance.file_path in paths - # Verify the content structure - updated_content = update_call_args[1]["content"] + updated_content = next( + content for path, content in file_changes if path == journal_instance.file_path + ) logger.info(f"Updated content length: {len(updated_content)} chars") logger.debug(f"Updated content:\n{updated_content}") @@ -262,7 +296,12 @@ def test_post_file_message_to_journal( try: # Execute the method logger.info("Executing journal_instance.run() with document message") - result = journal_instance.run(message=message, file_path=temp_pdf_path) + with patch.object( + journal_instance.org_api, + "create_atomic_commit", + wraps=journal_instance.org_api.create_atomic_commit, + ) as atomic_commit: + result = journal_instance.run(message=message, file_path=temp_pdf_path) # Verify result logger.info(f"Result: {result}") @@ -273,25 +312,18 @@ def test_post_file_message_to_journal( # Verify GitHub interactions logger.info("Verifying GitHub API interactions") - # Should have called create_file to upload the document - journal_instance.repo.create_file.assert_called_once() - create_call_args = journal_instance.repo.create_file.call_args - logger.debug(f"create_file called with: {create_call_args}") + atomic_commit.assert_called_once() + file_changes, _commit_message = atomic_commit.call_args.args - # Verify file was uploaded to correct path - uploaded_path = create_call_args[1]["path"] - logger.info(f"File uploaded to path: {uploaded_path}") - assert uploaded_path.startswith("pics/telegram/"), ( - "File should be uploaded to pics/telegram/" - ) - - # Should have called update_file to append journal entry - journal_instance.repo.update_file.assert_called_once() - update_call_args = journal_instance.repo.update_file.call_args - logger.debug(f"update_file called with: {update_call_args}") + assert len(file_changes) == 2, "Expected file + org file update in one commit" + paths = {path for path, _ in file_changes} + uploaded_paths = [p for p in paths if p.startswith("pics/telegram/")] + assert uploaded_paths, "Expected uploaded file under pics/telegram/" + assert journal_instance.file_path in paths - # Verify the content structure - updated_content = update_call_args[1]["content"] + updated_content = next( + content for path, content in file_changes if path == journal_instance.file_path + ) logger.info(f"Updated content length: {len(updated_content)} chars") logger.debug(f"Updated content:\n{updated_content}") diff --git a/tests/test_message_sequence_integration.py b/tests/test_message_sequence_integration.py index b3b3365..8791170 100644 --- a/tests/test_message_sequence_integration.py +++ b/tests/test_message_sequence_integration.py @@ -235,7 +235,7 @@ async def test_message_sequence_full_flow( # Process the message using OrgBot's internal method response = await org_bot._handle_action( - message, message.text, file_path=None + message, message.text, file_paths=None ) logger.info(f"Response: {response}") @@ -390,7 +390,7 @@ async def test_reply_response_not_none( # Process the reply response = await org_bot._handle_action( - reply_message, reply_message.text, file_path=None + reply_message, reply_message.text, file_paths=None ) logger.info(f"Response from reply: {response}") diff --git a/tests/test_todo_posting.py b/tests/test_todo_posting.py index 0eeb47b..d3827c6 100644 --- a/tests/test_todo_posting.py +++ b/tests/test_todo_posting.py @@ -45,6 +45,34 @@ def mock_github_client(self) -> MagicMock: mock_repo.update_file.return_value = {"commit": {"sha": "new_commit_sha"}} mock_repo.create_file.return_value = {"commit": {"sha": "file_commit_sha"}} + # Mock Git Tree API for atomic commits + mock_branch = MagicMock() + mock_branch.commit.sha = "base_commit_sha" + mock_repo.get_branch.return_value = mock_branch + + mock_blob = MagicMock() + mock_blob.sha = "blob_sha_456" + mock_repo.create_git_blob.return_value = mock_blob + + mock_base_tree = MagicMock() + mock_base_tree.sha = "base_tree_sha" + mock_repo.get_git_tree.return_value = mock_base_tree + + mock_new_tree = MagicMock() + mock_new_tree.sha = "new_tree_sha" + mock_repo.create_git_tree.return_value = mock_new_tree + + mock_parent_commit = MagicMock() + mock_parent_commit.sha = "parent_commit_sha" + mock_repo.get_git_commit.return_value = mock_parent_commit + + mock_commit = MagicMock() + mock_commit.sha = "atomic_commit_sha" + mock_repo.create_git_commit.return_value = mock_commit + + mock_ref = MagicMock() + mock_repo.get_git_ref.return_value = mock_ref + logger.debug(f"Mock GitHub client configured with repo: {mock_repo}") return client @@ -255,7 +283,12 @@ def test_post_photo_message_to_todo( try: # Execute the method logger.info("Executing todo_instance.run() with TODO photo message") - result = todo_instance.run(message=message, file_path=temp_image_path) + with patch.object( + todo_instance.org_api, + "create_atomic_commit", + wraps=todo_instance.org_api.create_atomic_commit, + ) as atomic_commit: + result = todo_instance.run(message=message, file_path=temp_image_path) # Verify result logger.info(f"Result: {result}") @@ -266,18 +299,17 @@ def test_post_photo_message_to_todo( # Verify GitHub interactions logger.info("Verifying GitHub API interactions") - # Should have called create_file to upload the image - todo_instance.repo.create_file.assert_called_once() - create_call_args = todo_instance.repo.create_file.call_args - logger.debug(f"create_file called with: {create_call_args}") + atomic_commit.assert_called_once() + file_changes, _commit_message = atomic_commit.call_args.args - # Should have called update_file to append TODO entry with image - todo_instance.repo.update_file.assert_called_once() - update_call_args = todo_instance.repo.update_file.call_args - logger.debug(f"update_file called with: {update_call_args}") + assert len(file_changes) == 2, "Expected photo + todo file update in one commit" + paths = {path for path, _ in file_changes} + assert any(p.startswith("pics/telegram/") for p in paths) + assert todo_instance.file_path in paths - # Verify the content structure - updated_content = update_call_args[1]["content"] + updated_content = next( + content for path, content in file_changes if path == todo_instance.file_path + ) logger.info(f"Updated content length: {len(updated_content)} chars") logger.debug(f"Updated content:\n{updated_content}") @@ -339,7 +371,12 @@ def test_post_file_message_to_todo( try: # Execute the method logger.info("Executing todo_instance.run() with TODO document message") - result = todo_instance.run(message=message, file_path=temp_pdf_path) + with patch.object( + todo_instance.org_api, + "create_atomic_commit", + wraps=todo_instance.org_api.create_atomic_commit, + ) as atomic_commit: + result = todo_instance.run(message=message, file_path=temp_pdf_path) # Verify result logger.info(f"Result: {result}") @@ -350,25 +387,18 @@ def test_post_file_message_to_todo( # Verify GitHub interactions logger.info("Verifying GitHub API interactions") - # Should have called create_file to upload the document - todo_instance.repo.create_file.assert_called_once() - create_call_args = todo_instance.repo.create_file.call_args - logger.debug(f"create_file called with: {create_call_args}") + atomic_commit.assert_called_once() + file_changes, _commit_message = atomic_commit.call_args.args - # Verify file was uploaded to correct path - uploaded_path = create_call_args[1]["path"] - logger.info(f"File uploaded to path: {uploaded_path}") - assert uploaded_path.startswith("pics/telegram/"), ( - "File should be uploaded to pics/telegram/" - ) + assert len(file_changes) == 2, "Expected file + todo file update in one commit" + paths = {path for path, _ in file_changes} + uploaded_paths = [p for p in paths if p.startswith("pics/telegram/")] + assert uploaded_paths, "Expected uploaded file under pics/telegram/" + assert todo_instance.file_path in paths - # Should have called update_file to append TODO entry - todo_instance.repo.update_file.assert_called_once() - update_call_args = todo_instance.repo.update_file.call_args - logger.debug(f"update_file called with: {update_call_args}") - - # Verify the content structure - updated_content = update_call_args[1]["content"] + updated_content = next( + content for path, content in file_changes if path == todo_instance.file_path + ) logger.info(f"Updated content length: {len(updated_content)} chars") logger.debug(f"Updated content:\n{updated_content}") diff --git a/uv.lock b/uv.lock index 7ea8380..bb9d977 100644 --- a/uv.lock +++ b/uv.lock @@ -485,30 +485,34 @@ wheels = [ [[package]] name = "functions-framework" -version = "3.4.0" +version = "3.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "cloudevents" }, { name = "flask" }, { name = "gunicorn", marker = "sys_platform != 'win32'" }, + { name = "starlette" }, + { name = "uvicorn" }, + { name = "uvicorn-worker" }, { name = "watchdog" }, + { name = "werkzeug" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/f1/9be8f433498f3223985820893d48dbe9ab2cd92d721496120ab98956bdba/functions-framework-3.4.0.tar.gz", hash = "sha256:c05639bcdd19f11a2eef1b6dabde5998f7d67cbddbffdf2f06ca56dc13c54e95", size = 38147, upload-time = "2023-05-25T21:18:59.001Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/d2/f3739b8f2a09123a07d847f43c52a13761a2b1cc41164ceb553e9de24e6f/functions_framework-3.10.0.tar.gz", hash = "sha256:b699d56308a986c118773b7102cf1a8d7740557c30196f282fe509102d3aae8a", size = 54091, upload-time = "2025-11-10T18:20:34.479Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/e5/8a50f46e0fe439fc59a27ffced0729d2121a1702b21a18e17eb67414d813/functions_framework-3.4.0-py3-none-any.whl", hash = "sha256:e22f30a66aab7180373f9a81f88114da088915b3622c62dcd6ec972ac908e0c4", size = 32018, upload-time = "2023-05-25T21:18:50.674Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9c/a6ad98c2b10ebf03fb55fde2cc95e475ac5f2491684cebba5847434ee902/functions_framework-3.10.0-py3-none-any.whl", hash = "sha256:fbeb7f383b35ed4c4e2eac47f33a756fe9814aa0c43d2c4dbb07b1aac02560c1", size = 41404, upload-time = "2025-11-10T18:20:33.557Z" }, ] [[package]] name = "gunicorn" -version = "20.1.0" +version = "25.0.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "setuptools" }, + { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/5b/0d1f0296485a6af03366604142ea8f19f0833894db3512a40ed07b2a56dd/gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8", size = 370601, upload-time = "2021-03-27T01:54:37.202Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/e5/e1d2225d2b75fe4988821715d2c526fdf7b39f4a7260aa7e2bb4b25ec65c/gunicorn-25.0.3.tar.gz", hash = "sha256:b53a7fff1a07b825b962af320554de44ae77a26abfa373711ff3f83d57d3506d", size = 9702357, upload-time = "2026-02-07T16:53:52.72Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/dd/5b190393e6066286773a67dfcc2f9492058e9b57c4867a95f1ba5caf0a83/gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e", size = 79531, upload-time = "2021-04-27T12:16:23.375Z" }, + { url = "https://files.pythonhosted.org/packages/5e/84/117f39896ded517149be72d16c02252885690e9b0d1b84281944928f61aa/gunicorn-25.0.3-py3-none-any.whl", hash = "sha256:aca364c096c81ca11acd4cede0aaeea91ba76ca74e2c0d7f879154db9d890f35", size = 171728, upload-time = "2026-02-07T16:53:49.546Z" }, ] [[package]] @@ -822,7 +826,7 @@ requires-dist = [ { name = "defopt" }, { name = "flake8", marker = "extra == 'dev'" }, { name = "flask", specifier = "~=2.3.2" }, - { name = "functions-framework", specifier = "==3.4.0" }, + { name = "functions-framework", specifier = "==3.10.0" }, { name = "mypy", marker = "extra == 'dev'" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, @@ -1245,15 +1249,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/f4/8e1be145ce63c4ef4e126ee362992ae1ada5e716723883912b74829583e7/sentry_sdk-2.8.0-py2.py3-none-any.whl", hash = "sha256:6051562d2cfa8087bb8b4b8b79dc44690f8a054762a29c07e22588b1f619bfb5", size = 300617, upload-time = "2024-07-08T08:11:33.904Z" }, ] -[[package]] -name = "setuptools" -version = "82.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -1276,6 +1271,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/f2/6b7627dfe7b4e418e295e254bb15c3a6455f11f8c0ad0d43113f678049c3/sphinxcontrib_napoleon-0.7-py2.py3-none-any.whl", hash = "sha256:711e41a3974bdf110a484aec4c1a556799eb0b3f3b897521a018ad7e2db13fef", size = 17151, upload-time = "2018-09-23T14:16:45.548Z" }, ] +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -1360,6 +1368,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "uvicorn-worker" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gunicorn" }, + { name = "uvicorn" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/59/9101b9c0680fd80e9d26c07deb822a5d18a324339fcf9cd017885ee808ad/uvicorn_worker-0.4.0.tar.gz", hash = "sha256:8ee5306070d8f38dce124adce488c3c0b50f20cf0c0222b12c66188da7214493", size = 9361, upload-time = "2025-09-20T10:47:01.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/25/09cd7a90c8bb7fb693be0d6704fccd5f9778d5513214b7a01cc4a94ff314/uvicorn_worker-0.4.0-py3-none-any.whl", hash = "sha256:e2ed952cef976f5e9e429d7269640bbcafbd36c80aa80f1003c8c77a6797abde", size = 5364, upload-time = "2025-09-20T10:46:59.776Z" }, +] + [[package]] name = "watchdog" version = "6.0.0"