From af234169338bc424470c14b979f399a3eb137601 Mon Sep 17 00:00:00 2001 From: Frankbz Date: Thu, 31 Oct 2024 22:10:21 -0400 Subject: [PATCH] feat: Add Gmail Module to communicate with Gmail. --- .circleci/config.yml | 70 +++++++++ .gitignore | 178 +++++++++++++++++++++- .python-version | 1 - LICENSE | 31 ++++ README.md | 58 ++++++++ assistant.py | 203 -------------------------- config/.env | 14 ++ pyproject.toml | 44 +++++- server.py | 175 ---------------------- slack.py | 23 --- src/__init__.py | 1 + src/db/__init__.py | 0 src/db/models.py | 39 ----- src/gmail/API.py | 34 +++++ src/gmail/client.py | 105 ++++++++++++- src/gmail/models.py | 2 +- src/gpt/__init__.py | 0 src/gpt/gpt.py | 28 ---- src/maigic_nyu/__init__.py | 1 + src/maigic_nyu/demo_api/__init__.py | 1 + src/maigic_nyu/demo_api/_demo_math.py | 11 ++ src/maigic_nyu/demo_api/api.py | 4 + src/maigic_nyu/gmail/README.md | 67 +++++++++ src/maigic_nyu/gmail/__init__.py | 1 + src/maigic_nyu/gmail/_client.py | 54 +++++++ src/maigic_nyu/gmail/_models.py | 122 ++++++++++++++++ src/maigic_nyu/gmail/api.py | 6 + src/slack/__init__.py | 0 src/slack/slack.py | 17 --- tests/__init__.py | 1 + tests/test_demo_api/__init__.py | 1 + tests/test_demo_api/test_api.py | 23 +++ tests/test_gmail.py | 134 +++++++++++++++++ tests/test_gmail_api.py | 35 +++++ uv.lock | 49 +++++++ 35 files changed, 1032 insertions(+), 501 deletions(-) create mode 100644 .circleci/config.yml delete mode 100644 .python-version create mode 100644 LICENSE delete mode 100644 assistant.py create mode 100644 config/.env delete mode 100644 server.py delete mode 100644 slack.py delete mode 100644 src/db/__init__.py delete mode 100644 src/db/models.py create mode 100644 src/gmail/API.py delete mode 100644 src/gpt/__init__.py delete mode 100644 src/gpt/gpt.py create mode 100644 src/maigic_nyu/__init__.py create mode 100644 src/maigic_nyu/demo_api/__init__.py create mode 100644 src/maigic_nyu/demo_api/_demo_math.py create mode 100644 src/maigic_nyu/demo_api/api.py create mode 100644 src/maigic_nyu/gmail/README.md create mode 100644 src/maigic_nyu/gmail/__init__.py create mode 100644 src/maigic_nyu/gmail/_client.py create mode 100644 src/maigic_nyu/gmail/_models.py create mode 100644 src/maigic_nyu/gmail/api.py delete mode 100644 src/slack/__init__.py delete mode 100644 src/slack/slack.py create mode 100644 tests/__init__.py create mode 100644 tests/test_demo_api/__init__.py create mode 100644 tests/test_demo_api/test_api.py create mode 100644 tests/test_gmail.py create mode 100644 tests/test_gmail_api.py diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..cde2f93 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,70 @@ +version: 2.1 + +executors: + my-docker-executor: + docker: + - image: cimg/python:3.12 + +jobs: + build: + executor: my-docker-executor + steps: + - checkout + + - run: + name: Get uv via curl and install it + command: | + curl -LsSf https://astral.sh/uv/install.sh | sh + + - persist_to_workspace: + root: /home/circleci/ + paths: + - .local + + test: + executor: my-docker-executor + steps: + - checkout + + - attach_workspace: + at: /home/circleci/ + + - run: + name: Set-up dependencies path + command: echo 'export PATH=$HOME/.local/bin:$PATH' >> $BASH_ENV + + - run: + name: Set-up project dependencies + command: uv sync --extra dev + + - run: + name: Create test results directory + command: mkdir -p test-results + + - run: + name: Static Type Checking with mypy + command: uv run mypy . + + - run: + name: Linting checks with Ruff + # ignore = ["S101", "D203", "D213", "COM812", "ISC001"] + command: uv run ruff check . + + - run: + name: Run unit tests with coverage + command: uv run pytest tests/ --junitxml=test-results/coverage.xml + + - store_test_results: + path: test-results + + - store_artifacts: + path: test-results/coverage.xml + destination: coverage-report + +workflows: + build_and_test: + jobs: + - build + - test: + requires: + - build diff --git a/.gitignore b/.gitignore index 9291fde..65de103 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +# from conv2 branch /*.json /email_examples __pycache__ @@ -5,4 +6,179 @@ __pycache__ /secrets.sh /secret.sh /secrets -/credentials.json \ No newline at end of file +/credentials.json + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# 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/ + +**/**cache** + +**/.python-version + +**/uv.lock + +**/py.typed + +**/**.xml + +**/test-results \ No newline at end of file diff --git a/.python-version b/.python-version deleted file mode 100644 index e4fba21..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..482512d --- /dev/null +++ b/LICENSE @@ -0,0 +1,31 @@ +MIT License + +Copyright (c) 2024 NYU-OSS +Contributors: + +- Kamen Yotov +- Satyam Chatrola +- Frank Zhao +- Sidhved Warik +- Yaxin Ke +- Eli Edme +- Abdullah Suri +- Mihir Lovekar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c8de4d7..8c7d230 100644 --- a/README.md +++ b/README.md @@ -1 +1,59 @@ +# mAIgic + +This project will organize life by tracking messages and information that need follow-ups and reminding the user to take follow-ups. It will also provide a search based on individuals. + +The project aims to leverage AI to track and remind for follow up messages for systems like Slack, Trello, Whatsapp, Gmail, Google Docs comments, etc. + +# Technical tools + +- Programming Language: Python +- Project dependency management tool: uv +- Project linter: ruff +- Project tester: pytest +- Continuous Integration (CI) tool: circleCI + +# Instructions + Collaboration doc for this initiative: https://docs.google.com/document/d/1duhM5Ufkq_doBnMvHo65S7cplmUJw4KqQvDRfZlmj7s/edit + + +## Setup + +- Install `uv` on your system with command with `curl -LsSf https://astral.sh/uv/install.sh | sh`. +- `cd` into the cloned repository and execute `uv sync --extra dev` (This will install all the project and developer dependencies). +- Activate the environment `venv` by executing `source ./.venv/bin/activate`. + +## Processes + +- To check the formatting standard with ruff (linter) execute either `uv run ruff check .` or `ruff check .` from the root directory of the project. +- To test the code, execute either `uv run pytest .` or `pytest .` from the root directory of the project. + +Note: One can run tools like ruff and pytest independently or can run them through `uv`. + +## Commit Style Guide + +- Request to `strictly adhere` to the suggested commit style guide. +- The project follows Udacity's [Commit Style Guide](https://udacity.github.io/git-styleguide/). +- Reason: + - It is simple, elegant, concise and effective. + - It does not have many rules that could create confusion but yet have just enough to keep things working smoothly through clear and concise communication. + +## GitHub Workflow + +- Members of same team can preferably `clone` the repository. +- Make sure to push new changes to `dev` remote branch. +- Create a `Pull Request` and the changes would be reviewed and merged to the `main` remote branch. `Review` includes code, code quality, code standards, commit style guides, and Pull Request descriptions. Consistent code standards and documentation would be aided by `ruff`. +- `main` branch serves as production branch which would accumulate new changes, features and bug-fixes from `dev` branch. +- Would appreciate if you open `issues` whenever you come across any. Issues can be bugs, proposed features, performance / code improvement proposals, etc. + +## Requesting access to project and CI space + +- Send your`github username` to become collaborators to the project. +- Send your `email id` used to `register with circleCI` to get access to the circleCI organization to manage CI workflows and triggers. You will receive an invitation in the provided email's inbox to join the circleCI organization. +- Currently, the `magic2` CircleCI project is attached to this project. + +Note: Request to keep all `communication` in the Google Chats Project Group. + +# License + +mAIgic has a MIT-style license, as found in the [LICENSE](LICENSE) file. diff --git a/assistant.py b/assistant.py deleted file mode 100644 index b0dc374..0000000 --- a/assistant.py +++ /dev/null @@ -1,203 +0,0 @@ -import json -from pprint import pformat -from typing import Any -from src.db.models import DB, Chat, Item, ItemType -from src.gmail.client import Gmail -from src.gmail.models import GMailMessage -import logging - -from src.gpt.gpt import GPT, Line, Role -from pydantic import BaseModel - -LOGGER = logging.getLogger(__file__) - - -GMAIL_PRIMER = Line( - role=Role.SYSTEM, - # FIXME: this content needs to be tailored to the custome - content=""" - I am Kamen Yotov. - My emails are kamen@yotov.org, kyotov@gmail.com, ky12@nyu.edu. - - When I first receive an email, I want you to analyze it respond with the analysis in json as follows: - - * "author" - * "time_received" in iso format - * "categories" - ** "urgent": bool - ** "important": bool - ** "spam": bool - * "summary" : summary of the content - * "action": proposed next action - - Then answer my follow up questions in markdown supported by slack api. - """, -) - - -class ItemKey(BaseModel): - type: ItemType - id: str - - @classmethod - def from_item(cls, item: Item) -> "ItemKey": - return ItemKey(type=item.type, id=item.id) - - -class Conversation: - def __init__(self, db: DB, gpt: GPT, item_key: ItemKey) -> None: - self.db = db - self.gpt = gpt - self.item_key = item_key - - self.item = ( - db.session.query(Item) - .where((Item.type == item_key.type) & (Item.id == item_key.id)) - .one() - ) - - self.thread = list( - db.session.query(Chat) - .where((Chat.type == item_key.type) & (Chat.id == item_key.id)) - .order_by(Chat.seq) - .all() - ) - - def primer_lines(self) -> list[Line]: - return [ - GMAIL_PRIMER, - Line(role=Role.USER, content=self.item.content), - ] - - def handle_user_reply(self, message: str = "") -> str: - lines = self.primer_lines() + [ - Line(role=x.role, content=x.content) for x in self.thread - ] - - if len(lines) == 2: # this conversation has not started yet! - assert message == "" # there is no message by user yet! - else: - assert len(lines) >= 3, lines # there should be at least 1 reply ... - assert lines[-1].role == Role.ASSISTANT # ... by the assistant - - assert message - lines.append(Line(role=Role.USER, content=message)) - - r = self.gpt.complete(lines) - - # TODO: this is where we need to differentiate if it is a tool/function call... - # for now assert it is not! - assert len(r.choices) == 1 - choice = r.choices[0] - assert choice.finish_reason == "stop" - result = choice.message.content - assert result - - self.db.session.add( - Chat( - type=self.item.type, - id=self.item.id, - role=Role.USER, - content=message, - ) - ) - self.db.session.add( - Chat( - type=self.item.type, - id=self.item.id, - role=Role.ASSISTANT, - content=result, - ) - ) - self.db.session.commit() - - return result - - -class Assistant: - def __init__(self) -> None: - # self.db = DB() - # self.gpt = GPT() - pass - - def fetch_emails(self) -> int: - try: - gmail = Gmail() - db = DB() - new_count = 0 - # TODO(kamen): figure out how to query for new emails only? - # maybe based on date of the last email we know about? - # just ask for emails that are newer than that date? - for message in gmail.query(): - if ( - found := db.session.query(Item) - .where((Item.type == ItemType.gmail) & (Item.id == message["id"])) - .first() - ): - LOGGER.debug("skipping %s already in database", found.id) - else: - mm = gmail.get_message(message["id"]) - m = GMailMessage(mm) - db.session.add( - Item(type=ItemType.gmail, id=mm["id"], content=m.as_md()) - ) - db.session.commit() - LOGGER.debug("added %s to database", mm["id"]) - new_count += 1 - return new_count - except Exception: - LOGGER.exception("fetch_emails failed") - raise - - def handle_one(self) -> dict[str, Any]: - try: - db = DB() - gpt = GPT() - - if ( - new_item := db.session.query(Item) - .where(Item.slack_thread.is_(None)) - .first() - ): - convo = Conversation(db, gpt, ItemKey.from_item(new_item)) - - LOGGER.info("handling %s", new_item.id) - - result = convo.handle_user_reply() - result = result.removeprefix("```json").removesuffix("```") - result = json.loads(result) - result["id"] = new_item.id - - LOGGER.info(pformat(result)) - return result - except: - LOGGER.exception("") - raise - - def handle_reply(self, thread_ts: str, reply: str) -> str: - try: - db = DB() - gpt = GPT() - - if ( - item := db.session.query(Item) - .where(Item.slack_thread == thread_ts) - .first() - ): - convo = Conversation(db, gpt, ItemKey.from_item(item)) - - LOGGER.info("handling reply on %s for %s", thread_ts, item.id) - result = convo.handle_user_reply(reply) - LOGGER.info(pformat(result)) - return result - else: - raise RuntimeError(f"item not found for {thread_ts}") - except: - LOGGER.exception("") - - -if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG) - a = Assistant() - a.fetch_emails() - a.handle_one() diff --git a/config/.env b/config/.env new file mode 100644 index 0000000..046736b --- /dev/null +++ b/config/.env @@ -0,0 +1,14 @@ +context = 'I am Satyam Chatrola. + My personal email id is satyamchatrola14@gmail.com. + + When I first receive an email, I want you to analyze it respond with the analysis in json as follows: + + * "author" + * "time_received" in iso format + ** "urgent": bool + ** "important": bool + ** "spam": bool + * "summary" : summary of the content + * "action": proposed next action + + Then answer my follow up questions in markdown supported by slack api.' \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 71a0d47..68adb7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "maigic" +name = "maigic-nyu" version = "0.1.0" description = "Add your description here" readme = "README.md" @@ -10,22 +10,50 @@ dependencies = [ "google-auth-httplib2>=0.2.0", "google-auth-oauthlib>=1.2.1", "google-apps-chat>=0.1.11", - "openai>=1.50.2", "pydantic>=2.9.2", "html2text>=2024.2.26", "slack-sdk>=3.33.1", "sqlalchemy>=2.0.35", "slack-bolt>=1.20.1", - "trio>=0.26.2", "trio-asyncio>=0.15.0", "aiohttp>=3.10.8", "asyncio>=3.4.3", "humanreadable>=0.4.0", + "pyyaml>=6.0.2", + "google-auth-stubs>=0.3.0", + "trio>=0.27.0", ] -[tool.uv] -dev-dependencies = [ - "black>=24.8.0", - "mypy>=1.11.2", - "ruff>=0.6.8", +[project.optional-dependencies] +dev = [ + "mypy>=1.13.0", + "pytest>=8.3.3", + "ruff>=0.7.3", ] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/maigic_nyu"] + +[tool.setuptools.package-data] +maigic_nyu = ["py.typed"] + +[tool.mypy] +ignore_missing_imports = true +mypy_path = "src" +explicit_package_bases = true + +[tool.ruff.lint] +select = ["ALL"] +ignore = ["S101", "D203", "D213", "COM812", "ISC001", "PLC0414", "T203", "TD002", "TD003", "FIX002", "ANN401", "RET504"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --strict-markers" diff --git a/server.py b/server.py deleted file mode 100644 index ed141c5..0000000 --- a/server.py +++ /dev/null @@ -1,175 +0,0 @@ -import asyncio -from datetime import datetime -import enum -import logging -import os -from concurrent.futures import Future, ProcessPoolExecutor -from pprint import pformat -from queue import Queue -import re -from typing import TypeVar - -from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler -from slack_bolt.async_app import AsyncApp - -from assistant import Assistant -from src.db.models import DB, Item - -logging.basicConfig(level=logging.INFO) -LOGGER = logging.getLogger(__file__) - -app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) - - -class OP(enum.Enum): - fetch = enum.auto() - handle_one = enum.auto() - - -T = TypeVar("T") - - -class Server: - pool: ProcessPoolExecutor - - def __init__(self) -> None: - self.assistant = Assistant() - app.message()(self.message_hello) - - self.queue = Queue() - - async def await_future(self, future: Future[T]) -> T: - done = asyncio.Event() - future.add_done_callback(lambda _: done.set()) - await done.wait() - return future.result() - - async def _slack_app(self): - handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) - await handler.start_async() - - def run(self): - with ProcessPoolExecutor() as pool: - self.pool = pool - asyncio.run(self._slack_app()) - - def fix_md_for_slack(self, md_text: str) -> str: - # Convert **bold** to *bold* (Slack syntax) - slack_text = re.sub(r"\*\*(.*?)\*\*", r"*\1*", md_text) - - # Convert *italic* to _italic_ (Slack syntax) - slack_text = re.sub(r"\*(.*?)\*", r"_\1_", slack_text) - - # Convert [text](link) to (Slack link syntax) - slack_text = re.sub(r"\[(.*?)\]\((.*?)\)", r"<\2|\1>", slack_text) - - return slack_text - - async def message_hello(self, message, ack): - await ack() - - if thread_ts := message.get("thread_ts"): - text = message["text"] - future = self.pool.submit(self.assistant.handle_reply, thread_ts, text) - response = await self.await_future(future) - LOGGER.info("response: %s", response) - - r = await app.client.chat_postMessage( - channel=os.getenv("SLACK_CHANNEL_ID"),\ - thread_ts=thread_ts, - blocks=[ - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": self.fix_md_for_slack(response), - }, - ], - }, - ], - unfurl_links=False, - unfurl_media=False, - ) - r.validate() - - match message["text"]: - - case str(value) if "fetch" in value: - future = self.pool.submit(self.assistant.fetch_emails) - found = await self.await_future(future) - LOGGER.info("%s new emails found", found) - - case str(value) if "handle_one" in value: - future = self.pool.submit(self.assistant.handle_one) - result = await self.await_future(future) - - time = datetime.fromisoformat(result["time_received"]) - - r = await app.client.chat_postMessage( - channel=os.getenv("SLACK_CHANNEL_ID"), - text=f"Handling `{result['id']}`...", - unfurl_links=False, - unfurl_media=False, - ) - r.validate() - ts = r.get("ts") - - r = await app.client.chat_update( - channel=os.getenv("SLACK_CHANNEL_ID"), - ts=ts, - blocks=[ - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": f"`From:` {result['author']}", - }, - ], - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": f"`When:` ", - }, - ], - }, - {"type": "divider"}, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": f"{result['summary']}", - }, - ], - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": f"{result['action']}", - }, - ], - }, - ], - ) - r.validate() - - db = DB() - item = db.session.query(Item).where(Item.id == result["id"]).one() - item.slack_channel = "C01CAH729TK" - item.slack_thread = r.get("ts") - db.session.commit() - - LOGGER.info("\n" + pformat(message)) - - -if __name__ == "__main__": - Server().run() - -# https://mail.google.com/mail/u/0/#inbox/19245a9bd60a75f6 diff --git a/slack.py b/slack.py deleted file mode 100644 index 9755f74..0000000 --- a/slack.py +++ /dev/null @@ -1,23 +0,0 @@ -from slack_sdk import WebClient -import os - - -def main(): - client = WebClient( - token=os.getenv("SLACK_BOT_TOKEN") - ) - - # r = client.users_list() - # r.validate() - # pprint(r.data) - - # r = client.conversations_open(channel="U01C91V6QNQ") - # r.validate() - - r = client.chat_postMessage(channel=os.getenv("SLACK_CHANNEL_ID"), - text="hello!") - r.validate() - - -if __name__ == "__main__": - main() diff --git a/src/__init__.py b/src/__init__.py index e69de29..87d6430 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -0,0 +1 @@ +"""Make modules discoverable.""" diff --git a/src/db/__init__.py b/src/db/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/db/models.py b/src/db/models.py deleted file mode 100644 index c35f4af..0000000 --- a/src/db/models.py +++ /dev/null @@ -1,39 +0,0 @@ -from sqlalchemy import Integer, create_engine, Column, String, Enum -import enum -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker - - -Base = declarative_base() - - -class ItemType(enum.StrEnum): - gmail = enum.auto() - - -class Item(Base): - __tablename__ = "items" - - type = Column(Enum(ItemType), nullable=False, primary_key=True) - id = Column(String, primary_key=True) - slack_channel = Column(String) - slack_thread = Column(String) - content = Column(String) - - -class Chat(Base): - __tablename__ = "chats" - - seq = Column(Integer, primary_key=True, autoincrement=True) - type = Column(Enum(ItemType), nullable=False) # this and the next one is a foreign key to Item - id = Column(String) - role = Column(String) - content = Column(String) - - -class DB: - def __init__(self) -> None: - self.engine = create_engine("sqlite:///maigic.db", echo=False) - Base.metadata.create_all(self.engine) - self.Session = sessionmaker(bind=self.engine) - self.session = self.Session() diff --git a/src/gmail/API.py b/src/gmail/API.py new file mode 100644 index 0000000..62b16d1 --- /dev/null +++ b/src/gmail/API.py @@ -0,0 +1,34 @@ +from typing import Optional, Generator +from datetime import datetime +from .client import Gmail + +# Create a single instance of Gmail +_gmail = Gmail() + + +def query( + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + max_results: int = 10000, +) -> Generator[dict, None, None]: + """Query Gmail messages with filters.""" + return _gmail.query(start_date, end_date, max_results) + + +def get_message(message_id: str) -> dict: + """Fetch a specific email message by ID.""" + return _gmail.get_message(message_id) + + +def get_labels() -> list[dict]: + """Fetch all labels in the user's mailbox.""" + return _gmail.get_labels() + + +def search(query: str, max_results: int = 100) -> Generator[dict, None, None]: + """Search emails using Gmail's query syntax.""" + return _gmail.search(query, max_results) + + +# Export only the public functions +__all__ = ["query", "get_message", "get_labels", "search"] diff --git a/src/gmail/client.py b/src/gmail/client.py index cf8c398..e272d9c 100644 --- a/src/gmail/client.py +++ b/src/gmail/client.py @@ -1,19 +1,41 @@ import os.path from typing import ClassVar +from datetime import datetime +from typing import Optional, Generator, Any 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 os +import logging + +LOGGER = logging.getLogger(__name__) class Gmail: + """A class to interact with Gmail using the Gmail API. + + This class provides methods to authenticate with Gmail and perform + various operations like fetching and filtering emails. + + """ + SCOPES: ClassVar[list[str]] = ["https://www.googleapis.com/auth/gmail.readonly"] def __init__(self) -> None: + """Initialize Gmail client with authenticated credentials.""" self._client = self.authenticate() - def authenticate(self): + def authenticate(self) -> Any: + """Authenticate with Gmail API using OAuth 2.0. + + Returns: + An authenticated Gmail API service object. + + Raises: + FileNotFoundError: If credentials file is not found. + ValueError: If authentication fails. + """ creds = None if os.path.exists("token.json"): creds = Credentials.from_authorized_user_file("token.json", self.SCOPES) @@ -29,17 +51,90 @@ def authenticate(self): token.write(creds.to_json()) return build("gmail", "v1", credentials=creds, cache_discovery=False) - def query(self): + # we can add start_date, end_date to fetch commands in slack bot + def query( + self, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + max_results: int = 10000, + ) -> Generator[dict, None, None]: + """Query Gmail messages with filters. + + Args: + start_date: Only return messages after this date + end_date: Only return messages before this date + max_results: Maximum number of messages to return + + Yields: + Dict containing message metadata + + Search operators used: + after:YYYY/MM/DD - Messages after date + before:YYYY/MM/DD - Messages before date + """ + query_parts = [] + + if start_date: + query_parts.append(f"after:{start_date.strftime('%Y/%m/%d')}") + + if end_date: + query_parts.append(f"before:{end_date.strftime('%Y/%m/%d')}") + + query_string = " AND ".join(query_parts) if query_parts else "" + results = ( self._client.users() .messages() - .list(userId="me", labelIds=["INBOX"], maxResults=10000) + .list(userId="me", maxResults=max_results, q=query_string) .execute() ) - messages = results.get("messages", []) + messages = results.get("messages", []) for message in messages: yield message - def get_message(self, message_id: str): + def get_message(self, message_id: str) -> dict: + """Fetch a specific email message by ID. + + Args: + message_id: The ID of the message to fetch. + + Returns: + Dict containing the message details. + + """ return self._client.users().messages().get(userId="me", id=message_id).execute() + + def get_labels(self) -> list[dict]: + """Fetch all labels in the user's mailbox. + + Returns: + List of dictionaries containing label information. + """ + results = self._client.users().labels().list(userId="me").execute() + return results.get("labels", []) + + def search( + self, + query: str, + max_results: int = 100, + ) -> Generator[dict, None, None]: + """Search emails using Gmail's query syntax. + + Args: + query: Gmail search query string. + max_results: Maximum number of results to return. + + Yields: + Dict containing message information. + """ + results = ( + self._client.users() + .messages() + .list(userId="me", maxResults=max_results, q=query) + .execute() + ) + + messages = results.get("messages", []) + for message in messages: + yield message diff --git a/src/gmail/models.py b/src/gmail/models.py index b1b0643..97addb3 100644 --- a/src/gmail/models.py +++ b/src/gmail/models.py @@ -41,7 +41,7 @@ def body(self) -> str: if " None: - self.client = OpenAI() - - def complete(self, log: list[Line]) -> ChatCompletion: - return self.client.chat.completions.create( - # model="gpt-4", - model="gpt-4o-mini", - messages=[{"role": x.role, "content": x.content} for x in log], - ) diff --git a/src/maigic_nyu/__init__.py b/src/maigic_nyu/__init__.py new file mode 100644 index 0000000..2926e00 --- /dev/null +++ b/src/maigic_nyu/__init__.py @@ -0,0 +1 @@ +"""Make files discoverable.""" diff --git a/src/maigic_nyu/demo_api/__init__.py b/src/maigic_nyu/demo_api/__init__.py new file mode 100644 index 0000000..2926e00 --- /dev/null +++ b/src/maigic_nyu/demo_api/__init__.py @@ -0,0 +1 @@ +"""Make files discoverable.""" diff --git a/src/maigic_nyu/demo_api/_demo_math.py b/src/maigic_nyu/demo_api/_demo_math.py new file mode 100644 index 0000000..4bfdd55 --- /dev/null +++ b/src/maigic_nyu/demo_api/_demo_math.py @@ -0,0 +1,11 @@ +"""Dummy file to check ruff, pytest and automatic tests with CircleCI.""" + + +def addition(num1: int, num2: int) -> int: + """Add 2 numebrs and return their sum.""" + return num1 + num2 + + +def substraction(num1: int, num2: int) -> int: + """Substract num2 from num1 i.e. num1 -num2.""" + return num1 - num2 diff --git a/src/maigic_nyu/demo_api/api.py b/src/maigic_nyu/demo_api/api.py new file mode 100644 index 0000000..719abc8 --- /dev/null +++ b/src/maigic_nyu/demo_api/api.py @@ -0,0 +1,4 @@ +"""Maigic API, exposed to the user.""" + +from maigic_nyu.demo_api._demo_math import addition as addition +from maigic_nyu.demo_api._demo_math import substraction as substraction diff --git a/src/maigic_nyu/gmail/README.md b/src/maigic_nyu/gmail/README.md new file mode 100644 index 0000000..ddde375 --- /dev/null +++ b/src/maigic_nyu/gmail/README.md @@ -0,0 +1,67 @@ +# mAIgic - Gmail module + +This module can be used to communicate with Gmail account. With appropriate credentials, one can access the respective Gmail account's content. The module supports 2 operations as of now: + +- Fetch a specific email. +- Fetch all emails. + +There are multiple classes that can effectively represent an email and more importantly, its metadata. The metadata includes the following: + +- Labels +- Headers +- Attachments +- Thread id + etc. + +# Technical tools + +- Programming Language: Python +- Project dependency management tool: uv +- Project linter: ruff +- Project tester: pytest +- Continuous Integration (CI) tool: circleCI + +# Instructions + +Collaboration doc for this initiative: https://docs.google.com/document/d/1duhM5Ufkq_doBnMvHo65S7cplmUJw4KqQvDRfZlmj7s/edit + +## Setup + +- Install `uv` on your system with command with `curl -LsSf https://astral.sh/uv/install.sh | sh`. +- `cd` into the cloned repository and execute `uv sync --extra dev` (This will install all the project and developer dependencies). +- Activate the environment `venv` by executing `source ./.venv/bin/activate`. + +## Processes + +- To check the formatting standard with ruff (linter) execute either `uv run ruff check .` or `ruff check .` from the root directory of the project. +- To test the code, execute either `uv run pytest .` or `pytest .` from the root directory of the project. + +Note: One can run tools like ruff and pytest independently or can run them through `uv`. + +## Commit Style Guide + +- Request to `strictly adhere` to the suggested commit style guide. +- The project follows Udacity's [Commit Style Guide](https://udacity.github.io/git-styleguide/). +- Reason: + - It is simple, elegant, concise and effective. + - It does not have many rules that could create confusion but yet have just enough to keep things working smoothly through clear and concise communication. + +## GitHub Workflow + +- Members of same team can preferably `clone` the repository. +- Make sure to push new changes to `dev` remote branch. +- Create a `Pull Request` and the changes would be reviewed and merged to the `main` remote branch. `Review` includes code, code quality, code standards, commit style guides, and Pull Request descriptions. Consistent code standards and documentation would be aided by `ruff`. +- `main` branch serves as production branch which would accumulate new changes, features and bug-fixes from `dev` branch. +- Would appreciate if you open `issues` whenever you come across any. Issues can be bugs, proposed features, performance / code improvement proposals, etc. + +## Requesting access to project and CI space + +- Send your`github username` to become collaborators to the project. +- Send your `email id` used to `register with circleCI` to get access to the circleCI organization to manage CI workflows and triggers. You will receive an invitation in the provided email's inbox to join the circleCI organization. +- Currently, the `magic2` CircleCI project is attached to this project. + +Note: Request to keep all `communication` in the Google Chats Project Group. + +# License + +mAIgic has a MIT-style license, as found in the [LICENSE](LICENSE) file. diff --git a/src/maigic_nyu/gmail/__init__.py b/src/maigic_nyu/gmail/__init__.py new file mode 100644 index 0000000..2926e00 --- /dev/null +++ b/src/maigic_nyu/gmail/__init__.py @@ -0,0 +1 @@ +"""Make files discoverable.""" diff --git a/src/maigic_nyu/gmail/_client.py b/src/maigic_nyu/gmail/_client.py new file mode 100644 index 0000000..df5ff77 --- /dev/null +++ b/src/maigic_nyu/gmail/_client.py @@ -0,0 +1,54 @@ +"""TODO: Add docstring.""" + +import os +import os.path +from pathlib import Path +from typing import Any, ClassVar + +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 + + +class Gmail: + """TODO: Add docstring.""" + + SCOPES: ClassVar[list[str]] = ["https://www.googleapis.com/auth/gmail.readonly"] + + def __init__(self) -> None: + """TODO: Add docstring.""" + self._client = self.authenticate() + + def authenticate(self) -> Any: + """TODO: Add docstring.""" + creds = None + if Path("token.json").exists(): + creds = Credentials.from_authorized_user_file("token.json", self.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( + os.getenv("CREDENTIALS_FILE_NAME"), self.SCOPES + ) + creds = flow.run_local_server(port=0) + with Path("token.json").open("w") as token: + token.write(creds.to_json()) + return build("gmail", "v1", credentials=creds, cache_discovery=False) + + def query(self) -> Any: + """TODO: Add docstring.""" + results = ( + self._client.users() + .messages() + .list(userId="me", labelIds=["INBOX"], maxResults=10000) + .execute() + ) + messages = results.get("messages", []) + + yield from messages + + def get_message(self, message_id: str) -> Any: + """TODO: Add docstring.""" + return self._client.users().messages().get(userId="me", id=message_id).execute() diff --git a/src/maigic_nyu/gmail/_models.py b/src/maigic_nyu/gmail/_models.py new file mode 100644 index 0000000..c6f07c8 --- /dev/null +++ b/src/maigic_nyu/gmail/_models.py @@ -0,0 +1,122 @@ +"""TODO: Add docstring.""" + +import base64 +from pprint import pprint +from typing import Any, ClassVar + +from html2text import html2text +from pydantic import BaseModel + + +class Header(BaseModel): + """TODO: Add docstring.""" + + name: str + value: str + + +class GmailMessagePart: + """TODO: Add docstring.""" + + interesting_header_names: ClassVar[set[str]] = { + "From", + "To", + "Date", + "Subject", + } + + def __init__(self, part: dict[str, Any]) -> None: + """TODO: Add docstring.""" + self._part = part + + @property + def headers(self) -> list[Header]: + """TODO: Add docstring.""" + return [ + Header(name=x["name"], value=x["value"]) + for x in self._part["headers"] + if x["name"] in self.interesting_header_names + ] + + @property + def body(self) -> str: + """TODO: Add docstring.""" + # TODO(kamen): sometimes there is no body... + # e.g. there maybe `attachmentId` instead. + # we are not handling that for now... :( + if self._part["body"]["size"] != 0 and "data" in self._part["body"]: + try: + data = self._part["body"]["data"] + decoded = base64.urlsafe_b64decode(data).decode() + if " list["GmailMessagePart"]: + """TODO: Add docstring.""" + return [GmailMessagePart(x) for x in self._part.get("parts", [])] + + def as_dict(self) -> dict[str, Any]: + """TODO: Add docstring.""" + return { + "headers": [x.model_dump() for x in self.headers], + "body": self.body, + "parts": [x.as_dict() for x in self.parts], + } + + def as_md(self) -> str: + """TODO: Add docstring.""" + result = [] + result.append("-" * 80) + result.extend(f"{header.name}: {header.value}" for header in self.headers) + result.append("") + result.append(self.body) + result.extend(part.as_md() for part in self.parts) + return "\n".join(result) + + +class GMailMessage: + """TODO: Add docstring.""" + + def __init__(self, message: dict[str, Any]) -> None: + """TODO: Add docstring.""" + self._message = message + + @property + def label_ids(self) -> list[str]: + """TODO: Add docstring.""" + return self._message["labelIds"] + + @property + def thread_id(self) -> str: + """TODO: Add docstring.""" + return self._message["threadId"] + + @property + def parts(self) -> list[GmailMessagePart]: + """TODO: Add docstring.""" + return [GmailMessagePart(self._message["payload"])] + [ + GmailMessagePart(x) for x in self._message.get("parts", []) + ] + + def as_dict(self) -> dict[str, Any]: + """TODO: Add docstring.""" + return { + "label_ids": self.label_ids, + "thread_id": self.thread_id, + "parts": [x.as_dict() for x in self.parts], + } + + def as_md(self) -> str: + """TODO: Add docstring.""" + result = [] + result.append(f"labels: {', '.join(self.label_ids)}") + result.append(f"thread: {self.thread_id}") + result.extend(part.as_md() for part in self.parts) + return "\n".join(result) diff --git a/src/maigic_nyu/gmail/api.py b/src/maigic_nyu/gmail/api.py new file mode 100644 index 0000000..1607a08 --- /dev/null +++ b/src/maigic_nyu/gmail/api.py @@ -0,0 +1,6 @@ +"""Expose _clinet.py and _models.py through api.py.""" + +from maigic_nyu.gmail._client import Gmail as Gmail +from maigic_nyu.gmail._models import GMailMessage as GMailMessage +from maigic_nyu.gmail._models import GmailMessagePart as GmailMessagePart +from maigic_nyu.gmail._models import Header as Header diff --git a/src/slack/__init__.py b/src/slack/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/slack/slack.py b/src/slack/slack.py deleted file mode 100644 index e75bc7b..0000000 --- a/src/slack/slack.py +++ /dev/null @@ -1,17 +0,0 @@ -import os - -from slack_sdk import WebClient - - -class Slack: - def __init__(self) -> None: - client = WebClient(token=os.getenv("SLACK_BOT_TOKEN")) - - def send(self, thread: str | None, text: str) -> None: - r = self.client.chat_postMessage( - channel=os.getenc("SLACK_CHANNEL_ID"), - thread=thread, - text=text, - ) - r.validate() - return r diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..87d6430 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Make modules discoverable.""" diff --git a/tests/test_demo_api/__init__.py b/tests/test_demo_api/__init__.py new file mode 100644 index 0000000..a7dbf8b --- /dev/null +++ b/tests/test_demo_api/__init__.py @@ -0,0 +1 @@ +"""Make python identify tests directory as a refernciable package.""" diff --git a/tests/test_demo_api/test_api.py b/tests/test_demo_api/test_api.py new file mode 100644 index 0000000..ef0f9fa --- /dev/null +++ b/tests/test_demo_api/test_api.py @@ -0,0 +1,23 @@ +"""File tests hello.py file.""" + +from maigic_nyu.demo_api.api import addition, substraction + + +def test_addition() -> None: + """Test the addition function.""" + test_7 = 7 + test_0 = 0 + test__2 = -2 + assert addition(3, 4) == test_7 + assert addition(-1, 1) == test_0 + assert addition(-1, -1) == test__2 + + +def test_substraction() -> None: + """Test the addition function.""" + test_7 = 7 + test_0 = 0 + test_negative_2 = -2 + assert substraction(10, 3) == test_7 + assert substraction(-1, -1) == test_0 + assert substraction(-1, 1) == test_negative_2 diff --git a/tests/test_gmail.py b/tests/test_gmail.py new file mode 100644 index 0000000..7b2d9e2 --- /dev/null +++ b/tests/test_gmail.py @@ -0,0 +1,134 @@ +# pytest test/test_gmail.py -v +import pytest +from datetime import datetime +from unittest.mock import Mock, patch +from src.gmail.client import Gmail + + +@pytest.fixture +def mock_gmail_service(): + """Fixture to mock the Gmail API service""" + with patch("src.gmail.client.build") as mock_build: + mock_service = Mock() + mock_build.return_value = mock_service + yield mock_service + + +@pytest.fixture +def gmail_client(mock_gmail_service): + """Fixture to create a Gmail client with mocked authentication""" + with patch("src.gmail.client.Credentials") as mock_creds: + mock_creds.from_authorized_user_file.return_value = Mock(valid=True) + return Gmail() + + +def test_query_method(gmail_client, mock_gmail_service): + # Mock response data + mock_messages = { + "messages": [ + {"id": "123", "threadId": "thread123"}, + {"id": "456", "threadId": "thread456"}, + ] + } + + # Setup the mock chain + mock_service = mock_gmail_service + mock_service.users.return_value.messages.return_value.list.return_value.execute.return_value = mock_messages + + # Test the query method + start_date = datetime(2024, 10, 30) + end_date = datetime(2024, 10, 31) + messages = list( + gmail_client.query(start_date=start_date, end_date=end_date, max_results=5) + ) + + # Assertions + assert len(messages) == 2 + assert messages[0]["id"] == "123" + assert messages[1]["id"] == "456" + + # Verify the correct query parameters were used + mock_service.users.return_value.messages.return_value.list.assert_called_with( + userId="me", maxResults=5, q="after:2024/10/30 AND before:2024/10/31" + ) + + +def test_get_message(gmail_client, mock_gmail_service): + # Mock response data + mock_message = { + "id": "123", + "payload": { + "headers": [ + {"name": "Subject", "value": "Test Subject"}, + {"name": "From", "value": "sender@example.com"}, + ] + }, + } + + # Setup the mock + mock_service = mock_gmail_service + mock_service.users.return_value.messages.return_value.get.return_value.execute.return_value = mock_message + + # Test get_message + result = gmail_client.get_message("123") + + # Assertions + assert result == mock_message + assert result["payload"]["headers"][0]["value"] == "Test Subject" + + # Verify the correct parameters were used + mock_service.users.return_value.messages.return_value.get.assert_called_with( + userId="me", id="123" + ) + + +def test_search_method(gmail_client, mock_gmail_service): + # Mock response data + mock_search_results = { + "messages": [ + {"id": "789", "threadId": "thread789"}, + {"id": "012", "threadId": "thread012"}, + ] + } + + # Setup the mock + mock_service = mock_gmail_service + mock_service.users.return_value.messages.return_value.list.return_value.execute.return_value = mock_search_results + + # Test search method + search_query = "test query" + results = list(gmail_client.search(search_query, max_results=2)) + + # Assertions + assert len(results) == 2 + assert results[0]["id"] == "789" + assert results[1]["id"] == "012" + + # Verify the correct parameters were used + mock_service.users.return_value.messages.return_value.list.assert_called_with( + userId="me", maxResults=2, q=search_query + ) + + +def test_get_labels(gmail_client, mock_gmail_service): + # Mock response data + mock_labels = { + "labels": [{"id": "INBOX", "name": "INBOX"}, {"id": "SENT", "name": "SENT"}] + } + + # Setup the mock + mock_service = mock_gmail_service + mock_service.users.return_value.labels.return_value.list.return_value.execute.return_value = mock_labels + + # Test get_labels + results = gmail_client.get_labels() + + # Assertions + assert len(results) == 2 + assert results[0]["name"] == "INBOX" + assert results[1]["name"] == "SENT" + + # Verify the correct parameters were used + mock_service.users.return_value.labels.return_value.list.assert_called_with( + userId="me" + ) diff --git a/tests/test_gmail_api.py b/tests/test_gmail_api.py new file mode 100644 index 0000000..963be2a --- /dev/null +++ b/tests/test_gmail_api.py @@ -0,0 +1,35 @@ +# In order to test Google Auth, actual message in your Gmail and API. You can +# replace specific info (like start/end date or message_id) in this file. +# Then compare the output with the actual output in your Gmail. + +from datetime import datetime +from src.gmail.API import query, get_message, get_labels, search + +## Query Messages Example +# Get messages from a specific date range +start_date = datetime(2024, 10, 31) +end_date = datetime(2024, 11, 1) +messages = query(start_date=start_date, end_date=end_date, max_results=5) +for msg in messages: + print(f"Message ID: {msg['id']}, Thread ID: {msg['threadId']}") + +## Get Single Message Example +# Fetch details of a specific message +message_id = "192e80bb14e565c5" +message = get_message(message_id) +print(f"Subject: {message.get('subject')}") +print(f"Snippet: {message.get('snippet')}") + +## Get Labels Example +# Fetch all Gmail labels +labels = get_labels() +for label in labels: + print(f"Label Name: {label['name']}, Label ID: {label['id']}") + +## Search Example +# Search for specific emails +search_query = "aaa" +search_results = search(query=search_query, max_results=5) +for result in search_results: + message_details = get_message(result["id"]) + print(f"Found message: {message_details.get('snippet')}") diff --git a/uv.lock b/uv.lock index cf29625..1670d2d 100644 --- a/uv.lock +++ b/uv.lock @@ -514,6 +514,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, ] +[[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 = "jiter" version = "0.5.0" @@ -550,6 +559,8 @@ dependencies = [ { name = "humanreadable" }, { name = "openai" }, { name = "pydantic" }, + { name = "pytest" }, + { name = "pytest-mock" }, { name = "slack-bolt" }, { name = "slack-sdk" }, { name = "sqlalchemy" }, @@ -577,6 +588,8 @@ requires-dist = [ { name = "humanreadable", specifier = ">=0.4.0" }, { name = "openai", specifier = ">=1.50.2" }, { name = "pydantic", specifier = ">=2.9.2" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "slack-bolt", specifier = ">=1.20.1" }, { name = "slack-sdk", specifier = ">=3.33.1" }, { name = "sqlalchemy", specifier = ">=2.0.35" }, @@ -736,6 +749,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, ] +[[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 = "proto-plus" version = "1.24.0" @@ -850,6 +872,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/0c/0e3c05b1c87bb6a1c76d281b0f35e78d2d80ac91b5f8f524cebf77f51049/pyparsing-3.1.4-py3-none-any.whl", hash = "sha256:a6a7ee4235a3f944aa1fa2249307708f893fe5717dc603503c6c7969c070fb7c", size = 104100 }, ] +[[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 = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + [[package]] name = "requests" version = "2.32.3"