diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..b23a26d --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,30 @@ +version: 2.1 + +jobs: + test: + docker: + - image: circleci/python:3.12 + steps: + - checkout + - run: + name: Install uv + command: | + curl -sSL https://install.uv.tools | bash + - run: + name: Install dependencies + command: uv init # Installs dependencies from pyproject.toml + - run: + name: Run pytest tests + command: pytest tests + - run: + name: Run ruff for linting + command: ruff check . + - run: + name: Run mypy for type checking + command: mypy . + +workflows: + version: 2 + test_and_static_analysis: + jobs: + - test diff --git a/.gitignore b/.gitignore index 82f9275..685731a 100644 --- a/.gitignore +++ b/.gitignore @@ -154,9 +154,18 @@ dmypy.json # Cython debug symbols cython_debug/ +# Mac metadata file +.DS_Store + # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +.DS_Store + +credentials.json +token.json +token_calendar.json +.python-version \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/README.md b/README.md index 73b708f..95a4930 100644 --- a/README.md +++ b/README.md @@ -1 +1,159 @@ -# mAIgic-nyu \ No newline at end of file +# mAIgic + +## Project Description + +**mAIgic** is an AI-powered assistant designed to enhance productivity and organization by tracking messages and important information across multiple platforms. It identifies and reminds users about follow-ups, ensuring timely responses and efficient information management. With intelligent search capabilities, mAIgic enables users to easily locate conversations and details tied to specific individuals or tasks. + +The project supports popular platforms such as Slack, Trello, Gmail, and Google Calendar, providing seamless follow-up tracking, reminders, and contextual search across these communication channels. This integration leverages AI to automate the reminder process, making it easier to stay on top of essential follow-ups and tasks. + +## Features + +- **Slack Integration**: Manage tasks, create checklists, and fetch emails directly from Slack. +- **Trello Integration**: Automate task management with Trello boards, lists, and cards. +- **Gmail Integration**: Fetch and post unread emails to Slack channels. +- **Google Calendar Integration**: Automatically synchronize tasks with due dates to Google Calendar. +- **Unit Testing**: Comprehensive test coverage using `pytest`. +- **Static Code Analysis**: Ensure code quality with `ruff` (linter). +- **Type Checking**: Maintain type consistency with `mypy`. +- **Continuous Integration**: CircleCI pipeline for running tests and static analysis on every push. + +## Setup Instructions + +### 1. Clone the Repository + +```bash +git clone https://github.com/your-username/mAIgic.git +cd mAIgic +``` + +### 2. Install Dependencies + +Using the uv package manager, run the following command: + +```bash +uv sync +``` + +This will install all required dependencies in a virtual environment. To activate the virtual environment: + +```bash +source ./.venv/bin/activate +``` + +## Running the Application + +### 1. Start the Application + +Run the application to start the Slack bot and Gmail bot: + +```bash +python app.py +``` + +This will: +1. Start a thread for the Gmail bot to periodically fetch unread emails. +2. Initialize the Slack bot to listen for commands. + +## Commands Overview + +### Slack Commands + +1. **Add Task to Trello**: + ``` + add "task name" to [list name] by YYYY-MM-DD HH:MM + ``` + - Adds a task to the specified Trello list. + - If a due date is provided, it creates a Google Calendar event. + +2. **Remove Task from Trello**: + ``` + remove "task name" from [list name] + ``` + - Removes a task from the Trello list and deletes the corresponding Google Calendar event. + +3. **Show Tasks in Trello List**: + ``` + show me tasks in [list name] + ``` + - Displays all tasks in the specified Trello list. + +4. **Delete Trello List**: + ``` + delete list [list name] + ``` + - Archives the specified Trello list and removes associated Google Calendar events. + +5. **Fetch Unread Emails**: + ``` + fetch emails + ``` + - Fetches the latest 5 unread emails and posts them in Slack. + +6. **Show Specific Number of Emails**: + ``` + show me [number] emails + ``` + - Fetches the specified number of unread emails. + +7. **Create Checklist in Trello Card**: + ``` + create checklist "checklist name" in "card name" in [list name] + ``` + +8. **Add Item to Checklist**: + ``` + add "item name" to checklist "checklist name" in "card name" in [list name] + ``` + +9. **Help**: + ``` + help + ``` + - Displays the list of available commands. + +## Running Tests + +### Unit Tests + +Run tests using pytest: + +```bash +pytest tests +``` + +### Static Code Analysis + +Check for linting issues with ruff: + +```bash +ruff check . +``` + +### Type Checking + +Perform type checking with mypy: + +```bash +mypy . +``` + +## CircleCI Configuration + +The project uses CircleCI for continuous integration. Every push triggers the following steps: +1. Install dependencies using uv. +2. Run unit tests using pytest. +3. Perform static code analysis using ruff. +4. Perform type checking using mypy. + +## License + +This project is licensed under the MIT License. See the LICENSE file for details. + +## Contributors + +- Siddharth Singh - sms10221@nyu.edu +- Adittya Mittal - am14079@nyu.edu +- Anushka Tawte - at5849@nyu.edu +- Rafael de Leon - rdl404@nyu.edu +- Alex Ying - aty2009@nyu.edu +- Mridul Mittal - mm13171@nyu.edu \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..d72bf7a --- /dev/null +++ b/app.py @@ -0,0 +1,13 @@ +# app.py + +import threading +from src.slack.slack_bot import run_slack_bot +from src.gmail.gmail_bot import run_gmail_to_slack + +if __name__ == "__main__": + # Start Gmail bot in a separate thread + gmail_thread = threading.Thread(target=run_gmail_to_slack) + gmail_thread.start() + + # Start Slack bot + run_slack_bot() \ No newline at end of file diff --git a/hello.py b/hello.py new file mode 100644 index 0000000..986258f --- /dev/null +++ b/hello.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from maigic!") + + +if __name__ == "__main__": + main() diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..41ca336 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,3 @@ +[mypy] +python_version = 3.10 +ignore_missing_imports = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..de20e18 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "mAIgic" +version = "0.1.0" +description = "A project focused on testing, static analysis, and CI integration with CircleCI" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "pytest", + "ruff", + "mypy" +] + +[tool.ruff.lint] +select = ["ALL"] +ignore = ["D203", "D213", "COM812", "ISC001", "PLW0603", "FBT001", "FBT002", "SLF001", "ANN001", "ANN201", "PLR2004", "DTZ005", "SIM117", "D103", "BLE001"] \ No newline at end of file diff --git a/src/calendar/__init__.py b/src/calendar/__init__.py new file mode 100644 index 0000000..abab659 --- /dev/null +++ b/src/calendar/__init__.py @@ -0,0 +1,3 @@ +# src/calendar/__init__.py +from .calendar_api import authenticate_google_calendar, add_event_to_calendar, update_calendar_event, delete_calendar_event +from .calendar_sync import add_task_to_calendar, update_task_in_calendar, delete_task_from_calendar diff --git a/src/calendar/calendar_api.py b/src/calendar/calendar_api.py new file mode 100644 index 0000000..1eaa2f2 --- /dev/null +++ b/src/calendar/calendar_api.py @@ -0,0 +1,107 @@ +# src/calendar/calendar_api.py + +import os +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build +from google.auth.transport.requests import Request + +# Load environment variables +SCOPES = ['https://www.googleapis.com/auth/calendar'] + +# Authenticate and set up Google Calendar API +def authenticate_google_calendar(): + """ + Authenticate and set up the Google Calendar API client. + + Checks for existing credentials in `token_calendar.json`. If credentials + are invalid or do not exist, initiates the OAuth2 flow to generate new credentials. + + Returns: + google.oauth2.credentials.Credentials: Authenticated credentials for the API. + + Raises: + FileNotFoundError: If `credentials.json` is not found in the current directory. + """ + creds = None + if os.path.exists('token_calendar.json'): + creds = Credentials.from_authorized_user_file('token_calendar.json', SCOPES) + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES) + creds = flow.run_local_server(port=0) + with open('token_calendar.json', 'w') as token: + token.write(creds.to_json()) + return creds + +# Add an event to Google Calendar +def add_event_to_calendar(task_name: str, due_date: str) -> str: + """ + Add a new event to the user's primary Google Calendar. + + Args: + task_name (str): The name or summary of the event. + due_date (str): The start and end time of the event in ISO 8601 format + (e.g., '2024-12-04T10:00:00-07:00'). + + Returns: + str: The unique event ID of the created event. + + Raises: + googleapiclient.errors.HttpError: If the event creation fails. + """ + service = build('calendar', 'v3', credentials=authenticate_google_calendar()) + event = { + 'summary': task_name, + 'start': { + 'dateTime': due_date, + }, + 'end': { + 'dateTime': due_date, + }, + } + event = service.events().insert(calendarId='primary', body=event).execute() + return event['id'] + +# Update an existing event in Google Calendar +def update_calendar_event(event_id: str, task_name: str, due_date: str) -> dict: + """ + Update an existing event in the user's primary Google Calendar. + + Args: + event_id (str): The unique ID of the event to be updated. + task_name (str): The updated name or summary of the event. + due_date (str): The updated start and end time of the event in ISO 8601 format. + + Returns: + dict: The updated event resource. + + Raises: + googleapiclient.errors.HttpError: If the event update fails. + """ + service = build('calendar', 'v3', credentials=authenticate_google_calendar()) + event = service.events().get(calendarId='primary', eventId=event_id).execute() + event['summary'] = task_name + event['start']['dateTime'] = due_date + event['end']['dateTime'] = due_date + updated_event = service.events().update(calendarId='primary', eventId=event_id, body=event).execute() + return updated_event + +# Delete an event from Google Calendar +def delete_calendar_event(event_id : str) -> None: + """ + Delete an event from the user's primary Google Calendar. + + Args: + event_id (str): The unique ID of the event to be deleted. + + Returns: + None + + Raises: + googleapiclient.errors.HttpError: If the event deletion fails. + """ + service = build('calendar', 'v3', credentials=authenticate_google_calendar()) + service.events().delete(calendarId='primary', eventId=event_id).execute() \ No newline at end of file diff --git a/src/calendar/calendar_sync.py b/src/calendar/calendar_sync.py new file mode 100644 index 0000000..d44801b --- /dev/null +++ b/src/calendar/calendar_sync.py @@ -0,0 +1,59 @@ +# src/calendar/calendar_sync.py + +from .calendar_api import add_event_to_calendar, update_calendar_event, delete_calendar_event + +# Function to handle task addition with due date +def add_task_to_calendar(task_name: str, due_date: str) -> str: + """ + Add a task to the Google Calendar if a due date is provided. + + Args: + task_name (str): The name or summary of the task. + due_date (str): The due date and time of the task in ISO 8601 format + (e.g., '2024-12-04T10:00:00-07:00'). + + Returns: + str: The unique event ID of the created calendar event, or None if no due date is provided. + + Raises: + googleapiclient.errors.HttpError: If the event creation fails. + """ + if due_date: + event_id = add_event_to_calendar(task_name, due_date) + return event_id + return None + +# Function to handle task update with a new due date +def update_task_in_calendar(event_id: str, task_name: str, new_due_date: str) -> None: + """ + Update an existing task in the Google Calendar with a new name or due date. + + Args: + event_id (str): The unique ID of the calendar event to be updated. + task_name (str): The updated name or summary of the task. + new_due_date (str): The updated due date and time in ISO 8601 format. + + Returns: + None + + Raises: + googleapiclient.errors.HttpError: If the event update fails. + """ + if new_due_date: + update_calendar_event(event_id, task_name, new_due_date) + +# Function to handle task deletion +def delete_task_from_calendar(event_id: str) -> None: + """ + Delete a task from the Google Calendar. + + Args: + event_id (str): The unique ID of the calendar event to be deleted. + + Returns: + None + + Raises: + googleapiclient.errors.HttpError: If the event deletion fails. + """ + delete_calendar_event(event_id) \ No newline at end of file diff --git a/src/gmail/__init__.py b/src/gmail/__init__.py new file mode 100644 index 0000000..e6f3441 --- /dev/null +++ b/src/gmail/__init__.py @@ -0,0 +1,4 @@ +# src/gmail/__init__.py + +from .gmail_api import authenticate_gmail, fetch_unread_emails +#from .gmail_bot import fetch_and_post_emails, run_gmail_to_slack \ No newline at end of file diff --git a/src/gmail/gmail_api.py b/src/gmail/gmail_api.py new file mode 100644 index 0000000..ad11628 --- /dev/null +++ b/src/gmail/gmail_api.py @@ -0,0 +1,85 @@ +# src/gmail/gmail_api.py + +import os +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build +import html + +# Load environment variables +SCOPES = ['https://www.googleapis.com/auth/gmail.modify'] + +# Authenticate and set up Gmail API +def authenticate_gmail(): + """ + Authenticate and set up the Gmail API client. + + Checks for existing credentials in `token.json`. If credentials + are invalid or do not exist, initiates the OAuth2 flow to generate new credentials. + + Returns: + google.oauth2.credentials.Credentials: Authenticated credentials for the API. + + Raises: + FileNotFoundError: If `credentials.json` is not found in the current directory. + """ + creds = None + if os.path.exists('token.json'): + creds = Credentials.from_authorized_user_file('token.json', SCOPES) + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file( + 'credentials.json', SCOPES) + creds = flow.run_local_server(port=0) + with open('token.json', 'w') as token: + token.write(creds.to_json()) + return creds + +# Fetch unread emails from Gmail +def fetch_unread_emails(service, num_emails: int = 5) -> list: + """ + Fetch unread emails from the user's Gmail inbox. + + Args: + service: The Gmail API service instance. + num_emails (int, optional): The maximum number of unread emails to fetch. Defaults to 5. + + Returns: + list: A list of dictionaries containing email subjects and snippets. + Each dictionary has the structure: + { + "subject": , + "snippet": + } + + Raises: + googleapiclient.errors.HttpError: If the API request fails. + """ + results = service.users().messages().list(userId='me', labelIds=['UNREAD']).execute() + messages = results.get('messages', []) + email_texts = [] + if not messages: + return email_texts + + # Limit the number of emails to fetch based on num_emails parameter + for msg in messages[:num_emails]: + msg_id = msg['id'] + message = service.users().messages().get(userId='me', id=msg_id).execute() + subject = '' + for header in message['payload']['headers']: + if header['name'] == 'Subject': + subject = header['value'] + break + # Decode HTML entities in the snippet + snippet = html.unescape(message.get('snippet', '')) + + # Append email details as a dictionary + email_texts.append({ + "subject": subject, + "snippet": snippet + }) + + return email_texts \ No newline at end of file diff --git a/src/gmail/gmail_bot.py b/src/gmail/gmail_bot.py new file mode 100644 index 0000000..fc5b4e6 --- /dev/null +++ b/src/gmail/gmail_bot.py @@ -0,0 +1,116 @@ +# src/gmail/gmail_bot.py + +import os +import time +from dotenv import load_dotenv +from googleapiclient.discovery import build +from .gmail_api import authenticate_gmail, fetch_unread_emails +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +# Load environment variables +load_dotenv() + +# Environment variables +SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN") +SLACK_CHANNEL_ID = os.getenv("SLACK_CHANNEL_ID") + +# Set up Slack client +slack_client = WebClient(token=SLACK_BOT_TOKEN) + +# Send fetched emails to Slack +def post_emails_to_slack() -> None: + """ + Fetch unread emails from Gmail and post them to a Slack channel. + + If no unread emails are found, a message is posted indicating no new emails. + + Returns: + None + + Raises: + googleapiclient.errors.HttpError: If Gmail API requests fail. + slack_sdk.errors.SlackApiError: If Slack API requests fail. + """ + service = build('gmail', 'v1', credentials=authenticate_gmail()) + emails = fetch_unread_emails(service) + if not emails: + try: + slack_client.chat_postMessage(channel=SLACK_CHANNEL_ID, text="No new unread emails.") + except SlackApiError as e: + print(f"Error posting to Slack: {e.response['error']}") + return + + for email in emails: + message = { + "text": "*New Email Received*", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Subject*: {email['subject']}\n*Snippet*: {email['snippet']}" + } + } + ] + } + try: + slack_client.chat_postMessage(channel=SLACK_CHANNEL_ID, **message) + except SlackApiError as e: + print(f"Error posting to Slack: {e.response['error']}") + +# Fetch and post a specific number of emails to Slack on demand +def fetch_and_post_emails(num_emails: int = 5) -> None: + """ + Fetch a specific number of unread emails from Gmail and post them to a Slack channel. + + Args: + num_emails (int, optional): The number of unread emails to fetch. Defaults to 5. + + Returns: + None + + Raises: + googleapiclient.errors.HttpError: If Gmail API requests fail. + slack_sdk.errors.SlackApiError: If Slack API requests fail. + """ + service = build('gmail', 'v1', credentials=authenticate_gmail()) + emails = fetch_unread_emails(service, num_emails=num_emails) + if not emails: + try: + slack_client.chat_postMessage(channel=SLACK_CHANNEL_ID, text="No new unread emails.") + except SlackApiError as e: + print(f"Error posting to Slack: {e.response['error']}") + return + + for email in emails: + message = { + "text": "*New Email Received*", + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*Subject*: {email['subject']}\n*Snippet*: {email['snippet']}" + } + } + ] + } + try: + slack_client.chat_postMessage(channel=SLACK_CHANNEL_ID, **message) + except SlackApiError as e: + print(f"Error posting to Slack: {e.response['error']}") + +# Run this function periodically +def run_gmail_to_slack() -> None: + """ + Periodically fetch unread emails from Gmail and post them to Slack. + + This function runs indefinitely, checking for new emails every 5 minutes. + + Returns: + None + """ + while True: + post_emails_to_slack() + time.sleep(3000) # Check for new emails every 5 minutes \ No newline at end of file diff --git a/src/slack/__init__.py b/src/slack/__init__.py new file mode 100644 index 0000000..cb4766d --- /dev/null +++ b/src/slack/__init__.py @@ -0,0 +1,4 @@ +# src/slack/__init__.py + +from .slack_api import post_to_slack +#from .slack_bot import run_slack_bot \ No newline at end of file diff --git a/src/slack/slack_api.py b/src/slack/slack_api.py new file mode 100644 index 0000000..a2c91c3 --- /dev/null +++ b/src/slack/slack_api.py @@ -0,0 +1,31 @@ +# src/slack/slack_api.py + +import os +from slack_sdk import WebClient +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +SLACK_BOT_TOKEN = os.getenv('SLACK_BOT_TOKEN') + +# Initialize the Slack client +slack_client = WebClient(token=SLACK_BOT_TOKEN) + +# Function to post a message to a Slack channel +def post_to_slack(channel: str, message: str) -> dict: + """ + Post a message to a specific Slack channel. + + Args: + channel (str): The Slack channel ID or name where the message will be posted. + message (str): The message text to post. + + Returns: + dict: The response from Slack API containing details about the posted message. + + Raises: + slack_sdk.errors.SlackApiError: If the Slack API request fails. + """ + response = slack_client.chat_postMessage(channel=channel, text=message) + return response \ No newline at end of file diff --git a/src/slack/slack_bot.py b/src/slack/slack_bot.py new file mode 100644 index 0000000..5243207 --- /dev/null +++ b/src/slack/slack_bot.py @@ -0,0 +1,260 @@ +# src/slack/slack_bot.py + +import os +import re +from slack_sdk import WebClient +from slack_bolt import App +from dotenv import load_dotenv +from datetime import datetime +from pytz import timezone +from slack_bolt import Ack + +from ..trello.trello_api import TrelloClient +# card_event_map should still be accessible from trello_bot if it's defined there +from ..trello.trello_bot import card_event_map +from ..calendar.calendar_sync import add_task_to_calendar, delete_task_from_calendar +from ..gmail.gmail_bot import fetch_and_post_emails +from .slack_api import post_to_slack + +# Load environment variables from .env file +load_dotenv() + +# Environment variables +SLACK_BOT_TOKEN = os.getenv('SLACK_BOT_TOKEN') +SLACK_APP_TOKEN = os.getenv('SLACK_APP_TOKEN') +LOCAL_TIMEZONE = timezone("America/New_York") # Adjust as necessary + +# Initialize the Slack app (Bolt framework) +app = App(token=SLACK_BOT_TOKEN) + +# Create a Trello client instance +trello_client = TrelloClient() + + +@app.message(re.compile(r'add "(.*)" to (.*?)(?: by (\d{4}-\d{2}-\d{2} \d{2}:\d{2}))?$')) +def add_to_trello_task(message, say, context): + task_to_add = context['matches'][0] + list_name = context['matches'][1] + due_date_str = context['matches'][2] if len(context['matches']) > 2 else None + + due_date = None + if due_date_str: + try: + naive_due_date = datetime.strptime(due_date_str, '%Y-%m-%d %H:%M') + local_due_date = LOCAL_TIMEZONE.localize(naive_due_date) + due_date = local_due_date.isoformat() + except ValueError: + response_message = "Failed to add the task. Please provide date and time in 'YYYY-MM-DD HH:MM' format." + post_to_slack(message['channel'], response_message) + return + + try: + added_card = trello_client.add_card_to_trello(task_to_add, list_name, due_date) + if due_date: + event_id = add_task_to_calendar(task_to_add, due_date) + card_id = added_card['id'] + card_event_map[card_id] = event_id + response_message = f"Added '{task_to_add}' to '{list_name}' with a due date of {due_date_str}. Event added to Google Calendar." + else: + response_message = f"Added '{task_to_add}' to '{list_name}'." + except Exception as e: + response_message = f"Failed to add the task. Error: {e}" + + post_to_slack(message['channel'], response_message) + + +@app.message(re.compile(r'remove "(.*)" from (.*)')) +def remove_from_trello_task(message, say, context): + task_to_remove = context['matches'][0] + list_name = context['matches'][1] + try: + card_id = trello_client.delete_card_from_trello(task_to_remove, list_name) + if card_id: + if card_id in card_event_map: + event_id = card_event_map.pop(card_id) + delete_task_from_calendar(event_id) + response_message = f"Deleted '{task_to_remove}' from your Trello '{list_name}' list and corresponding Google Calendar event." + else: + response_message = f"No task found with the name '{task_to_remove}' in your Trello '{list_name}' list." + except Exception as e: + response_message = f"Failed to remove the task. Error: {e}" + + post_to_slack(message['channel'], response_message) + + +@app.message(re.compile(r'delete list (.*)', re.IGNORECASE)) +def delete_trello_list(message, say, context): + list_name = context['matches'][0] + try: + success = trello_client.archive_trello_list(list_name) + if success: + # Also need to remove calendar events for tasks in this list + try: + cards = trello_client.get_trello_cards(list_name) + for card in cards: + card_id = card['id'] + if card_id in card_event_map: + event_id = card_event_map.pop(card_id) + delete_task_from_calendar(event_id) + except Exception: + # If we fail to fetch cards after archiving, just pass + pass + + response_message = f"Archived the list '{list_name}' in Trello and removed associated Google Calendar events." + else: + response_message = f"The list '{list_name}' does not exist in Trello." + except Exception as e: + response_message = f"Failed to delete the list. Error: {e}" + + post_to_slack(message['channel'], response_message) + + +@app.message(re.compile(r'show me tasks in (.*)')) +def show_tasks_in_list(message, say, context): + list_name = context['matches'][0] + + try: + cards = trello_client.get_trello_cards(list_name) + tasks = [card['name'] for card in cards] + if tasks: + task_list = "\n".join(tasks) + response_message = f"Here are the tasks in '{list_name}':\n{task_list}" + else: + response_message = f"No tasks found in '{list_name}'." + except Exception as e: + response_message = f"Failed to fetch tasks. Error: {e}" + + post_to_slack(message['channel'], response_message) + + +@app.message(re.compile(r'show me (\d+) emails')) +def handle_show_emails(message, say, context): + num_emails = int(context['matches'][0]) + say(f"Fetching the latest {num_emails} unread emails...") + fetch_and_post_emails(num_emails) + + +@app.message("fetch emails") +def handle_fetch_emails(message, say): + say("Fetching the latest 5 unread emails...") + fetch_and_post_emails() + + +@app.message(re.compile(r'create checklist "(.*)" in "(.*)" in (.*)', re.IGNORECASE)) +def create_checklist_handler(message, say, context): + checklist_name = context['matches'][0] + card_name = context['matches'][1] + list_name = context['matches'][2] + + try: + # Get card ID + card_id = trello_client.get_card_id(card_name, list_name) + if card_id is None: + response_message = f"The card '{card_name}' does not exist in list '{list_name}'." + post_to_slack(message['channel'], response_message) + return + + # Create checklist + checklist = trello_client.create_checklist_in_card(card_id, checklist_name) + response_message = f"Created checklist '{checklist_name}' in card '{card_name}' in list '{list_name}'." + except Exception as e: + response_message = f"Failed to create checklist. Error: {e}" + + post_to_slack(message['channel'], response_message) + + +@app.message(re.compile(r'add "(.*)" to checklist "(.*)" in "(.*)" in (.*)', re.IGNORECASE)) +def add_item_to_checklist_handler(message, say, context): + item_name = context['matches'][0] + checklist_name = context['matches'][1] + card_name = context['matches'][2] + list_name = context['matches'][3] + + try: + # Get card ID + card_id = trello_client.get_card_id(card_name, list_name) + if card_id is None: + response_message = f"The card '{card_name}' does not exist in list '{list_name}'." + post_to_slack(message['channel'], response_message) + return + + # Get checklist ID + checklist_id = trello_client.get_checklist_id(card_id, checklist_name) + if checklist_id is None: + response_message = f"The checklist '{checklist_name}' does not exist in card '{card_name}'." + post_to_slack(message['channel'], response_message) + return + + # Add item to checklist + item = trello_client.add_item_to_checklist(checklist_id, item_name) + response_message = f"Added item '{item_name}' to checklist '{checklist_name}' in card '{card_name}'." + except Exception as e: + response_message = f"Failed to add item to checklist. Error: {e}" + + post_to_slack(message['channel'], response_message) + + +@app.message("help") +def show_help(message, say): + help_text = """ +Here are the commands you can use: + +*Task Management Commands:* +1. **Add a task to a list**: + `add "task name" to [list name] by YYYY-MM-DD HH:MM` + - Adds a task to the specified list with an optional due date and time. + - If a due date and time are provided, it will also create an event in Google Calendar. + +2. **Remove a task from a list**: + `remove "task name" from [list name]` + - Deletes the specified task from the specified Trello list. + - If the task has a corresponding Google Calendar event, it will also be deleted. + +3. **Show tasks in a list**: + `show me tasks in [list name]` + - Displays all tasks in the specified Trello list. + +4. **Delete (archive) a list**: + `delete list [list name]` + - Archives the specified Trello list and removes associated Google Calendar events. + +*Email Commands:* +5. **Fetch unread emails**: + `fetch emails` + - Fetches the latest 5 unread emails from Gmail and posts them in Slack. + +6. **Show me X emails**: + `show me [number] emails` + - Fetches the specified number of unread emails. + +*Checklist Commands:* +7. **Create a checklist in a card**: + `create checklist "checklist name" in "card name" in list name` + +8. **Add an item to a checklist**: + `add "item name" to checklist "checklist name" in "card name" in list name` + +*General Commands:* +9. **Help**: + `help` + - Displays this help message. + +*Google Calendar Synchronization*: +- Adding a task with a due date creates a Google Calendar event. +- Removing a task or deleting a list removes associated Google Calendar events. + +Use double quotes around task names for clarity. +""" + post_to_slack(message['channel'], help_text) + +@app.event("message") +def handle_unhandled_messages(event, say, logger): + user = event.get('user') + text = event.get('text') + logger.info(f"Unhandled message from user {user}: {text}") + say(f"Sorry, I don't understand that command. Type `help` to see the list of available commands.") + +def run_slack_bot(): + from slack_bolt.adapter.socket_mode import SocketModeHandler + handler = SocketModeHandler(app, os.getenv('SLACK_APP_TOKEN')) + handler.start() \ No newline at end of file diff --git a/src/trello/__init__.py b/src/trello/__init__.py new file mode 100644 index 0000000..96ed6f0 --- /dev/null +++ b/src/trello/__init__.py @@ -0,0 +1,2 @@ +from .trello_api import TrelloClient +from .trello_bot import TrelloBot, card_event_map \ No newline at end of file diff --git a/src/trello/trello_api.py b/src/trello/trello_api.py new file mode 100644 index 0000000..393e675 --- /dev/null +++ b/src/trello/trello_api.py @@ -0,0 +1,238 @@ +# src/trello/trello_api.py +import os +import requests +from dotenv import load_dotenv +from typing import Optional + +# Load environment variables from .env file +load_dotenv() + +# Environment variables +TRELLO_API_KEY = os.getenv('TRELLO_API_KEY') +TRELLO_TOKEN = os.getenv('TRELLO_TOKEN') +TRELLO_BOARD_ID = os.getenv('TRELLO_BOARD_ID') + +# Base URL for Trello API +TRELLO_API_BASE = "https://api.trello.com/1" + + +class TrelloClient: + def __init__(self, api_key: str = TRELLO_API_KEY, token: str = TRELLO_TOKEN, board_id: str = TRELLO_BOARD_ID): + self.api_key = api_key + self.token = token + self.board_id = board_id + + def get_trello_list_id(self, list_name: str, include_archived: bool = False) -> Optional[str]: + """ + Fetch a Trello list ID by name. + """ + filter_value = 'all' if include_archived else 'open' + lists_url = f"{TRELLO_API_BASE}/boards/{self.board_id}/lists" + params = { + 'key': self.api_key, + 'token': self.token, + 'filter': filter_value + } + response = requests.get(lists_url, params=params) + if response.status_code == 200: + lists = response.json() + for lst in lists: + if lst['name'].lower() == list_name.lower(): + return lst['id'] + return None + else: + raise Exception(f"Failed to fetch lists from Trello: {response.status_code}, {response.text}") + + def create_trello_list(self, list_name: str) -> str: + """ + Create a new Trello list. + """ + create_list_url = f"{TRELLO_API_BASE}/boards/{self.board_id}/lists" + params = { + 'key': self.api_key, + 'token': self.token, + 'name': list_name, + 'pos': 'bottom' + } + response = requests.post(create_list_url, params=params) + if response.status_code == 200: + return response.json()['id'] + else: + raise Exception(f"Failed to create list '{list_name}': {response.status_code}, {response.text}") + + def add_card_to_trello(self, card_name: str, list_name: str, due_date: str = None) -> dict: + """ + Add a card to a specified Trello list with an optional due date. + """ + list_id = self.get_trello_list_id(list_name) + if list_id is None: + # List does not exist, so create it + list_id = self.create_trello_list(list_name) + + create_card_url = f"{TRELLO_API_BASE}/cards" + params = { + 'key': self.api_key, + 'token': self.token, + 'idList': list_id, + 'name': card_name, + 'due': due_date + } + response = requests.post(create_card_url, params=params) + + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to add card to Trello: {response.status_code}, {response.text}") + + def get_trello_cards(self, list_name: str) -> list: + """ + Fetch all cards from a specified Trello list. + """ + list_id = self.get_trello_list_id(list_name) + if list_id is None: + raise Exception(f"The list '{list_name}' does not exist in Trello.") + + get_cards_url = f"{TRELLO_API_BASE}/lists/{list_id}/cards" + params = { + 'key': self.api_key, + 'token': self.token + } + response = requests.get(get_cards_url, params=params) + + if response.status_code == 200: + cards = response.json() + return cards + else: + raise Exception(f"Failed to fetch tasks from Trello: {response.status_code}, {response.text}") + + def delete_card_from_trello(self, card_name: str, list_name: str) -> Optional[str]: + """ + Delete a card from a specified Trello list by name. + """ + list_id = self.get_trello_list_id(list_name) + if list_id is None: + return None + + get_cards_in_list_url = f"{TRELLO_API_BASE}/lists/{list_id}/cards" + params = { + 'key': self.api_key, + 'token': self.token + } + response = requests.get(get_cards_in_list_url, params=params) + + if response.status_code == 200: + cards = response.json() + for card in cards: + if card['name'].lower() == card_name.lower(): + card_id = card['id'] + delete_card_url = f"{TRELLO_API_BASE}/cards/{card_id}" + delete_response = requests.delete(delete_card_url, params=params) + + if delete_response.status_code == 200: + return card_id + else: + raise Exception(f"Failed to delete card: {delete_response.status_code}, {delete_response.text}") + return None + else: + raise Exception(f"Failed to fetch tasks from Trello: {response.status_code}, {response.text}") + + def archive_trello_list(self, list_name: str) -> bool: + """ + Archive a Trello list by its name. + """ + list_id = self.get_trello_list_id(list_name, include_archived=True) + if list_id is None: + return False + + archive_list_url = f"{TRELLO_API_BASE}/lists/{list_id}/closed" + params = { + 'key': self.api_key, + 'token': self.token, + 'value': 'true' + } + response = requests.put(archive_list_url, params=params) + + if response.status_code == 200: + return True + else: + raise Exception(f"Failed to archive the list '{list_name}': {response.status_code}, {response.text}") + + def get_card_id(self, card_name: str, list_name: str) -> Optional[str]: + """ + Get the ID of a card by its name and the name of the list it is in. + """ + list_id = self.get_trello_list_id(list_name) + if list_id is None: + return None + + cards_url = f"{TRELLO_API_BASE}/lists/{list_id}/cards" + params = { + 'key': self.api_key, + 'token': self.token + } + response = requests.get(cards_url, params=params) + + if response.status_code == 200: + cards = response.json() + for card in cards: + if card['name'].lower() == card_name.lower(): + return card['id'] + return None + else: + raise Exception(f"Failed to fetch cards from Trello: {response.status_code}, {response.text}") + + def create_checklist_in_card(self, card_id: str, checklist_name: str) -> dict: + """ + Create a checklist in a specified Trello card. + """ + create_checklist_url = f"{TRELLO_API_BASE}/checklists" + params = { + 'key': self.api_key, + 'token': self.token, + 'idCard': card_id, + 'name': checklist_name + } + response = requests.post(create_checklist_url, params=params) + + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to create checklist: {response.status_code}, {response.text}") + + def get_checklist_id(self, card_id: str, checklist_name: str) -> Optional[str]: + """ + Get the ID of a checklist by its name and the card ID it is in. + """ + card_checklists_url = f"{TRELLO_API_BASE}/cards/{card_id}/checklists" + params = { + 'key': self.api_key, + 'token': self.token + } + response = requests.get(card_checklists_url, params=params) + + if response.status_code == 200: + checklists = response.json() + for checklist in checklists: + if checklist['name'].lower() == checklist_name.lower(): + return checklist['id'] + return None + else: + raise Exception(f"Failed to fetch checklists from Trello: {response.status_code}, {response.text}") + + def add_item_to_checklist(self, checklist_id: str, item_name: str) -> dict: + """ + Add an item to a specified Trello checklist. + """ + add_checkitem_url = f"{TRELLO_API_BASE}/checklists/{checklist_id}/checkItems" + params = { + 'key': self.api_key, + 'token': self.token, + 'name': item_name, + 'checked': 'false' + } + response = requests.post(add_checkitem_url, params=params) + + if response.status_code == 200: + return response.json() + else: + raise Exception(f"Failed to add item to checklist: {response.status_code}, {response.text}") \ No newline at end of file diff --git a/src/trello/trello_bot.py b/src/trello/trello_bot.py new file mode 100644 index 0000000..5c15512 --- /dev/null +++ b/src/trello/trello_bot.py @@ -0,0 +1,19 @@ +# src/trello/trello_bot.py + +from .trello_api import TrelloClient + +# Temporary dictionary to store Trello card to Google Calendar event ID mapping +card_event_map = {} + +class TrelloBot: + def __init__(self): + self.trello_client = TrelloClient() + + # Example method showing how the bot could use the TrelloClient + def add_card_and_map_event(self, card_name: str, list_name: str, event_id: str): + card = self.trello_client.add_card_to_trello(card_name, list_name) + card_id = card.get('id') + if card_id: + card_event_map[card_id] = event_id + return card_id + return None \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..4f54c83 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# tests/__init__.py \ No newline at end of file diff --git a/tests/test_calendar/__init__.py b/tests/test_calendar/__init__.py new file mode 100644 index 0000000..97c5878 --- /dev/null +++ b/tests/test_calendar/__init__.py @@ -0,0 +1 @@ +# tests/test_calendar/__init__.py \ No newline at end of file diff --git a/tests/test_calendar/test_calendar_api.py b/tests/test_calendar/test_calendar_api.py new file mode 100644 index 0000000..f6c93d0 --- /dev/null +++ b/tests/test_calendar/test_calendar_api.py @@ -0,0 +1,80 @@ +# tests/test_calendar/test_calendar_api.py + +import pytest +from unittest.mock import patch, MagicMock +from src.calendar.calendar_api import ( + authenticate_google_calendar, + add_event_to_calendar, + update_calendar_event, + delete_calendar_event, +) + +# Mock the authenticate_google_calendar function to prevent actual authentication +@patch('src.calendar.calendar_api.authenticate_google_calendar') +def test_add_event_to_calendar(mock_authenticate): + # Mock the credentials and service + mock_authenticate.return_value = MagicMock() + with patch('src.calendar.calendar_api.build') as mock_build: + mock_service = MagicMock() + mock_build.return_value = mock_service + mock_events = mock_service.events.return_value + mock_insert = mock_events.insert.return_value + mock_execute = mock_insert.execute.return_value + mock_execute.get.return_value = 'event_id_12345' + mock_execute.__getitem__.return_value = 'event_id_12345' + + # Call the function + event_id = add_event_to_calendar('Test Task', '2023-12-31T23:59:00-05:00') + + # Assertions + mock_build.assert_called_once_with('calendar', 'v3', credentials=mock_authenticate.return_value) + mock_service.events.assert_called_once() + mock_events.insert.assert_called_once() + mock_insert.execute.assert_called_once() + assert event_id == 'event_id_12345' + +@patch('src.calendar.calendar_api.authenticate_google_calendar') +def test_update_calendar_event(mock_authenticate): + mock_authenticate.return_value = MagicMock() + with patch('src.calendar.calendar_api.build') as mock_build: + mock_service = MagicMock() + mock_build.return_value = mock_service + mock_events = mock_service.events.return_value + mock_get = mock_events.get.return_value + mock_get.execute.return_value = { + 'id': 'event_id_12345', + 'summary': 'Old Task', + 'start': {'dateTime': '2023-12-31T23:59:00-05:00'}, + 'end': {'dateTime': '2023-12-31T23:59:00-05:00'}, + } + mock_update = mock_events.update.return_value + mock_update.execute.return_value = {'updated': True} + + # Call the function + updated_event = update_calendar_event('event_id_12345', 'Updated Task', '2024-01-01T00:00:00-05:00') + + # Assertions + mock_build.assert_called_once_with('calendar', 'v3', credentials=mock_authenticate.return_value) + mock_events.get.assert_called_once_with(calendarId='primary', eventId='event_id_12345') + mock_get.execute.assert_called_once() + mock_events.update.assert_called_once() + mock_update.execute.assert_called_once() + assert updated_event == {'updated': True} + +@patch('src.calendar.calendar_api.authenticate_google_calendar') +def test_delete_calendar_event(mock_authenticate): + mock_authenticate.return_value = MagicMock() + with patch('src.calendar.calendar_api.build') as mock_build: + mock_service = MagicMock() + mock_build.return_value = mock_service + mock_events = mock_service.events.return_value + mock_delete = mock_events.delete.return_value + mock_delete.execute.return_value = None + + # Call the function + delete_calendar_event('event_id_12345') + + # Assertions + mock_build.assert_called_once_with('calendar', 'v3', credentials=mock_authenticate.return_value) + mock_events.delete.assert_called_once_with(calendarId='primary', eventId='event_id_12345') + mock_delete.execute.assert_called_once() \ No newline at end of file diff --git a/tests/test_calendar/test_calendar_sync.py b/tests/test_calendar/test_calendar_sync.py new file mode 100644 index 0000000..da43493 --- /dev/null +++ b/tests/test_calendar/test_calendar_sync.py @@ -0,0 +1,30 @@ +# tests/test_calendar/test_calendar_sync.py + +import pytest +from unittest.mock import patch +from src.calendar.calendar_sync import ( + add_task_to_calendar, + update_task_in_calendar, + delete_task_from_calendar, +) + +@patch('src.calendar.calendar_sync.add_event_to_calendar') +def test_add_task_to_calendar(mock_add_event): + mock_add_event.return_value = 'event_id_12345' + event_id = add_task_to_calendar('Test Task', '2023-12-31T23:59:00-05:00') + mock_add_event.assert_called_once_with('Test Task', '2023-12-31T23:59:00-05:00') + assert event_id == 'event_id_12345' + +def test_add_task_to_calendar_no_due_date(): + event_id = add_task_to_calendar('Test Task', None) + assert event_id is None + +@patch('src.calendar.calendar_sync.update_calendar_event') +def test_update_task_in_calendar(mock_update_event): + update_task_in_calendar('event_id_12345', 'Updated Task', '2024-01-01T00:00:00-05:00') + mock_update_event.assert_called_once_with('event_id_12345', 'Updated Task', '2024-01-01T00:00:00-05:00') + +@patch('src.calendar.calendar_sync.delete_calendar_event') +def test_delete_task_from_calendar(mock_delete_event): + delete_task_from_calendar('event_id_12345') + mock_delete_event.assert_called_once_with('event_id_12345') \ No newline at end of file diff --git a/tests/test_gmail/__init__.py b/tests/test_gmail/__init__.py new file mode 100644 index 0000000..f12c54e --- /dev/null +++ b/tests/test_gmail/__init__.py @@ -0,0 +1 @@ +# tests/test_gmail/__init__.py \ No newline at end of file diff --git a/tests/test_gmail/test_gmail_api.py b/tests/test_gmail/test_gmail_api.py new file mode 100644 index 0000000..9e6dafc --- /dev/null +++ b/tests/test_gmail/test_gmail_api.py @@ -0,0 +1,52 @@ +# tests/test_gmail/test_gmail_api.py + +import pytest +from unittest.mock import patch, MagicMock +from src.gmail.gmail_api import authenticate_gmail, fetch_unread_emails + +@patch('src.gmail.gmail_api.build') +def test_fetch_unread_emails_no_emails(mock_build): + # Mock the service + mock_service = MagicMock() + mock_users = mock_service.users.return_value + mock_messages = mock_users.messages.return_value + mock_list = mock_messages.list.return_value + mock_list.execute.return_value = {'messages': []} + mock_build.return_value = mock_service + + emails = fetch_unread_emails(mock_service) + assert emails == [] + +@patch('src.gmail.gmail_api.build') +def test_fetch_unread_emails_with_emails(mock_build): + mock_service = MagicMock() + mock_users = mock_service.users.return_value + mock_messages = mock_users.messages.return_value + + # Mock list + mock_list = mock_messages.list.return_value + mock_list.execute.return_value = { + 'messages': [{'id': 'message_id_1'}, {'id': 'message_id_2'}] + } + + # Mock get + def mock_get(userId, id): + get_result = MagicMock() + get_result.execute.return_value = { + 'id': id, + 'snippet': 'Email snippet', + 'payload': { + 'headers': [ + {'name': 'Subject', 'value': f'Subject {id}'} + ] + } + } + return get_result + + mock_messages.get.side_effect = mock_get + mock_build.return_value = mock_service + + emails = fetch_unread_emails(mock_service, num_emails=2) + assert len(emails) == 2 + assert emails[0]['subject'] == 'Subject message_id_1' + assert emails[1]['subject'] == 'Subject message_id_2' \ No newline at end of file diff --git a/tests/test_gmail/test_gmail_bot.py b/tests/test_gmail/test_gmail_bot.py new file mode 100644 index 0000000..d42a797 --- /dev/null +++ b/tests/test_gmail/test_gmail_bot.py @@ -0,0 +1,33 @@ +# tests/test_gmail/test_gmail_bot.py + +import pytest +from unittest.mock import patch, MagicMock +from src.gmail.gmail_bot import ( + post_emails_to_slack, + fetch_and_post_emails, +) +import os + +@patch('src.gmail.gmail_bot.authenticate_gmail') +@patch('src.gmail.gmail_bot.build') +@patch('src.gmail.gmail_bot.fetch_unread_emails') +@patch('src.gmail.gmail_bot.slack_client') +def test_fetch_and_post_emails(mock_slack_client, mock_fetch_emails, mock_build, mock_authenticate_gmail): + # Set up the mocks + mock_service = MagicMock() + mock_build.return_value = mock_service + mock_authenticate_gmail.return_value = MagicMock() + mock_fetch_emails.return_value = [ + {'subject': 'Test Subject', 'snippet': 'Test Snippet'} + ] + mock_slack_client.chat_postMessage.return_value = None + + # Call the function under test + fetch_and_post_emails(num_emails=1) + + # Assertions + mock_fetch_emails.assert_called_once_with( + mock_service, + num_emails=1 + ) + mock_slack_client.chat_postMessage.assert_called_once() \ No newline at end of file diff --git a/tests/test_samples.py b/tests/test_samples.py new file mode 100644 index 0000000..26c4649 --- /dev/null +++ b/tests/test_samples.py @@ -0,0 +1,30 @@ +import pytest + +def test_addition(): + assert 2 + 2 == 4 + +def test_subtraction(): + assert 5 - 3 == 2 + +def test_multiplication(): + assert 3 * 3 == 9 + +def test_division(): + assert 8 / 2 == 4 + +def test_string_equality(): + assert "hello".upper() == "HELLO" + +def test_list_append(): + lst = [1, 2, 3] + lst.append(4) + assert lst == [1, 2, 3, 4] + +def test_dictionary_key(): + d = {"name": "Alice", "age": 30} + assert "name" in d + assert d["name"] == "Alice" + +def test_raise_error(): + with pytest.raises(ZeroDivisionError): + 1 / 0 diff --git a/tests/test_slack/__init__.py b/tests/test_slack/__init__.py new file mode 100644 index 0000000..ea37134 --- /dev/null +++ b/tests/test_slack/__init__.py @@ -0,0 +1 @@ +# tests/test_slack/__init__.py \ No newline at end of file diff --git a/tests/test_slack/test_slack_api.py b/tests/test_slack/test_slack_api.py new file mode 100644 index 0000000..3e176cb --- /dev/null +++ b/tests/test_slack/test_slack_api.py @@ -0,0 +1,14 @@ +# tests/test_slack/test_slack_api.py + +import pytest +from unittest.mock import patch +from src.slack.slack_api import post_to_slack + +@patch('src.slack.slack_api.slack_client') +def test_post_to_slack(mock_slack_client): + channel = '#test-channel' + message = 'Test Message' + + post_to_slack(channel, message) + + mock_slack_client.chat_postMessage.assert_called_once_with(channel=channel, text=message) \ No newline at end of file diff --git a/tests/test_slack/test_slack_bot.py b/tests/test_slack/test_slack_bot.py new file mode 100644 index 0000000..233f0e8 --- /dev/null +++ b/tests/test_slack/test_slack_bot.py @@ -0,0 +1,85 @@ +# tests/test_slack/test_slack_bot.py + +import pytest +from unittest.mock import patch, MagicMock +from slack_sdk.web.client import WebClient + +from src.slack.slack_bot import ( + add_to_trello_task, + remove_from_trello_task, + delete_trello_list, + show_tasks_in_list, + handle_fetch_emails, + handle_show_emails, +) + +# Helper function to simulate messages +def create_message_event(text): + return {'channel': 'C12345', 'user': 'U12345', 'text': text} + +def create_context(matches): + return {'matches': matches} + +# Mock the 'say' function +@pytest.fixture +def say(): + return MagicMock() + + +@patch('src.slack.slack_bot.trello_client.add_card_to_trello') +@patch('src.slack.slack_bot.post_to_slack') +def test_add_to_trello_task(mock_post_to_slack, mock_add_card, say): + mock_add_card.return_value = {'id': 'card_id_12345'} + message = create_message_event('add "Test Task" to TestList by 2023-12-31 23:59') + context = create_context(['Test Task', 'TestList', '2023-12-31 23:59']) + add_to_trello_task(message=message, say=say, context=context) + mock_add_card.assert_called_once() + mock_post_to_slack.assert_called_once() + + +@patch('src.slack.slack_bot.trello_client.delete_card_from_trello') +@patch('src.slack.slack_bot.post_to_slack') +def test_remove_from_trello_task(mock_post_to_slack, mock_delete_card, say): + mock_delete_card.return_value = 'card_id_12345' + message = create_message_event('remove "Test Task" from TestList') + context = create_context(['Test Task', 'TestList']) + remove_from_trello_task(message=message, say=say, context=context) + mock_delete_card.assert_called_once() + mock_post_to_slack.assert_called_once() + + +@patch('src.slack.slack_bot.trello_client.archive_trello_list') +@patch('src.slack.slack_bot.post_to_slack') +def test_delete_trello_list(mock_post_to_slack, mock_archive_list, say): + mock_archive_list.return_value = True + message = create_message_event('delete list TestList') + context = create_context(['TestList']) + delete_trello_list(message=message, say=say, context=context) + mock_archive_list.assert_called_once() + mock_post_to_slack.assert_called_once() + + +@patch('src.slack.slack_bot.trello_client.get_trello_cards') +@patch('src.slack.slack_bot.post_to_slack') +def test_show_tasks_in_list(mock_post_to_slack, mock_get_cards, say): + mock_get_cards.return_value = [{'name': 'Task 1'}, {'name': 'Task 2'}] + message = create_message_event('show me tasks in TestList') + context = create_context(['TestList']) + show_tasks_in_list(message=message, say=say, context=context) + mock_get_cards.assert_called_once() + mock_post_to_slack.assert_called_once() + + +@patch('src.slack.slack_bot.fetch_and_post_emails') +def test_handle_fetch_emails(mock_fetch_emails, say): + message = create_message_event('fetch emails') + handle_fetch_emails(message=message, say=say) + mock_fetch_emails.assert_called_once() + + +@patch('src.slack.slack_bot.fetch_and_post_emails') +def test_handle_show_emails(mock_fetch_emails, say): + message = create_message_event('show me 3 emails') + context = create_context(['3']) + handle_show_emails(message=message, say=say, context=context) + mock_fetch_emails.assert_called_once_with(3) \ No newline at end of file diff --git a/tests/test_trello/__init__.py b/tests/test_trello/__init__.py new file mode 100644 index 0000000..f2db9c5 --- /dev/null +++ b/tests/test_trello/__init__.py @@ -0,0 +1 @@ +# tests/test_trello/__init__.py \ No newline at end of file diff --git a/tests/test_trello/test_trello_api.py b/tests/test_trello/test_trello_api.py new file mode 100644 index 0000000..f1c323b --- /dev/null +++ b/tests/test_trello/test_trello_api.py @@ -0,0 +1,103 @@ +# tests/test_trello/test_trello_api.py + +import pytest +from unittest.mock import patch, MagicMock +from src.trello.trello_api import TrelloClient, TRELLO_API_BASE + +@pytest.fixture +def trello_client(): + return TrelloClient(api_key='test_key', token='test_token', board_id='test_board_id') + +@patch('src.trello.trello_api.requests.get') +def test_get_trello_list_id_existing(mock_get, trello_client): + mock_get.return_value = MagicMock(status_code=200) + mock_get.return_value.json.return_value = [{'id': 'list_id_12345', 'name': 'TestList'}] + list_id = trello_client.get_trello_list_id('TestList') + assert list_id == 'list_id_12345' + mock_get.assert_called_once_with( + f"{TRELLO_API_BASE}/boards/test_board_id/lists", + params={'key': 'test_key', 'token': 'test_token', 'filter': 'open'} + ) + +@patch('src.trello.trello_api.requests.get') +def test_get_trello_list_id_non_existing(mock_get, trello_client): + mock_get.return_value = MagicMock(status_code=200) + mock_get.return_value.json.return_value = [] + list_id = trello_client.get_trello_list_id('NonExistingList') + assert list_id is None + mock_get.assert_called_once() + +@patch('src.trello.trello_api.requests.post') +def test_create_trello_list(mock_post, trello_client): + mock_post.return_value = MagicMock(status_code=200) + mock_post.return_value.json.return_value = {'id': 'new_list_id_67890'} + list_id = trello_client.create_trello_list('NewList') + assert list_id == 'new_list_id_67890' + mock_post.assert_called_once_with( + f"{TRELLO_API_BASE}/boards/test_board_id/lists", + params={'key': 'test_key', 'token': 'test_token', 'name': 'NewList', 'pos': 'bottom'} + ) + +@patch('src.trello.trello_api.TrelloClient.get_trello_list_id') +@patch('src.trello.trello_api.requests.post') +def test_add_card_to_trello_existing_list(mock_post, mock_get_trello_list_id, trello_client): + mock_get_trello_list_id.return_value = 'list_id_12345' + mock_post.return_value = MagicMock(status_code=200) + mock_post.return_value.json.return_value = {'id': 'card_id_12345'} + + card = trello_client.add_card_to_trello('Test Card', 'TestList') + assert card['id'] == 'card_id_12345' + mock_get_trello_list_id.assert_called_once_with('TestList') + mock_post.assert_called_once_with( + f"{TRELLO_API_BASE}/cards", + params={'key': 'test_key', 'token': 'test_token', 'idList': 'list_id_12345', 'name': 'Test Card', 'due': None} + ) + +@patch('src.trello.trello_api.TrelloClient.get_trello_list_id') +@patch('src.trello.trello_api.TrelloClient.create_trello_list') +@patch('src.trello.trello_api.requests.post') +def test_add_card_to_trello_new_list(mock_post, mock_create_list, mock_get_list_id, trello_client): + mock_get_list_id.return_value = None + mock_create_list.return_value = 'new_list_id_67890' + mock_post.return_value = MagicMock(status_code=200) + mock_post.return_value.json.return_value = {'id': 'card_id_abcde'} + + card = trello_client.add_card_to_trello('Another Card', 'AnotherList') + assert card['id'] == 'card_id_abcde' + mock_get_list_id.assert_called_once_with('AnotherList') + mock_create_list.assert_called_once_with('AnotherList') + mock_post.assert_called_once_with( + f"{TRELLO_API_BASE}/cards", + params={'key': 'test_key', 'token': 'test_token', 'idList': 'new_list_id_67890', 'name': 'Another Card', 'due': None} + ) + +@patch('src.trello.trello_api.TrelloClient.get_trello_list_id') +@patch('src.trello.trello_api.requests.get') +def test_get_trello_cards(mock_get, mock_get_list_id, trello_client): + mock_get_list_id.return_value = 'list_id_12345' + mock_get.return_value = MagicMock(status_code=200) + mock_get.return_value.json.return_value = [{'name': 'Task 1'}, {'name': 'Task 2'}] + cards = trello_client.get_trello_cards('TestList') + assert len(cards) == 2 + mock_get.assert_called_once() + +@patch('src.trello.trello_api.TrelloClient.get_trello_list_id') +@patch('src.trello.trello_api.requests.get') +@patch('src.trello.trello_api.requests.delete') +def test_delete_card_from_trello(mock_delete, mock_get, mock_get_list_id, trello_client): + mock_get_list_id.return_value = 'list_id_12345' + mock_get.return_value = MagicMock(status_code=200) + mock_get.return_value.json.return_value = [{'id': 'card_id_12345', 'name': 'Test Card'}] + mock_delete.return_value = MagicMock(status_code=200) + card_id = trello_client.delete_card_from_trello('Test Card', 'TestList') + assert card_id == 'card_id_12345' + mock_delete.assert_called_once() + +@patch('src.trello.trello_api.TrelloClient.get_trello_list_id') +@patch('src.trello.trello_api.requests.put') +def test_archive_trello_list(mock_put, mock_get_list_id, trello_client): + mock_get_list_id.return_value = 'list_id_12345' + mock_put.return_value = MagicMock(status_code=200) + success = trello_client.archive_trello_list('TestList') + assert success is True + mock_put.assert_called_once() \ No newline at end of file diff --git a/tests/test_trello/test_trello_bot.py b/tests/test_trello/test_trello_bot.py new file mode 100644 index 0000000..6a265f6 --- /dev/null +++ b/tests/test_trello/test_trello_bot.py @@ -0,0 +1,26 @@ +# tests/test_trello/test_trello_bot.py + +import pytest +from unittest.mock import patch, MagicMock +from src.trello.trello_bot import TrelloBot, card_event_map + +@pytest.fixture +def trello_bot(): + return TrelloBot() + +@patch('src.trello.trello_bot.TrelloClient') +def test_add_card_and_map_event(mock_trello_client_class): + # Create a mock TrelloClient instance + mock_trello_client = MagicMock() + mock_trello_client.add_card_to_trello.return_value = {'id': 'card_id_999'} + + # When TrelloBot is instantiated, it creates a TrelloClient. Return the mock instead. + mock_trello_client_class.return_value = mock_trello_client + + bot = TrelloBot() + event_id = 'event_12345' + card_id = bot.add_card_and_map_event('Test Card', 'TestList', event_id) + + assert card_id == 'card_id_999' + assert card_event_map[card_id] == event_id + mock_trello_client.add_card_to_trello.assert_called_once_with('Test Card', 'TestList') \ No newline at end of file diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bc38629 --- /dev/null +++ b/uv.lock @@ -0,0 +1,136 @@ +version = 1 +requires-python = ">=3.12" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "maigic" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[[package]] +name = "mypy" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, + { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, + { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, + { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, + { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, + { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, + { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, + { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, + { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, + { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "packaging" +version = "24.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pytest" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, +] + +[[package]] +name = "ruff" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/21/5c6e05e0fd3fbb41be4fb92edbc9a04de70baf60adb61435ce0c6b8c3d55/ruff-0.7.1.tar.gz", hash = "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4", size = 3181670 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/45/8a20a9920175c9c4892b2420f80ff3cf14949cf3067118e212f9acd9c908/ruff-0.7.1-py3-none-linux_armv6l.whl", hash = "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89", size = 10389268 }, + { url = "https://files.pythonhosted.org/packages/1b/d3/2f8382db2cf4f9488e938602e33e36287f9d26cb283aa31f11c31297ce79/ruff-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35", size = 10188348 }, + { url = "https://files.pythonhosted.org/packages/a2/31/7d14e2a88da351200f844b7be889a0845d9e797162cf76b136d21b832a23/ruff-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99", size = 9841448 }, + { url = "https://files.pythonhosted.org/packages/db/99/738cafdc768eceeca0bd26c6f03e213aa91203d2278e1d95b1c31c4ece41/ruff-0.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca", size = 10674864 }, + { url = "https://files.pythonhosted.org/packages/fe/12/bcf2836b50eab53c65008383e7d55201e490d75167c474f14a16e1af47d2/ruff-0.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250", size = 10192105 }, + { url = "https://files.pythonhosted.org/packages/2b/71/261d5d668bf98b6c44e89bfb5dfa4cb8cb6c8b490a201a3d8030e136ea4f/ruff-0.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c", size = 11194144 }, + { url = "https://files.pythonhosted.org/packages/90/1f/0926d18a3b566fa6e7b3b36093088e4ffef6b6ba4ea85a462d9a93f7e35c/ruff-0.7.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565", size = 11917066 }, + { url = "https://files.pythonhosted.org/packages/cd/a8/9fac41f128b6a44ab4409c1493430b4ee4b11521e8aeeca19bfe1ce851f9/ruff-0.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7", size = 11458821 }, + { url = "https://files.pythonhosted.org/packages/25/cd/59644168f086ab13fe4e02943b9489a0aa710171f66b178e179df5383554/ruff-0.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a", size = 12700379 }, + { url = "https://files.pythonhosted.org/packages/fb/30/3bac63619eb97174661829c07fc46b2055a053dee72da29d7c304c1cd2c0/ruff-0.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad", size = 11019813 }, + { url = "https://files.pythonhosted.org/packages/4b/af/f567b885b5cb3bcdbcca3458ebf210cc8c9c7a9f61c332d3c2a050c3b21e/ruff-0.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112", size = 10662146 }, + { url = "https://files.pythonhosted.org/packages/bc/ad/eb930d3ad117a9f2f7261969c21559ebd82bb13b6e8001c7caed0d44be5f/ruff-0.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378", size = 10256911 }, + { url = "https://files.pythonhosted.org/packages/20/d5/af292ce70a016fcec792105ca67f768b403dd480a11888bc1f418fed0dd5/ruff-0.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8", size = 10767488 }, + { url = "https://files.pythonhosted.org/packages/24/85/cc04a3bd027f433bebd2a097e63b3167653c079f7f13d8f9a1178e693412/ruff-0.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd", size = 11093368 }, + { url = "https://files.pythonhosted.org/packages/0b/fb/c39cbf32d1f3e318674b8622f989417231794926b573f76dd4d0ca49f0f1/ruff-0.7.1-py3-none-win32.whl", hash = "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9", size = 8594180 }, + { url = "https://files.pythonhosted.org/packages/5a/71/ec8cdea34ecb90c830ca60d54ac7b509a7b5eab50fae27e001d4470fe813/ruff-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307", size = 9419751 }, + { url = "https://files.pythonhosted.org/packages/79/7b/884553415e9f0a9bf358ed52fb68b934e67ef6c5a62397ace924a1afdf9a/ruff-0.7.1-py3-none-win_arm64.whl", hash = "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37", size = 8717402 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +]