From adf37b7cd7e7e4dcfee4eafa5d0a30d6bca5c58d Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Fri, 5 Dec 2025 21:47:34 +0530 Subject: [PATCH 1/6] feat: revive googlecl with modern Python tooling and uv support - Add pyproject.toml with uv-compatible dependencies - Fix CLI entry point with proper main() function - Add lazy loading in __init__.py modules for faster startup - Implement Gmail, Drive, and Calendar service modules - Update README with comprehensive documentation - All 14 tests passing Google APIs supported: - Gmail API v1: inbox, send, search, labels - Drive API v3: list, upload, download, folders, quota - Calendar API v3: events, quick-add, calendars --- README.rst | 253 +++++++-- pyproject.toml | 131 +++++ src/google_cl/__init__.py | 42 +- src/google_cl/exceptions.py | 31 +- src/google_cl/main/__init__.py | 15 + src/google_cl/main/application.py | 35 +- src/google_cl/main/auth.py | 333 ++++++++---- src/google_cl/main/cli.py | 815 ++++++++++++++++++++++++++++- src/google_cl/services/__init__.py | 8 + src/google_cl/services/base.py | 91 ++++ src/google_cl/services/calendar.py | 386 ++++++++++++++ src/google_cl/services/drive.py | 403 ++++++++++++++ src/google_cl/services/gmail.py | 302 +++++++++++ tests/test_google_cl.py | 248 +++++++-- uv.lock | 802 ++++++++++++++++++++++++++++ 15 files changed, 3697 insertions(+), 198 deletions(-) create mode 100644 pyproject.toml create mode 100644 src/google_cl/services/__init__.py create mode 100644 src/google_cl/services/base.py create mode 100644 src/google_cl/services/calendar.py create mode 100644 src/google_cl/services/drive.py create mode 100644 src/google_cl/services/gmail.py create mode 100644 uv.lock diff --git a/README.rst b/README.rst index b6b7916..f9cd929 100644 --- a/README.rst +++ b/README.rst @@ -1,74 +1,225 @@ ========= -google_cl +GoogleCL ========= - .. image:: https://img.shields.io/pypi/v/google_cl.svg :target: https://pypi.python.org/pypi/google_cl -.. image:: https://img.shields.io/travis/vinitkumar/google_cl.svg - :target: https://travis-ci.org/vinitkumar/google_cl +.. image:: https://img.shields.io/pypi/pyversions/google_cl.svg + :target: https://pypi.python.org/pypi/google_cl .. image:: https://readthedocs.org/projects/google-cl/badge/?version=latest :target: https://google-cl.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status +**Command-line interface for Google services - Gmail, Drive, and Calendar** -.. image:: https://pyup.io/repos/github/vinitkumar/google_cl/shield.svg - :target: https://pyup.io/repos/github/vinitkumar/google_cl/ - :alt: Updates - - - -Pythonic interface to interact with google services - +GoogleCL provides a modern, intuitive command-line interface to interact with Google services. Manage your emails, files, and calendar events directly from your terminal. * Free software: Apache Software License 2.0 -* Documentation: https://google-cl.readthedocs.io. - +* Documentation: https://google-cl.readthedocs.io Features -+++++++ - -* TODO - -Contributors -+++++++ - -Code Contributors ------------------- - -This project exists thanks to all the people who contribute. [[Contribute](CONTRIBUTING.md)]. -.. image:: https://opencollective.com/googlecl/contributors.svg?width=890&button=false - -Financial Contributors ------------------- - -Become a financial contributor and help us sustain our community. Contribute_ - -Individuals -~~~~~~~~~~~~~~~~~~~~~~ - -.. image:: https://opencollective.com/googlecl/individuals.svg?width=890 - :target: https://opencollective.com/googlecl - -Organizations -~~~~~~~~~~~~~~~~~~~~~~ - -Support this project with your organization. Your logo will show up here with a link to your website. Contribute_ - -.. image:: https://opencollective.com/googlecl/organization/0/avatar.svg - :target: https://opencollective.com/googlecl/organization/0/website - -.. _Contribute: https://opencollective.com/googlecl - -* TODO +-------- + +📧 **Gmail** + - List and read emails from your inbox + - Send emails with plain text or HTML + - Search emails using Gmail query syntax + - Manage labels + +📁 **Google Drive** + - List and search files + - Upload and download files + - Create folders + - Check storage quota + - Delete files (trash or permanent) + +📅 **Google Calendar** + - View today's events + - List upcoming events + - Create new events + - Quick add events using natural language + - Manage multiple calendars + +Installation +------------ + +Using `uv` (recommended):: + + uv pip install googlecl + +Using pip:: + + pip install googlecl + +From source:: + + git clone https://github.com/vinitkumar/googlecl.git + cd googlecl + uv pip install -e . + +Quick Start +----------- + +1. **Set up Google Cloud credentials**: + + - Go to https://console.cloud.google.com/ + - Create a new project or select an existing one + - Enable the Gmail, Drive, and Calendar APIs + - Go to APIs & Services > Credentials + - Create an OAuth 2.0 Client ID (Desktop application) + - Download the JSON file + +2. **Configure GoogleCL**:: + + # Show configuration paths + googlecl auth info + + # Copy your credentials.json to the config directory + mkdir -p ~/.config/googlecl + cp ~/Downloads/credentials.json ~/.config/googlecl/ + +3. **Authenticate**:: + + googlecl auth login + +4. **Start using GoogleCL**:: + + # Check your inbox + googlecl gmail inbox + + # List Drive files + googlecl drive list + + # Show today's calendar events + googlecl calendar today + +Usage Examples +-------------- + +**Gmail**:: + + # List 10 emails from inbox + googlecl gmail inbox -n 10 + + # Show only unread emails + googlecl gmail inbox --unread + + # Read a specific email + googlecl gmail read MESSAGE_ID + + # Send an email + googlecl gmail send --to user@example.com --subject "Hello" --body "Hi there!" + + # Search emails + googlecl gmail search "from:boss@company.com is:unread" + + # List all labels + googlecl gmail labels + +**Google Drive**:: + + # List files + googlecl drive list + + # Search for files + googlecl drive search "report" + + # Upload a file + googlecl drive upload myfile.pdf + + # Download a file + googlecl drive download FILE_ID -o ~/Downloads/ + + # Create a folder + googlecl drive mkdir "New Folder" + + # Check storage quota + googlecl drive quota + +**Google Calendar**:: + + # Show today's events + googlecl calendar today + + # Show upcoming events (next 7 days) + googlecl calendar upcoming + + # Show next 30 days + googlecl calendar upcoming --days 30 + + # Create an event + googlecl calendar add "Team Meeting" --start "2024-01-15 10:00" --end "2024-01-15 11:00" + + # Quick add (natural language) + googlecl calendar quick "Lunch with John tomorrow at noon" + + # List all calendars + googlecl calendar calendars + +Authentication +-------------- + +GoogleCL uses OAuth 2.0 for authentication. Your credentials are stored locally at: + +- **Credentials file**: ``~/.config/googlecl/credentials.json`` +- **Token file**: ``~/.config/googlecl/token.json`` + +Commands:: + + # Check authentication status + googlecl auth status + + # Login (authenticate) + googlecl auth login + + # Force re-authentication + googlecl auth login --force + + # Logout (remove tokens) + googlecl auth logout + + # Show configuration info + googlecl auth info + +Development +----------- + +Clone the repository:: + + git clone https://github.com/vinitkumar/googlecl.git + cd googlecl + +Set up development environment:: + + uv venv + source .venv/bin/activate + uv pip install -e ".[dev]" + +Run tests:: + + pytest + +Run linting:: + + ruff check src tests + mypy src + +Contributing +------------ + +Contributions are welcome! Please read `CONTRIBUTING.rst` for guidelines. + +License +------- + +This project is licensed under the Apache License 2.0 - see the `LICENSE` file for details. Credits -++++++++ +------- This package was created with Cookiecutter_ and the `audreyr/cookiecutter-pypackage`_ project template. .. _Cookiecutter: https://github.com/audreyr/cookiecutter .. _`audreyr/cookiecutter-pypackage`: https://github.com/audreyr/cookiecutter-pypackage - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..64b4f40 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,131 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "googlecl" +version = "0.1.0" +description = "Command-line interface for Google services - Gmail, Drive, and Calendar" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "Vinit Kumar", email = "mail@vinitkumar.me" }, +] +keywords = [ + "google", + "cli", + "gmail", + "drive", + "calendar", + "command-line", + "oauth", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Communications :: Email", + "Topic :: Internet", + "Topic :: Utilities", +] +dependencies = [ + "google-api-python-client>=2.100.0", + "google-auth>=2.23.0", + "google-auth-oauthlib>=1.1.0", + "google-auth-httplib2>=0.1.1", + "typer>=0.9.0", + "rich>=13.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-cov>=4.1.0", + "mypy>=1.5.0", + "ruff>=0.1.0", + "pre-commit>=3.5.0", +] +docs = [ + "sphinx>=7.0.0", + "sphinx-rtd-theme>=1.3.0", +] + +[project.scripts] +googlecl = "google_cl.main.cli:main" + +[project.urls] +Homepage = "https://github.com/vinitkumar/googlecl" +Documentation = "https://googlecl.readthedocs.io" +Repository = "https://github.com/vinitkumar/googlecl" +Issues = "https://github.com/vinitkumar/googlecl/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/google_cl"] + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", + "/README.rst", + "/LICENSE", +] + +[tool.ruff] +target-version = "py310" +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # do not perform function calls in argument defaults + "C901", # too complex +] + +[tool.ruff.lint.isort] +known-first-party = ["google_cl"] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +ignore_missing_imports = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +addopts = "-v --tb=short" + +[tool.coverage.run] +source = ["src/google_cl"] +branch = true + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] diff --git a/src/google_cl/__init__.py b/src/google_cl/__init__.py index 27e5e66..361c594 100644 --- a/src/google_cl/__init__.py +++ b/src/google_cl/__init__.py @@ -1,7 +1,39 @@ -# -*- coding: utf-8 -*- +""" +GoogleCL - Command-line interface for Google services. -"""Top-level package for google_cl.""" +This package provides a Pythonic interface to interact with Google services +including Gmail, Google Drive, and Google Calendar from the command line. +""" -__author__ = """Vinit Kumar""" -__email__ = 'mail@vinitkumar.me' -__version__ = '0.0.1' +__author__ = "Vinit Kumar" +__email__ = "mail@vinitkumar.me" +__version__ = "0.1.0" + + +def __getattr__(name: str): + """Lazy loading of heavy modules to speed up CLI startup.""" + if name == "GoogleAuth": + from google_cl.main.auth import GoogleAuth + return GoogleAuth + if name == "authenticate": + from google_cl.main.auth import authenticate + return authenticate + if name == "GmailService": + from google_cl.services.gmail import GmailService + return GmailService + if name == "DriveService": + from google_cl.services.drive import DriveService + return DriveService + if name == "CalendarService": + from google_cl.services.calendar import CalendarService + return CalendarService + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "GoogleAuth", + "authenticate", + "GmailService", + "DriveService", + "CalendarService", +] diff --git a/src/google_cl/exceptions.py b/src/google_cl/exceptions.py index 87badc2..0e50156 100644 --- a/src/google_cl/exceptions.py +++ b/src/google_cl/exceptions.py @@ -1,8 +1,37 @@ +"""Custom exceptions for GoogleCL.""" + + class GoogleCLException(Exception): + """Base exception for GoogleCL.""" + pass + class ExecutionError(GoogleCLException): + """Raised when there's an error during command execution.""" + pass -class EarlyQuitException(GoogleCLException): + +class EarlyQuit(GoogleCLException): + """Raised when the application needs to quit early.""" + + pass + + +class AuthenticationError(GoogleCLException): + """Raised when authentication fails.""" + + pass + + +class ServiceError(GoogleCLException): + """Raised when a Google service operation fails.""" + + pass + + +class ConfigurationError(GoogleCLException): + """Raised when there's a configuration issue.""" + pass diff --git a/src/google_cl/main/__init__.py b/src/google_cl/main/__init__.py index e69de29..15ffc11 100644 --- a/src/google_cl/main/__init__.py +++ b/src/google_cl/main/__init__.py @@ -0,0 +1,15 @@ +"""Main module for GoogleCL CLI.""" + +__all__ = ["Application", "app"] + + +def __getattr__(name: str): + """Lazy loading to avoid import issues.""" + if name == "Application": + from google_cl.main.application import Application + return Application + if name == "app": + from google_cl.main.cli import app + return app + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + diff --git a/src/google_cl/main/application.py b/src/google_cl/main/application.py index 7b1a5cd..a1c8744 100644 --- a/src/google_cl/main/application.py +++ b/src/google_cl/main/application.py @@ -1,41 +1,60 @@ -import time -import logging -from google_cl import exceptions +"""Main application module for GoogleCL.""" +import logging +import time from typing import Sequence +from google_cl import exceptions LOG = logging.getLogger(__name__) + class Application: + """Main application class for GoogleCL.""" def __init__(self) -> None: + """Initialize the application.""" self.start_time = time.time() self.catastrophic_failure = False def exit_code(self) -> int: + """Get the exit code based on application state.""" if self.catastrophic_failure: return 1 return 0 - def initialize(self, argv: Sequence[str]): - print(argv) + def initialize(self, argv: Sequence[str]) -> None: + """Initialize the application with command line arguments.""" + # Import here to avoid circular imports + from google_cl.main.cli import app + + # If argv is empty, show help + if not argv: + argv = ["--help"] + + # Run the Typer app + app(argv) def _run(self, argv: Sequence[str]) -> None: + """Internal run method.""" self.initialize(argv) def run(self, argv: Sequence[str]) -> None: + """Run the application.""" try: self._run(argv) except KeyboardInterrupt as exc: - print("... stopped") + print("\n... stopped") LOG.critical("Caught keyboard interrupt from user") LOG.exception(exc) self.catastrophic_failure = True except exceptions.ExecutionError as exc: - print("There was a critical error during execution of Flake8:") - print(exc) + print(f"There was a critical error during execution: {exc}") LOG.exception(exc) self.catastrophic_failure = True except exceptions.EarlyQuit: self.catastrophic_failure = True + except SystemExit as exc: + # Typer raises SystemExit, handle it gracefully + if exc.code != 0: + self.catastrophic_failure = True diff --git a/src/google_cl/main/auth.py b/src/google_cl/main/auth.py index c0b0a20..043259f 100644 --- a/src/google_cl/main/auth.py +++ b/src/google_cl/main/auth.py @@ -1,114 +1,267 @@ -from __future__ import annotations +""" +Authentication module for Google APIs using OAuth 2.0. +This module handles OAuth 2.0 authentication flow for Google services, +including token management, refresh, and secure storage. +""" -import logging -from requests.adapters import HTTPAdapter -from requests_oauthlib import OAuth2Session +from __future__ import annotations +import json +import logging from pathlib import Path -from urllib3.util.retry import Retry - -from typing import Optional +from typing import TYPE_CHECKING -from json import load, dump, JSONDecodeError +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +if TYPE_CHECKING: + from googleapiclient.discovery import Resource log = logging.getLogger(__name__) -# OAuth endpoints given in the Google API documentation -authorization_base_url = "https://accounts.google.com/o/oauth2/v2/auth" -token_uri = "https://www.googleapis.com/oauth2/v4/token" +# Default paths for credentials and tokens +DEFAULT_CONFIG_DIR = Path.home() / ".config" / "googlecl" +DEFAULT_CREDENTIALS_FILE = DEFAULT_CONFIG_DIR / "credentials.json" +DEFAULT_TOKEN_FILE = DEFAULT_CONFIG_DIR / "token.json" + +# All available scopes for Google services +SCOPES = { + "gmail": [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.compose", + "https://www.googleapis.com/auth/gmail.labels", + ], + "drive": [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive.file", + "https://www.googleapis.com/auth/drive.metadata.readonly", + ], + "calendar": [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", + ], + "sheets": [ + "https://www.googleapis.com/auth/spreadsheets.readonly", + "https://www.googleapis.com/auth/spreadsheets", + ], +} + + +def get_all_scopes() -> list[str]: + """Get all available scopes combined.""" + all_scopes: list[str] = [] + for scope_list in SCOPES.values(): + all_scopes.extend(scope_list) + return list(set(all_scopes)) + + +def ensure_config_dir() -> Path: + """Ensure the config directory exists and return its path.""" + DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + return DEFAULT_CONFIG_DIR + + +class GoogleAuth: + """ + Handles Google OAuth 2.0 authentication. + This class manages the OAuth flow, token storage, and refresh + for authenticating with Google APIs. + """ -class Authorize: def __init__( - self, - scope: list[str], - token_file: Path, - secrets_file: Path, - max_retries: int = 5, - ): - self.max_retries = max_retries - self.scope: list[str] = scope - self.token_file: Path = token_file - self.session = None - self.token = None + self, + credentials_file: Path | None = None, + token_file: Path | None = None, + scopes: list[str] | None = None, + ) -> None: + """ + Initialize GoogleAuth. - try: - with secrets_file.open("r") as stream: - all_json = load(stream) - secrets = all_json["installed"] - self.client_id = secrets["client_id"] - self.client_secret = secrets["client_secret"] - self.redirect_uri = secrets["redirect_uri"][0] - self.token_uri = secrets["token_uri"] - self.extra = { - "client_id": self.client_id, - "client_secret": self.client_secret - } - except (JSONDecodeError, IOError): - print(f"Missing or bad secrets file: {secrets_file}") - - - def load_token(self) -> Optional[str]: - try: - with self.token_file.open("r") as stream: - token = load(stream) + Args: + credentials_file: Path to the OAuth credentials JSON file. + token_file: Path to store/load the authentication token. + scopes: List of OAuth scopes to request. + """ + ensure_config_dir() + self.credentials_file = credentials_file or DEFAULT_CREDENTIALS_FILE + self.token_file = token_file or DEFAULT_TOKEN_FILE + self.scopes = scopes or get_all_scopes() + self._credentials: Credentials | None = None + + @property + def credentials(self) -> Credentials | None: + """Get the current credentials.""" + return self._credentials - except (JSONDecodeError, IOError): + def load_token(self) -> Credentials | None: + """ + Load saved token from file if it exists. + + Returns: + Credentials object if token exists, None otherwise. + """ + if not self.token_file.exists(): + log.debug("No token file found at %s", self.token_file) return None - return token - def save_token(self, token: str): - with self.token_file.open("w") as stream: - dump(token, stream) + try: + creds = Credentials.from_authorized_user_file( + str(self.token_file), self.scopes + ) + log.debug("Loaded token from %s", self.token_file) + return creds + except (json.JSONDecodeError, ValueError) as e: + log.warning("Failed to load token: %s", e) + return None + + def save_token(self, credentials: Credentials) -> None: + """ + Save credentials to the token file. + + Args: + credentials: The credentials to save. + """ + with open(self.token_file, "w") as token: + token.write(credentials.to_json()) + # Set secure permissions self.token_file.chmod(0o600) + log.debug("Saved token to %s", self.token_file) + def authenticate(self, force_new: bool = False) -> Credentials: + """ + Authenticate with Google and return credentials. - def authorize(self): - token = self.load_token() - if token: - self.session = OAuth2Session( - self.client_id, - token=token, - auto_refresh_url=self.token_uri, - auto_refreh_kwargs=self.extra, - token_update=self.save_token, - ) - else: - self.session = OAuth2Session( - self.client_id, - scope=self.scope, - redirect_uri=self.redirect_uri, - auto_refresh_url=self.token_uri, - auto_refreh_kwargs=self.extra, - token_update=self.save_token, - ) - authorization_url, _ = self.session.authorization_url( - authorization_base_url, - access_type="offline", - prompt="select_account", - ) - print(f"Please go here and authorize, {authorization_url}\n", ) + This method will: + 1. Try to load existing token + 2. Refresh if expired + 3. Run OAuth flow if no valid token exists - # Get the authorization verifier code from the callback url - response_code = input("Paste the response token here:\n") + Args: + force_new: Force a new authentication flow. - # Fetch the access token - self.token = self.session.fetch_token( - self.token_uri, client_secret=self.client_secret, code=response_code + Returns: + Valid Credentials object. + + Raises: + FileNotFoundError: If credentials file doesn't exist. + ValueError: If authentication fails. + """ + creds: Credentials | None = None + + if not force_new: + creds = self.load_token() + + # Check if we have valid credentials + if creds and creds.valid: + self._credentials = creds + return creds + + # Try to refresh expired credentials + if creds and creds.expired and creds.refresh_token: + try: + log.info("Refreshing expired token...") + creds.refresh(Request()) + self.save_token(creds) + self._credentials = creds + return creds + except Exception as e: + log.warning("Failed to refresh token: %s", e) + creds = None + + # Need to run OAuth flow + if not self.credentials_file.exists(): + raise FileNotFoundError( + f"Credentials file not found: {self.credentials_file}\n" + "Please download your OAuth credentials from Google Cloud Console:\n" + "1. Go to https://console.cloud.google.com/\n" + "2. Create a project or select existing\n" + "3. Enable the APIs you want to use\n" + "4. Go to APIs & Services > Credentials\n" + "5. Create OAuth 2.0 Client ID (Desktop application)\n" + "6. Download JSON and save to: {self.credentials_file}" ) - self.save_token(self.token) - - # set up the retry bevaiour for the authorized session - retries = Retry( - total=self.max_retries, - backoff_factor=0.5, - status_forcelist=[500, 502, 503, 504], - method_whitelist=frozenset(["GET", "POST"]), - raise_on_status=False, - respect_retry_after_header=True, + + log.info("Starting OAuth flow...") + flow = InstalledAppFlow.from_client_secrets_file( + str(self.credentials_file), self.scopes ) - # apply the retry behaviour to our session by repalcing the default HTTPAdapter - self.session.mount("https://", HTTPAdapter(max_retries=retries)) + # Run the local server for OAuth callback + creds = flow.run_local_server(port=0) + + # Save the credentials for future use + self.save_token(creds) + self._credentials = creds + + log.info("Authentication successful!") + return creds + + def is_authenticated(self) -> bool: + """Check if we have valid credentials.""" + if self._credentials is None: + creds = self.load_token() + if creds and creds.valid: + self._credentials = creds + return True + if creds and creds.expired and creds.refresh_token: + try: + creds.refresh(Request()) + self.save_token(creds) + self._credentials = creds + return True + except Exception: + return False + return False + return self._credentials.valid + + def revoke(self) -> bool: + """ + Revoke the current credentials and remove token file. + + Returns: + True if revocation was successful. + """ + if self.token_file.exists(): + self.token_file.unlink() + log.info("Removed token file") + + self._credentials = None + return True + + +def build_service(service_name: str, version: str, credentials: Credentials) -> "Resource": + """ + Build a Google API service client. + + Args: + service_name: Name of the Google service (e.g., 'gmail', 'drive'). + version: API version (e.g., 'v1', 'v3'). + credentials: Valid OAuth credentials. + + Returns: + Google API service resource. + """ + from googleapiclient.discovery import build + + return build(service_name, version, credentials=credentials) + + +# Global auth instance for convenience +_auth: GoogleAuth | None = None + + +def get_auth() -> GoogleAuth: + """Get or create the global auth instance.""" + global _auth + if _auth is None: + _auth = GoogleAuth() + return _auth + + +def authenticate(force_new: bool = False) -> Credentials: + """Convenience function to authenticate using global auth.""" + return get_auth().authenticate(force_new=force_new) diff --git a/src/google_cl/main/cli.py b/src/google_cl/main/cli.py index 4fa4865..516b6bc 100644 --- a/src/google_cl/main/cli.py +++ b/src/google_cl/main/cli.py @@ -1,21 +1,812 @@ -# -*- coding: utf-8 -*- +""" +Command-line interface for GoogleCL. -"""Console script for google_cl.""" -import sys +This module provides a modern CLI using Typer for interacting with +Google services (Gmail, Drive, Calendar) from the command line. +""" -from typing import Optional -from typing import Sequence +from __future__ import annotations +import logging +from datetime import datetime +from pathlib import Path +from typing import Annotated, Optional -from google_cl.main import application +import typer +from rich.console import Console +from rich.table import Table +from rich.panel import Panel +from rich.text import Text +from rich import print as rprint +from google_cl.main.auth import ( + GoogleAuth, + DEFAULT_CREDENTIALS_FILE, + DEFAULT_TOKEN_FILE, + SCOPES, +) -def main(argv: Optional[Sequence[str]] = None) -> int: - if argv is None: - argv = sys.argv[1:] +# Initialize the main CLI app +app = typer.Typer( + name="googlecl", + help="🔍 Command-line interface for Google services", + no_args_is_help=True, + rich_markup_mode="rich", +) - app = application.Application() - app.run(argv) - return app.exit_code() +# Sub-apps for each service +gmail_app = typer.Typer(help="📧 Gmail operations", no_args_is_help=True) +drive_app = typer.Typer(help="📁 Google Drive operations", no_args_is_help=True) +calendar_app = typer.Typer(help="📅 Google Calendar operations", no_args_is_help=True) +auth_app = typer.Typer(help="🔐 Authentication management", no_args_is_help=True) +# Register sub-apps +app.add_typer(gmail_app, name="gmail") +app.add_typer(drive_app, name="drive") +app.add_typer(calendar_app, name="calendar") +app.add_typer(auth_app, name="auth") +# Console for rich output +console = Console() + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(levelname)s: %(message)s", +) +log = logging.getLogger(__name__) + + +def get_credentials(service: str | None = None) -> GoogleAuth: + """Get authenticated credentials.""" + scopes = None + if service and service in SCOPES: + scopes = SCOPES[service] + + auth = GoogleAuth(scopes=scopes) + auth.authenticate() + return auth + + +# ============================================================================ +# Authentication Commands +# ============================================================================ + + +@auth_app.command("login") +def auth_login( + force: Annotated[bool, typer.Option("--force", "-f", help="Force re-authentication")] = False, +) -> None: + """Authenticate with Google services.""" + try: + auth = GoogleAuth() + auth.authenticate(force_new=force) + rprint("[green]✓ Successfully authenticated![/green]") + except FileNotFoundError as e: + rprint(f"[red]✗ {e}[/red]") + raise typer.Exit(1) + except Exception as e: + rprint(f"[red]✗ Authentication failed: {e}[/red]") + raise typer.Exit(1) + + +@auth_app.command("logout") +def auth_logout() -> None: + """Remove stored authentication tokens.""" + auth = GoogleAuth() + if auth.revoke(): + rprint("[green]✓ Successfully logged out![/green]") + else: + rprint("[yellow]No authentication tokens found.[/yellow]") + + +@auth_app.command("status") +def auth_status() -> None: + """Check authentication status.""" + auth = GoogleAuth() + if auth.is_authenticated(): + rprint("[green]✓ You are authenticated[/green]") + rprint(f" Token file: {DEFAULT_TOKEN_FILE}") + else: + rprint("[yellow]✗ Not authenticated[/yellow]") + rprint(" Run [bold]googlecl auth login[/bold] to authenticate") + + +@auth_app.command("info") +def auth_info() -> None: + """Show configuration paths and setup instructions.""" + table = Table(title="GoogleCL Configuration") + table.add_column("Setting", style="cyan") + table.add_column("Value", style="green") + + table.add_row("Config Directory", str(DEFAULT_CREDENTIALS_FILE.parent)) + table.add_row("Credentials File", str(DEFAULT_CREDENTIALS_FILE)) + table.add_row("Token File", str(DEFAULT_TOKEN_FILE)) + + console.print(table) + + rprint("\n[bold]Setup Instructions:[/bold]") + rprint("1. Go to https://console.cloud.google.com/") + rprint("2. Create a new project or select existing") + rprint("3. Enable the APIs: Gmail, Drive, Calendar") + rprint("4. Go to APIs & Services > Credentials") + rprint("5. Create OAuth 2.0 Client ID (Desktop application)") + rprint(f"6. Download JSON and save to: [cyan]{DEFAULT_CREDENTIALS_FILE}[/cyan]") + rprint("7. Run [bold]googlecl auth login[/bold]") + + +# ============================================================================ +# Gmail Commands +# ============================================================================ + + +@gmail_app.command("inbox") +def gmail_inbox( + count: Annotated[int, typer.Option("--count", "-n", help="Number of emails to show")] = 10, + unread: Annotated[bool, typer.Option("--unread", "-u", help="Show only unread emails")] = False, +) -> None: + """List emails in your inbox.""" + from google_cl.services.gmail import GmailService + + try: + auth = get_credentials("gmail") + gmail = GmailService(auth.credentials) + + label_ids = ["INBOX"] + if unread: + label_ids.append("UNREAD") + + emails = gmail.list_messages(max_results=count, label_ids=label_ids) + + if not emails: + rprint("[yellow]No emails found.[/yellow]") + return + + table = Table(title=f"📧 Inbox ({len(emails)} messages)") + table.add_column("From", style="cyan", max_width=30) + table.add_column("Subject", style="white", max_width=50) + table.add_column("Date", style="dim") + table.add_column("ID", style="dim", max_width=15) + + for email in emails: + # Truncate sender for display + sender = email.sender[:30] + "..." if len(email.sender) > 30 else email.sender + subject = email.subject[:50] + "..." if len(email.subject) > 50 else email.subject + + table.add_row(sender, subject, email.date[:20], email.id[:15]) + + console.print(table) + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@gmail_app.command("read") +def gmail_read( + message_id: Annotated[str, typer.Argument(help="Email message ID to read")], +) -> None: + """Read a specific email.""" + from google_cl.services.gmail import GmailService + + try: + auth = get_credentials("gmail") + gmail = GmailService(auth.credentials) + + email = gmail.get_message(message_id) + + # Create a styled panel for the email + content = Text() + content.append(f"From: ", style="bold cyan") + content.append(f"{email.sender}\n") + content.append(f"To: ", style="bold cyan") + content.append(f"{email.to}\n") + content.append(f"Date: ", style="bold cyan") + content.append(f"{email.date}\n") + content.append(f"Subject: ", style="bold cyan") + content.append(f"{email.subject}\n\n") + content.append(email.body or email.snippet) + + panel = Panel(content, title="📧 Email", border_style="blue") + console.print(panel) + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@gmail_app.command("send") +def gmail_send( + to: Annotated[str, typer.Option("--to", "-t", help="Recipient email address")], + subject: Annotated[str, typer.Option("--subject", "-s", help="Email subject")], + body: Annotated[str, typer.Option("--body", "-b", help="Email body")] = "", + html: Annotated[bool, typer.Option("--html", help="Send as HTML")] = False, +) -> None: + """Send an email.""" + from google_cl.services.gmail import GmailService + + try: + auth = get_credentials("gmail") + gmail = GmailService(auth.credentials) + + result = gmail.send_message(to=to, subject=subject, body=body, html=html) + rprint(f"[green]✓ Email sent successfully![/green]") + rprint(f" Message ID: {result.get('id')}") + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@gmail_app.command("search") +def gmail_search( + query: Annotated[str, typer.Argument(help="Gmail search query")], + count: Annotated[int, typer.Option("--count", "-n", help="Number of results")] = 10, +) -> None: + """Search emails using Gmail query syntax.""" + from google_cl.services.gmail import GmailService + + try: + auth = get_credentials("gmail") + gmail = GmailService(auth.credentials) + + emails = gmail.search(query=query, max_results=count) + + if not emails: + rprint("[yellow]No emails found matching your query.[/yellow]") + return + + table = Table(title=f"🔍 Search Results for '{query}'") + table.add_column("From", style="cyan", max_width=30) + table.add_column("Subject", style="white", max_width=50) + table.add_column("Date", style="dim") + table.add_column("ID", style="dim", max_width=15) + + for email in emails: + sender = email.sender[:30] + "..." if len(email.sender) > 30 else email.sender + subject = email.subject[:50] + "..." if len(email.subject) > 50 else email.subject + table.add_row(sender, subject, email.date[:20], email.id[:15]) + + console.print(table) + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@gmail_app.command("labels") +def gmail_labels() -> None: + """List all Gmail labels.""" + from google_cl.services.gmail import GmailService + + try: + auth = get_credentials("gmail") + gmail = GmailService(auth.credentials) + + labels = gmail.list_labels() + + table = Table(title="📑 Gmail Labels") + table.add_column("Name", style="cyan") + table.add_column("Type", style="dim") + table.add_column("Messages", justify="right") + table.add_column("Unread", justify="right", style="yellow") + + for label in labels: + table.add_row( + label.name, + label.type, + str(label.messages_total), + str(label.messages_unread), + ) + + console.print(table) + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +# ============================================================================ +# Drive Commands +# ============================================================================ + + +@drive_app.command("list") +def drive_list( + count: Annotated[int, typer.Option("--count", "-n", help="Number of files to show")] = 20, + folder: Annotated[Optional[str], typer.Option("--folder", "-f", help="Folder ID")] = None, +) -> None: + """List files in Google Drive.""" + from google_cl.services.drive import DriveService + + try: + auth = get_credentials("drive") + drive = DriveService(auth.credentials) + + files = drive.list_files(max_results=count, folder_id=folder) + + if not files: + rprint("[yellow]No files found.[/yellow]") + return + + table = Table(title="📁 Google Drive Files") + table.add_column("Name", style="cyan", max_width=40) + table.add_column("Type", style="dim", max_width=15) + table.add_column("Size", justify="right") + table.add_column("Modified", style="dim") + table.add_column("ID", style="dim", max_width=20) + + for f in files: + icon = "📁" if f.is_folder else "📄" + name = f"{icon} {f.name}" + if len(name) > 40: + name = name[:37] + "..." + table.add_row( + name, + f.type_name[:15], + f.size_formatted, + f.modified_time[:10], + f.id[:20], + ) + + console.print(table) + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@drive_app.command("search") +def drive_search( + query: Annotated[str, typer.Argument(help="Search query (file name)")], + count: Annotated[int, typer.Option("--count", "-n", help="Number of results")] = 20, +) -> None: + """Search for files in Google Drive.""" + from google_cl.services.drive import DriveService + + try: + auth = get_credentials("drive") + drive = DriveService(auth.credentials) + + files = drive.search_files(name=query, max_results=count) + + if not files: + rprint(f"[yellow]No files found matching '{query}'[/yellow]") + return + + table = Table(title=f"🔍 Search Results for '{query}'") + table.add_column("Name", style="cyan", max_width=40) + table.add_column("Type", style="dim") + table.add_column("Size", justify="right") + table.add_column("ID", style="dim", max_width=25) + + for f in files: + icon = "📁" if f.is_folder else "📄" + table.add_row(f"{icon} {f.name}"[:40], f.type_name, f.size_formatted, f.id[:25]) + + console.print(table) + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@drive_app.command("upload") +def drive_upload( + file_path: Annotated[Path, typer.Argument(help="Local file path to upload")], + name: Annotated[Optional[str], typer.Option("--name", "-n", help="Name in Drive")] = None, + folder: Annotated[Optional[str], typer.Option("--folder", "-f", help="Folder ID")] = None, +) -> None: + """Upload a file to Google Drive.""" + from google_cl.services.drive import DriveService + + try: + auth = get_credentials("drive") + drive = DriveService(auth.credentials) + + result = drive.upload_file(file_path=file_path, name=name, folder_id=folder) + rprint(f"[green]✓ File uploaded successfully![/green]") + rprint(f" Name: {result.name}") + rprint(f" ID: {result.id}") + if result.web_view_link: + rprint(f" Link: {result.web_view_link}") + + except FileNotFoundError: + rprint(f"[red]Error: File not found: {file_path}[/red]") + raise typer.Exit(1) + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@drive_app.command("download") +def drive_download( + file_id: Annotated[str, typer.Argument(help="File ID to download")], + destination: Annotated[ + Path, typer.Option("--output", "-o", help="Output path") + ] = Path("."), +) -> None: + """Download a file from Google Drive.""" + from google_cl.services.drive import DriveService + + try: + auth = get_credentials("drive") + drive = DriveService(auth.credentials) + + # If destination is a directory, use file name + if destination.is_dir(): + file_info = drive.get_file(file_id) + destination = destination / file_info.name + + result = drive.download_file(file_id=file_id, destination=destination) + rprint(f"[green]✓ File downloaded to: {result}[/green]") + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@drive_app.command("mkdir") +def drive_mkdir( + name: Annotated[str, typer.Argument(help="Folder name")], + parent: Annotated[Optional[str], typer.Option("--parent", "-p", help="Parent folder ID")] = None, +) -> None: + """Create a folder in Google Drive.""" + from google_cl.services.drive import DriveService + + try: + auth = get_credentials("drive") + drive = DriveService(auth.credentials) + + result = drive.create_folder(name=name, parent_id=parent) + rprint(f"[green]✓ Folder created![/green]") + rprint(f" Name: {result.name}") + rprint(f" ID: {result.id}") + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@drive_app.command("delete") +def drive_delete( + file_id: Annotated[str, typer.Argument(help="File ID to delete")], + permanent: Annotated[bool, typer.Option("--permanent", help="Permanently delete")] = False, +) -> None: + """Delete a file from Google Drive.""" + from google_cl.services.drive import DriveService + + try: + auth = get_credentials("drive") + drive = DriveService(auth.credentials) + + drive.delete_file(file_id=file_id, permanent=permanent) + if permanent: + rprint(f"[green]✓ File permanently deleted[/green]") + else: + rprint(f"[green]✓ File moved to trash[/green]") + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@drive_app.command("quota") +def drive_quota() -> None: + """Show Google Drive storage quota.""" + from google_cl.services.drive import DriveService + + try: + auth = get_credentials("drive") + drive = DriveService(auth.credentials) + + quota = drive.get_storage_quota() + + def format_size(size: int) -> str: + for unit in ["B", "KB", "MB", "GB", "TB"]: + if size < 1024: + return f"{size:.2f} {unit}" + size /= 1024 + return f"{size:.2f} PB" + + used = int(quota.get("usage", 0)) + limit = int(quota.get("limit", 0)) + percent = (used / limit * 100) if limit > 0 else 0 + + rprint(Panel( + f"[cyan]Used:[/cyan] {format_size(used)}\n" + f"[cyan]Total:[/cyan] {format_size(limit)}\n" + f"[cyan]Usage:[/cyan] {percent:.1f}%", + title="💾 Storage Quota", + )) + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +# ============================================================================ +# Calendar Commands +# ============================================================================ + + +@calendar_app.command("today") +def calendar_today() -> None: + """Show today's events.""" + from google_cl.services.calendar import CalendarService + + try: + auth = get_credentials("calendar") + calendar = CalendarService(auth.credentials) + + events = calendar.get_today_events() + + if not events: + rprint("[yellow]No events scheduled for today.[/yellow]") + return + + table = Table(title="📅 Today's Events") + table.add_column("Time", style="cyan") + table.add_column("Event", style="white", max_width=40) + table.add_column("Location", style="dim", max_width=30) + + for event in events: + if event.all_day: + time_str = "All day" + else: + # Parse and format time + start_str = str(event.start) + if "T" in start_str: + time_str = start_str.split("T")[1][:5] + else: + time_str = start_str + + table.add_row(time_str, event.summary[:40], event.location[:30]) + + console.print(table) + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@calendar_app.command("upcoming") +def calendar_upcoming( + days: Annotated[int, typer.Option("--days", "-d", help="Days to look ahead")] = 7, + count: Annotated[int, typer.Option("--count", "-n", help="Max events to show")] = 20, +) -> None: + """Show upcoming events.""" + from google_cl.services.calendar import CalendarService + + try: + auth = get_credentials("calendar") + calendar = CalendarService(auth.credentials) + + events = calendar.get_upcoming_events(days=days, max_results=count) + + if not events: + rprint(f"[yellow]No events in the next {days} days.[/yellow]") + return + + table = Table(title=f"📅 Upcoming Events (Next {days} days)") + table.add_column("Date", style="cyan") + table.add_column("Time", style="cyan") + table.add_column("Event", style="white", max_width=35) + table.add_column("Location", style="dim", max_width=25) + + for event in events: + start_str = str(event.start) + if event.all_day: + date_str = start_str[:10] + time_str = "All day" + elif "T" in start_str: + parts = start_str.split("T") + date_str = parts[0] + time_str = parts[1][:5] + else: + date_str = start_str[:10] + time_str = "-" + + table.add_row(date_str, time_str, event.summary[:35], event.location[:25]) + + console.print(table) + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@calendar_app.command("add") +def calendar_add( + title: Annotated[str, typer.Argument(help="Event title")], + start: Annotated[str, typer.Option("--start", "-s", help="Start time (YYYY-MM-DD HH:MM)")], + end: Annotated[Optional[str], typer.Option("--end", "-e", help="End time")] = None, + description: Annotated[str, typer.Option("--desc", "-d", help="Description")] = "", + location: Annotated[str, typer.Option("--location", "-l", help="Location")] = "", + all_day: Annotated[bool, typer.Option("--all-day", help="All-day event")] = False, +) -> None: + """Create a new calendar event.""" + from google_cl.services.calendar import CalendarService + + try: + auth = get_credentials("calendar") + calendar = CalendarService(auth.credentials) + + # Parse start time + try: + start_dt = datetime.fromisoformat(start.replace(" ", "T")) + except ValueError: + rprint("[red]Invalid start time format. Use: YYYY-MM-DD HH:MM[/red]") + raise typer.Exit(1) + + # Parse end time if provided + end_dt = None + if end: + try: + end_dt = datetime.fromisoformat(end.replace(" ", "T")) + except ValueError: + rprint("[red]Invalid end time format. Use: YYYY-MM-DD HH:MM[/red]") + raise typer.Exit(1) + + event = calendar.create_event( + summary=title, + start=start_dt, + end=end_dt, + description=description, + location=location, + all_day=all_day, + ) + + rprint(f"[green]✓ Event created![/green]") + rprint(f" Title: {event.summary}") + rprint(f" ID: {event.id}") + if event.html_link: + rprint(f" Link: {event.html_link}") + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@calendar_app.command("quick") +def calendar_quick( + text: Annotated[str, typer.Argument(help="Natural language event description")], +) -> None: + """Create event from natural language (e.g., 'Meeting tomorrow at 3pm').""" + from google_cl.services.calendar import CalendarService + + try: + auth = get_credentials("calendar") + calendar = CalendarService(auth.credentials) + + event = calendar.quick_add(text) + + rprint(f"[green]✓ Event created![/green]") + rprint(f" Title: {event.summary}") + rprint(f" Start: {event.start}") + if event.html_link: + rprint(f" Link: {event.html_link}") + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@calendar_app.command("delete") +def calendar_delete( + event_id: Annotated[str, typer.Argument(help="Event ID to delete")], +) -> None: + """Delete a calendar event.""" + from google_cl.services.calendar import CalendarService + + try: + auth = get_credentials("calendar") + calendar = CalendarService(auth.credentials) + + calendar.delete_event(event_id) + rprint(f"[green]✓ Event deleted[/green]") + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +@calendar_app.command("calendars") +def calendar_list() -> None: + """List all calendars.""" + from google_cl.services.calendar import CalendarService + + try: + auth = get_credentials("calendar") + calendar = CalendarService(auth.credentials) + + calendars = calendar.list_calendars() + + table = Table(title="📅 Your Calendars") + table.add_column("Name", style="cyan") + table.add_column("Primary", style="green") + table.add_column("Timezone", style="dim") + table.add_column("ID", style="dim", max_width=30) + + for cal in calendars: + primary = "✓" if cal.is_primary else "" + table.add_row(cal.summary, primary, cal.time_zone, cal.id[:30]) + + console.print(table) + + except Exception as e: + rprint(f"[red]Error: {e}[/red]") + raise typer.Exit(1) + + +# ============================================================================ +# Main App Commands +# ============================================================================ + + +@app.command() +def version() -> None: + """Show version information.""" + from google_cl import __version__ + rprint(f"[cyan]googlecl[/cyan] version [green]{__version__}[/green]") + + +@app.command() +def status() -> None: + """Show status of all services.""" + from google_cl.services.gmail import GmailService + from google_cl.services.drive import DriveService + from google_cl.services.calendar import CalendarService + + auth = GoogleAuth() + + table = Table(title="🔍 GoogleCL Status") + table.add_column("Service", style="cyan") + table.add_column("Status", style="white") + table.add_column("Details", style="dim") + + # Check authentication + if auth.is_authenticated(): + table.add_row("Authentication", "[green]✓ Connected[/green]", str(DEFAULT_TOKEN_FILE)) + + # Test each service + try: + gmail = GmailService(auth.credentials) + profile = gmail.get_profile() + table.add_row("Gmail", "[green]✓ Working[/green]", profile.get("emailAddress", "")) + except Exception as e: + table.add_row("Gmail", "[red]✗ Error[/red]", str(e)[:40]) + + try: + drive = DriveService(auth.credentials) + if drive.test_connection(): + table.add_row("Drive", "[green]✓ Working[/green]", "") + else: + table.add_row("Drive", "[red]✗ Error[/red]", "Connection failed") + except Exception as e: + table.add_row("Drive", "[red]✗ Error[/red]", str(e)[:40]) + + try: + calendar = CalendarService(auth.credentials) + if calendar.test_connection(): + table.add_row("Calendar", "[green]✓ Working[/green]", "") + else: + table.add_row("Calendar", "[red]✗ Error[/red]", "Connection failed") + except Exception as e: + table.add_row("Calendar", "[red]✗ Error[/red]", str(e)[:40]) + else: + table.add_row("Authentication", "[yellow]✗ Not connected[/yellow]", "Run: googlecl auth login") + table.add_row("Gmail", "[dim]- Unknown[/dim]", "") + table.add_row("Drive", "[dim]- Unknown[/dim]", "") + table.add_row("Calendar", "[dim]- Unknown[/dim]", "") + + console.print(table) + + +def main() -> None: + """Main entry point for the CLI.""" + app() + + +if __name__ == "__main__": + main() diff --git a/src/google_cl/services/__init__.py b/src/google_cl/services/__init__.py new file mode 100644 index 0000000..63a6268 --- /dev/null +++ b/src/google_cl/services/__init__.py @@ -0,0 +1,8 @@ +"""Google services implementations.""" + +from google_cl.services.gmail import GmailService +from google_cl.services.drive import DriveService +from google_cl.services.calendar import CalendarService + +__all__ = ["GmailService", "DriveService", "CalendarService"] + diff --git a/src/google_cl/services/base.py b/src/google_cl/services/base.py new file mode 100644 index 0000000..ccee746 --- /dev/null +++ b/src/google_cl/services/base.py @@ -0,0 +1,91 @@ +"""Base service class for Google API services.""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from google.oauth2.credentials import Credentials + from googleapiclient.discovery import Resource + + +log = logging.getLogger(__name__) + + +class BaseService(ABC): + """Base class for all Google API services.""" + + service_name: str = "" + service_version: str = "" + + def __init__(self, credentials: Credentials) -> None: + """ + Initialize the service with credentials. + + Args: + credentials: Valid Google OAuth credentials. + """ + self.credentials = credentials + self._service: Resource | None = None + + @property + def service(self) -> Resource: + """Get or create the service client.""" + if self._service is None: + self._service = self._build_service() + return self._service + + def _build_service(self) -> Resource: + """Build the Google API service client.""" + from googleapiclient.discovery import build + + log.debug("Building %s service (v%s)", self.service_name, self.service_version) + return build( + self.service_name, + self.service_version, + credentials=self.credentials, + ) + + @abstractmethod + def test_connection(self) -> bool: + """Test the connection to the service.""" + pass + + def _handle_error(self, error: Exception, operation: str) -> None: + """Handle API errors with consistent logging.""" + log.error("Error during %s: %s", operation, error) + raise + + def _paginate( + self, + request: Any, + items_key: str, + max_results: int | None = None, + ) -> list[Any]: + """ + Helper to paginate through API results. + + Args: + request: Initial API request object. + items_key: Key in response containing items. + max_results: Maximum number of results to return. + + Returns: + List of all items from paginated results. + """ + all_items: list[Any] = [] + + while request is not None: + response = request.execute() + items = response.get(items_key, []) + all_items.extend(items) + + if max_results and len(all_items) >= max_results: + return all_items[:max_results] + + request = self.service.list_next(request, response) + + return all_items + diff --git a/src/google_cl/services/calendar.py b/src/google_cl/services/calendar.py new file mode 100644 index 0000000..183b35c --- /dev/null +++ b/src/google_cl/services/calendar.py @@ -0,0 +1,386 @@ +"""Google Calendar service implementation.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any + +from google_cl.services.base import BaseService + +if TYPE_CHECKING: + from google.oauth2.credentials import Credentials + + +log = logging.getLogger(__name__) + + +@dataclass +class Calendar: + """Represents a Google Calendar.""" + + id: str + summary: str + description: str + time_zone: str + is_primary: bool + background_color: str + foreground_color: str + + @classmethod + def from_api_response(cls, cal: dict[str, Any]) -> "Calendar": + """Create Calendar from API response.""" + return cls( + id=cal.get("id", ""), + summary=cal.get("summary", ""), + description=cal.get("description", ""), + time_zone=cal.get("timeZone", ""), + is_primary=cal.get("primary", False), + background_color=cal.get("backgroundColor", ""), + foreground_color=cal.get("foregroundColor", ""), + ) + + +@dataclass +class Event: + """Represents a calendar event.""" + + id: str + summary: str + description: str + location: str + start: datetime | str + end: datetime | str + all_day: bool + status: str + html_link: str + creator: str + attendees: list[dict[str, Any]] = field(default_factory=list) + recurrence: list[str] | None = None + + @classmethod + def from_api_response(cls, event: dict[str, Any]) -> "Event": + """Create Event from API response.""" + # Handle start time (can be date or dateTime) + start_data = event.get("start", {}) + if "dateTime" in start_data: + start = start_data["dateTime"] + all_day = False + else: + start = start_data.get("date", "") + all_day = True + + # Handle end time + end_data = event.get("end", {}) + if "dateTime" in end_data: + end = end_data["dateTime"] + else: + end = end_data.get("date", "") + + creator = event.get("creator", {}) + + return cls( + id=event.get("id", ""), + summary=event.get("summary", "(No title)"), + description=event.get("description", ""), + location=event.get("location", ""), + start=start, + end=end, + all_day=all_day, + status=event.get("status", ""), + html_link=event.get("htmlLink", ""), + creator=creator.get("email", ""), + attendees=event.get("attendees", []), + recurrence=event.get("recurrence"), + ) + + +class CalendarService(BaseService): + """Service for interacting with Google Calendar API.""" + + service_name = "calendar" + service_version = "v3" + + def __init__(self, credentials: Credentials) -> None: + """Initialize Calendar service.""" + super().__init__(credentials) + + def test_connection(self) -> bool: + """Test the connection by listing calendars.""" + try: + calendars = self.service.calendarList().list().execute() + log.info("Connected to Calendar: %d calendars found", len(calendars.get("items", []))) + return True + except Exception as e: + log.error("Failed to connect to Calendar: %s", e) + return False + + def list_calendars(self) -> list[Calendar]: + """ + List all calendars the user has access to. + + Returns: + List of Calendar objects. + """ + response = self.service.calendarList().list().execute() + return [Calendar.from_api_response(cal) for cal in response.get("items", [])] + + def get_primary_calendar(self) -> Calendar | None: + """Get the user's primary calendar.""" + calendars = self.list_calendars() + for cal in calendars: + if cal.is_primary: + return cal + return calendars[0] if calendars else None + + def list_events( + self, + calendar_id: str = "primary", + max_results: int = 10, + time_min: datetime | None = None, + time_max: datetime | None = None, + query: str | None = None, + single_events: bool = True, + order_by: str = "startTime", + ) -> list[Event]: + """ + List events from a calendar. + + Args: + calendar_id: Calendar ID ('primary' for the main calendar). + max_results: Maximum number of events to return. + time_min: Start of time range (defaults to now). + time_max: End of time range. + query: Free text search query. + single_events: Expand recurring events into instances. + order_by: Order by 'startTime' or 'updated'. + + Returns: + List of Event objects. + """ + if time_min is None: + time_min = datetime.utcnow() + + kwargs: dict[str, Any] = { + "calendarId": calendar_id, + "maxResults": max_results, + "timeMin": time_min.isoformat() + "Z", + "singleEvents": single_events, + "orderBy": order_by, + } + + if time_max: + kwargs["timeMax"] = time_max.isoformat() + "Z" + if query: + kwargs["q"] = query + + response = self.service.events().list(**kwargs).execute() + return [Event.from_api_response(event) for event in response.get("items", [])] + + def get_event(self, event_id: str, calendar_id: str = "primary") -> Event: + """ + Get a specific event. + + Args: + event_id: The ID of the event. + calendar_id: Calendar ID. + + Returns: + Event object. + """ + event = self.service.events().get(calendarId=calendar_id, eventId=event_id).execute() + return Event.from_api_response(event) + + def create_event( + self, + summary: str, + start: datetime, + end: datetime | None = None, + description: str = "", + location: str = "", + calendar_id: str = "primary", + attendees: list[str] | None = None, + all_day: bool = False, + timezone: str | None = None, + ) -> Event: + """ + Create a new calendar event. + + Args: + summary: Event title. + start: Start time. + end: End time (defaults to 1 hour after start). + description: Event description. + location: Event location. + calendar_id: Calendar ID. + attendees: List of email addresses. + all_day: Create an all-day event. + timezone: Timezone for the event. + + Returns: + Created Event object. + """ + if end is None: + end = start + timedelta(hours=1) + + event_body: dict[str, Any] = { + "summary": summary, + "description": description, + "location": location, + } + + if all_day: + event_body["start"] = {"date": start.strftime("%Y-%m-%d")} + event_body["end"] = {"date": end.strftime("%Y-%m-%d")} + else: + event_body["start"] = {"dateTime": start.isoformat()} + event_body["end"] = {"dateTime": end.isoformat()} + if timezone: + event_body["start"]["timeZone"] = timezone + event_body["end"]["timeZone"] = timezone + + if attendees: + event_body["attendees"] = [{"email": email} for email in attendees] + + created = ( + self.service.events().insert(calendarId=calendar_id, body=event_body).execute() + ) + + log.info("Created event: %s", summary) + return Event.from_api_response(created) + + def quick_add(self, text: str, calendar_id: str = "primary") -> Event: + """ + Create an event from natural language text. + + Args: + text: Natural language description (e.g., "Meeting tomorrow at 3pm"). + calendar_id: Calendar ID. + + Returns: + Created Event object. + """ + created = ( + self.service.events().quickAdd(calendarId=calendar_id, text=text).execute() + ) + log.info("Quick added event: %s", text) + return Event.from_api_response(created) + + def update_event( + self, + event_id: str, + calendar_id: str = "primary", + summary: str | None = None, + description: str | None = None, + location: str | None = None, + start: datetime | None = None, + end: datetime | None = None, + ) -> Event: + """ + Update an existing event. + + Args: + event_id: The ID of the event to update. + calendar_id: Calendar ID. + summary: New title (if changing). + description: New description (if changing). + location: New location (if changing). + start: New start time (if changing). + end: New end time (if changing). + + Returns: + Updated Event object. + """ + # Get existing event + event = self.service.events().get(calendarId=calendar_id, eventId=event_id).execute() + + # Update fields + if summary is not None: + event["summary"] = summary + if description is not None: + event["description"] = description + if location is not None: + event["location"] = location + if start is not None: + if "date" in event.get("start", {}): + event["start"] = {"date": start.strftime("%Y-%m-%d")} + else: + event["start"] = {"dateTime": start.isoformat()} + if end is not None: + if "date" in event.get("end", {}): + event["end"] = {"date": end.strftime("%Y-%m-%d")} + else: + event["end"] = {"dateTime": end.isoformat()} + + updated = ( + self.service.events() + .update(calendarId=calendar_id, eventId=event_id, body=event) + .execute() + ) + + log.info("Updated event: %s", event_id) + return Event.from_api_response(updated) + + def delete_event(self, event_id: str, calendar_id: str = "primary") -> bool: + """ + Delete an event. + + Args: + event_id: The ID of the event to delete. + calendar_id: Calendar ID. + + Returns: + True if successful. + """ + self.service.events().delete(calendarId=calendar_id, eventId=event_id).execute() + log.info("Deleted event: %s", event_id) + return True + + def get_upcoming_events( + self, + days: int = 7, + calendar_id: str = "primary", + max_results: int = 20, + ) -> list[Event]: + """ + Get upcoming events for the next N days. + + Args: + days: Number of days to look ahead. + calendar_id: Calendar ID. + max_results: Maximum number of events. + + Returns: + List of upcoming Event objects. + """ + time_min = datetime.utcnow() + time_max = time_min + timedelta(days=days) + return self.list_events( + calendar_id=calendar_id, + max_results=max_results, + time_min=time_min, + time_max=time_max, + ) + + def get_today_events(self, calendar_id: str = "primary") -> list[Event]: + """ + Get all events for today. + + Args: + calendar_id: Calendar ID. + + Returns: + List of today's Event objects. + """ + now = datetime.utcnow() + start_of_day = now.replace(hour=0, minute=0, second=0, microsecond=0) + end_of_day = start_of_day + timedelta(days=1) + + return self.list_events( + calendar_id=calendar_id, + time_min=start_of_day, + time_max=end_of_day, + max_results=50, + ) + diff --git a/src/google_cl/services/drive.py b/src/google_cl/services/drive.py new file mode 100644 index 0000000..c40b8bf --- /dev/null +++ b/src/google_cl/services/drive.py @@ -0,0 +1,403 @@ +"""Google Drive service implementation.""" + +from __future__ import annotations + +import io +import logging +import mimetypes +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload + +from google_cl.services.base import BaseService + +if TYPE_CHECKING: + from google.oauth2.credentials import Credentials + + +log = logging.getLogger(__name__) + + +# Common MIME type mappings for Google Workspace files +GOOGLE_MIME_TYPES = { + "application/vnd.google-apps.document": "Google Docs", + "application/vnd.google-apps.spreadsheet": "Google Sheets", + "application/vnd.google-apps.presentation": "Google Slides", + "application/vnd.google-apps.folder": "Folder", + "application/vnd.google-apps.form": "Google Form", + "application/vnd.google-apps.drawing": "Google Drawing", +} + +# Export formats for Google Workspace files +EXPORT_FORMATS = { + "application/vnd.google-apps.document": { + "pdf": "application/pdf", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "txt": "text/plain", + "html": "text/html", + }, + "application/vnd.google-apps.spreadsheet": { + "pdf": "application/pdf", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "csv": "text/csv", + }, + "application/vnd.google-apps.presentation": { + "pdf": "application/pdf", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + }, +} + + +@dataclass +class DriveFile: + """Represents a file in Google Drive.""" + + id: str + name: str + mime_type: str + size: int | None + created_time: str + modified_time: str + parents: list[str] | None + web_view_link: str | None + is_folder: bool + + @classmethod + def from_api_response(cls, file: dict[str, Any]) -> "DriveFile": + """Create DriveFile from Drive API response.""" + mime_type = file.get("mimeType", "") + return cls( + id=file.get("id", ""), + name=file.get("name", ""), + mime_type=mime_type, + size=int(file["size"]) if "size" in file else None, + created_time=file.get("createdTime", ""), + modified_time=file.get("modifiedTime", ""), + parents=file.get("parents"), + web_view_link=file.get("webViewLink"), + is_folder=mime_type == "application/vnd.google-apps.folder", + ) + + @property + def type_name(self) -> str: + """Get human-readable type name.""" + if self.mime_type in GOOGLE_MIME_TYPES: + return GOOGLE_MIME_TYPES[self.mime_type] + return self.mime_type.split("/")[-1].upper() if self.mime_type else "Unknown" + + @property + def size_formatted(self) -> str: + """Get formatted file size.""" + if self.size is None: + return "-" + for unit in ["B", "KB", "MB", "GB", "TB"]: + if self.size < 1024: + return f"{self.size:.1f} {unit}" + self.size //= 1024 + return f"{self.size:.1f} PB" + + +class DriveService(BaseService): + """Service for interacting with Google Drive API.""" + + service_name = "drive" + service_version = "v3" + + def __init__(self, credentials: Credentials) -> None: + """Initialize Drive service.""" + super().__init__(credentials) + + def test_connection(self) -> bool: + """Test the connection by getting storage quota.""" + try: + about = self.service.about().get(fields="user,storageQuota").execute() + user = about.get("user", {}) + log.info("Connected to Drive: %s", user.get("emailAddress")) + return True + except Exception as e: + log.error("Failed to connect to Drive: %s", e) + return False + + def get_storage_quota(self) -> dict[str, Any]: + """Get storage quota information.""" + about = self.service.about().get(fields="storageQuota").execute() + return about.get("storageQuota", {}) + + def list_files( + self, + max_results: int = 20, + folder_id: str | None = None, + query: str | None = None, + order_by: str = "modifiedTime desc", + include_trashed: bool = False, + ) -> list[DriveFile]: + """ + List files in Google Drive. + + Args: + max_results: Maximum number of files to return. + folder_id: Only list files in this folder. + query: Additional Drive API query string. + order_by: Field to sort by. + include_trashed: Include trashed files. + + Returns: + List of DriveFile objects. + """ + # Build query + queries = [] + if not include_trashed: + queries.append("trashed = false") + if folder_id: + queries.append(f"'{folder_id}' in parents") + if query: + queries.append(query) + + q = " and ".join(queries) if queries else None + + response = ( + self.service.files() + .list( + pageSize=max_results, + q=q, + orderBy=order_by, + fields="files(id,name,mimeType,size,createdTime,modifiedTime,parents,webViewLink)", + ) + .execute() + ) + + return [DriveFile.from_api_response(f) for f in response.get("files", [])] + + def get_file(self, file_id: str) -> DriveFile: + """ + Get details about a specific file. + + Args: + file_id: The ID of the file. + + Returns: + DriveFile object. + """ + file = ( + self.service.files() + .get( + fileId=file_id, + fields="id,name,mimeType,size,createdTime,modifiedTime,parents,webViewLink", + ) + .execute() + ) + return DriveFile.from_api_response(file) + + def search_files( + self, + name: str | None = None, + mime_type: str | None = None, + full_text: str | None = None, + max_results: int = 20, + ) -> list[DriveFile]: + """ + Search for files in Drive. + + Args: + name: Search by file name (contains). + mime_type: Filter by MIME type. + full_text: Full-text search in file content. + max_results: Maximum number of results. + + Returns: + List of matching DriveFile objects. + """ + queries = ["trashed = false"] + + if name: + queries.append(f"name contains '{name}'") + if mime_type: + queries.append(f"mimeType = '{mime_type}'") + if full_text: + queries.append(f"fullText contains '{full_text}'") + + return self.list_files(max_results=max_results, query=" and ".join(queries)) + + def download_file(self, file_id: str, destination: Path) -> Path: + """ + Download a file from Drive. + + Args: + file_id: The ID of the file to download. + destination: Local path to save the file. + + Returns: + Path to the downloaded file. + """ + # Get file info first + file_info = self.get_file(file_id) + + # Handle Google Workspace files (need export) + if file_info.mime_type.startswith("application/vnd.google-apps"): + return self._export_file(file_id, file_info.mime_type, destination) + + # Regular file download + request = self.service.files().get_media(fileId=file_id) + fh = io.BytesIO() + downloader = MediaIoBaseDownload(fh, request) + + done = False + while not done: + status, done = downloader.next_chunk() + log.debug("Download progress: %d%%", int(status.progress() * 100)) + + destination.write_bytes(fh.getvalue()) + log.info("Downloaded %s to %s", file_info.name, destination) + return destination + + def _export_file(self, file_id: str, mime_type: str, destination: Path) -> Path: + """Export a Google Workspace file to a downloadable format.""" + # Determine export format + export_formats = EXPORT_FORMATS.get(mime_type, {}) + if not export_formats: + raise ValueError(f"Cannot export files of type: {mime_type}") + + # Default to PDF if available + export_mime = export_formats.get("pdf", list(export_formats.values())[0]) + + request = self.service.files().export_media(fileId=file_id, mimeType=export_mime) + fh = io.BytesIO() + downloader = MediaIoBaseDownload(fh, request) + + done = False + while not done: + status, done = downloader.next_chunk() + + destination.write_bytes(fh.getvalue()) + log.info("Exported file to %s", destination) + return destination + + def upload_file( + self, + file_path: Path, + name: str | None = None, + folder_id: str | None = None, + mime_type: str | None = None, + ) -> DriveFile: + """ + Upload a file to Google Drive. + + Args: + file_path: Local file path to upload. + name: Name for the file in Drive (defaults to local filename). + folder_id: Parent folder ID. + mime_type: MIME type (auto-detected if not provided). + + Returns: + DriveFile object for the uploaded file. + """ + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + if name is None: + name = file_path.name + + if mime_type is None: + mime_type = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" + + file_metadata: dict[str, Any] = {"name": name} + if folder_id: + file_metadata["parents"] = [folder_id] + + media = MediaFileUpload(str(file_path), mimetype=mime_type, resumable=True) + + file = ( + self.service.files() + .create( + body=file_metadata, + media_body=media, + fields="id,name,mimeType,size,createdTime,modifiedTime,parents,webViewLink", + ) + .execute() + ) + + log.info("Uploaded %s to Drive", name) + return DriveFile.from_api_response(file) + + def create_folder(self, name: str, parent_id: str | None = None) -> DriveFile: + """ + Create a folder in Google Drive. + + Args: + name: Name of the folder. + parent_id: Parent folder ID. + + Returns: + DriveFile object for the created folder. + """ + file_metadata: dict[str, Any] = { + "name": name, + "mimeType": "application/vnd.google-apps.folder", + } + if parent_id: + file_metadata["parents"] = [parent_id] + + folder = ( + self.service.files() + .create( + body=file_metadata, + fields="id,name,mimeType,size,createdTime,modifiedTime,parents,webViewLink", + ) + .execute() + ) + + log.info("Created folder: %s", name) + return DriveFile.from_api_response(folder) + + def delete_file(self, file_id: str, permanent: bool = False) -> bool: + """ + Delete a file (move to trash or permanently delete). + + Args: + file_id: The ID of the file to delete. + permanent: If True, permanently delete. Otherwise move to trash. + + Returns: + True if successful. + """ + if permanent: + self.service.files().delete(fileId=file_id).execute() + log.info("Permanently deleted file: %s", file_id) + else: + self.service.files().update(fileId=file_id, body={"trashed": True}).execute() + log.info("Moved file to trash: %s", file_id) + return True + + def share_file( + self, + file_id: str, + email: str, + role: str = "reader", + send_notification: bool = True, + ) -> dict[str, Any]: + """ + Share a file with a user. + + Args: + file_id: The ID of the file to share. + email: Email address of the user to share with. + role: Permission role ('reader', 'writer', 'commenter'). + send_notification: Whether to send email notification. + + Returns: + Permission object. + """ + permission = {"type": "user", "role": role, "emailAddress": email} + + return ( + self.service.permissions() + .create( + fileId=file_id, + body=permission, + sendNotificationEmail=send_notification, + ) + .execute() + ) + diff --git a/src/google_cl/services/gmail.py b/src/google_cl/services/gmail.py new file mode 100644 index 0000000..83667cb --- /dev/null +++ b/src/google_cl/services/gmail.py @@ -0,0 +1,302 @@ +"""Gmail service implementation.""" + +from __future__ import annotations + +import base64 +import logging +from dataclasses import dataclass +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import TYPE_CHECKING, Any + +from google_cl.services.base import BaseService + +if TYPE_CHECKING: + from google.oauth2.credentials import Credentials + + +log = logging.getLogger(__name__) + + +@dataclass +class Email: + """Represents an email message.""" + + id: str + thread_id: str + subject: str + sender: str + to: str + date: str + snippet: str + body: str = "" + labels: list[str] | None = None + + @classmethod + def from_api_response(cls, msg: dict[str, Any]) -> "Email": + """Create Email from Gmail API response.""" + headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} + + # Extract body + body = "" + payload = msg.get("payload", {}) + if "body" in payload and payload["body"].get("data"): + body = base64.urlsafe_b64decode(payload["body"]["data"]).decode("utf-8", errors="ignore") + elif "parts" in payload: + for part in payload["parts"]: + if part.get("mimeType") == "text/plain" and part.get("body", {}).get("data"): + body = base64.urlsafe_b64decode(part["body"]["data"]).decode( + "utf-8", errors="ignore" + ) + break + + return cls( + id=msg.get("id", ""), + thread_id=msg.get("threadId", ""), + subject=headers.get("Subject", "(No Subject)"), + sender=headers.get("From", "Unknown"), + to=headers.get("To", ""), + date=headers.get("Date", ""), + snippet=msg.get("snippet", ""), + body=body, + labels=msg.get("labelIds"), + ) + + +@dataclass +class Label: + """Represents a Gmail label.""" + + id: str + name: str + type: str + messages_total: int = 0 + messages_unread: int = 0 + + @classmethod + def from_api_response(cls, label: dict[str, Any]) -> "Label": + """Create Label from Gmail API response.""" + return cls( + id=label.get("id", ""), + name=label.get("name", ""), + type=label.get("type", ""), + messages_total=label.get("messagesTotal", 0), + messages_unread=label.get("messagesUnread", 0), + ) + + +class GmailService(BaseService): + """Service for interacting with Gmail API.""" + + service_name = "gmail" + service_version = "v1" + + def __init__(self, credentials: Credentials) -> None: + """Initialize Gmail service.""" + super().__init__(credentials) + self.user_id = "me" # Special value for authenticated user + + def test_connection(self) -> bool: + """Test the connection by getting user profile.""" + try: + profile = self.service.users().getProfile(userId=self.user_id).execute() + log.info("Connected to Gmail: %s", profile.get("emailAddress")) + return True + except Exception as e: + log.error("Failed to connect to Gmail: %s", e) + return False + + def get_profile(self) -> dict[str, Any]: + """Get the authenticated user's Gmail profile.""" + return self.service.users().getProfile(userId=self.user_id).execute() + + def list_labels(self) -> list[Label]: + """ + List all labels in the user's mailbox. + + Returns: + List of Label objects. + """ + response = self.service.users().labels().list(userId=self.user_id).execute() + labels = response.get("labels", []) + + result = [] + for label in labels: + # Get full label details + try: + full_label = ( + self.service.users().labels().get(userId=self.user_id, id=label["id"]).execute() + ) + result.append(Label.from_api_response(full_label)) + except Exception: + result.append(Label.from_api_response(label)) + + return result + + def list_messages( + self, + max_results: int = 10, + label_ids: list[str] | None = None, + query: str | None = None, + include_spam_trash: bool = False, + ) -> list[Email]: + """ + List messages in the user's mailbox. + + Args: + max_results: Maximum number of messages to return. + label_ids: Only return messages with these labels. + query: Gmail search query (same as web interface). + include_spam_trash: Include spam and trash in results. + + Returns: + List of Email objects. + """ + kwargs: dict[str, Any] = { + "userId": self.user_id, + "maxResults": max_results, + "includeSpamTrash": include_spam_trash, + } + + if label_ids: + kwargs["labelIds"] = label_ids + if query: + kwargs["q"] = query + + response = self.service.users().messages().list(**kwargs).execute() + messages = response.get("messages", []) + + # Fetch full message details + emails = [] + for msg in messages: + full_msg = ( + self.service.users() + .messages() + .get(userId=self.user_id, id=msg["id"], format="full") + .execute() + ) + emails.append(Email.from_api_response(full_msg)) + + return emails + + def get_message(self, message_id: str) -> Email: + """ + Get a specific message by ID. + + Args: + message_id: The ID of the message to retrieve. + + Returns: + Email object with full message details. + """ + msg = ( + self.service.users() + .messages() + .get(userId=self.user_id, id=message_id, format="full") + .execute() + ) + return Email.from_api_response(msg) + + def send_message( + self, + to: str, + subject: str, + body: str, + html: bool = False, + ) -> dict[str, Any]: + """ + Send an email message. + + Args: + to: Recipient email address. + subject: Email subject. + body: Email body content. + html: If True, body is treated as HTML. + + Returns: + API response with sent message details. + """ + message = MIMEMultipart("alternative") + message["to"] = to + message["subject"] = subject + + if html: + message.attach(MIMEText(body, "html")) + else: + message.attach(MIMEText(body, "plain")) + + # Encode the message + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + + return ( + self.service.users() + .messages() + .send(userId=self.user_id, body={"raw": raw}) + .execute() + ) + + def trash_message(self, message_id: str) -> dict[str, Any]: + """ + Move a message to trash. + + Args: + message_id: The ID of the message to trash. + + Returns: + API response. + """ + return ( + self.service.users() + .messages() + .trash(userId=self.user_id, id=message_id) + .execute() + ) + + def untrash_message(self, message_id: str) -> dict[str, Any]: + """ + Remove a message from trash. + + Args: + message_id: The ID of the message to untrash. + + Returns: + API response. + """ + return ( + self.service.users() + .messages() + .untrash(userId=self.user_id, id=message_id) + .execute() + ) + + def mark_as_read(self, message_id: str) -> dict[str, Any]: + """Mark a message as read by removing UNREAD label.""" + return ( + self.service.users() + .messages() + .modify(userId=self.user_id, id=message_id, body={"removeLabelIds": ["UNREAD"]}) + .execute() + ) + + def mark_as_unread(self, message_id: str) -> dict[str, Any]: + """Mark a message as unread by adding UNREAD label.""" + return ( + self.service.users() + .messages() + .modify(userId=self.user_id, id=message_id, body={"addLabelIds": ["UNREAD"]}) + .execute() + ) + + def search(self, query: str, max_results: int = 10) -> list[Email]: + """ + Search for messages using Gmail query syntax. + + Args: + query: Gmail search query (e.g., "from:user@example.com", "is:unread"). + max_results: Maximum number of results. + + Returns: + List of matching Email objects. + """ + return self.list_messages(max_results=max_results, query=query) + diff --git a/tests/test_google_cl.py b/tests/test_google_cl.py index 9565545..d25e458 100644 --- a/tests/test_google_cl.py +++ b/tests/test_google_cl.py @@ -1,38 +1,224 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -"""Tests for `google_cl` package.""" +"""Tests for GoogleCL package.""" import pytest +from typer.testing import CliRunner +from unittest.mock import patch, MagicMock + +from google_cl.main.cli import app +from google_cl import __version__ -from click.testing import CliRunner -from google_cl import google_cl -from google_cl import cli +runner = CliRunner() @pytest.fixture -def response(): - """Sample pytest fixture. - - See more at: http://doc.pytest.org/en/latest/fixture.html - """ - # import requests - # return requests.get('https://github.com/audreyr/cookiecutter-pypackage') - - -def test_content(response): - """Sample pytest test function with the pytest fixture as an argument.""" - # from bs4 import BeautifulSoup - # assert 'GitHub' in BeautifulSoup(response.content).title.string - - -def test_command_line_interface(): - """Test the CLI.""" - runner = CliRunner() - result = runner.invoke(cli.main) - assert result.exit_code == 0 - assert 'google_cl.cli.main' in result.output - help_result = runner.invoke(cli.main, ['--help']) - assert help_result.exit_code == 0 - assert '--help Show this message and exit.' in help_result.output +def mock_auth(): + """Mock authentication for tests.""" + with patch("google_cl.main.cli.GoogleAuth") as mock: + mock_instance = MagicMock() + mock_instance.is_authenticated.return_value = True + mock_instance.credentials = MagicMock() + mock.return_value = mock_instance + yield mock + + +class TestCLI: + """Tests for the CLI interface.""" + + def test_version(self): + """Test version command.""" + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert __version__ in result.stdout + + def test_help(self): + """Test help output.""" + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + assert "Command-line interface for Google services" in result.stdout + + def test_gmail_help(self): + """Test Gmail subcommand help.""" + result = runner.invoke(app, ["gmail", "--help"]) + assert result.exit_code == 0 + assert "Gmail operations" in result.stdout + + def test_drive_help(self): + """Test Drive subcommand help.""" + result = runner.invoke(app, ["drive", "--help"]) + assert result.exit_code == 0 + assert "Google Drive operations" in result.stdout + + def test_calendar_help(self): + """Test Calendar subcommand help.""" + result = runner.invoke(app, ["calendar", "--help"]) + assert result.exit_code == 0 + assert "Google Calendar operations" in result.stdout + + def test_auth_help(self): + """Test Auth subcommand help.""" + result = runner.invoke(app, ["auth", "--help"]) + assert result.exit_code == 0 + assert "Authentication management" in result.stdout + + +class TestAuthModule: + """Tests for the authentication module.""" + + def test_scopes_defined(self): + """Test that OAuth scopes are properly defined.""" + from google_cl.main.auth import SCOPES + + assert "gmail" in SCOPES + assert "drive" in SCOPES + assert "calendar" in SCOPES + assert "sheets" in SCOPES + + # Each service should have at least one scope + for service, scopes in SCOPES.items(): + assert len(scopes) > 0 + assert all(s.startswith("https://www.googleapis.com/auth/") for s in scopes) + + def test_get_all_scopes(self): + """Test that all scopes can be combined.""" + from google_cl.main.auth import get_all_scopes + + scopes = get_all_scopes() + assert isinstance(scopes, list) + assert len(scopes) > 0 + # Should be unique + assert len(scopes) == len(set(scopes)) + + +class TestExceptions: + """Tests for custom exceptions.""" + + def test_exception_hierarchy(self): + """Test exception inheritance.""" + from google_cl.exceptions import ( + GoogleCLException, + ExecutionError, + EarlyQuit, + AuthenticationError, + ServiceError, + ConfigurationError, + ) + + assert issubclass(ExecutionError, GoogleCLException) + assert issubclass(EarlyQuit, GoogleCLException) + assert issubclass(AuthenticationError, GoogleCLException) + assert issubclass(ServiceError, GoogleCLException) + assert issubclass(ConfigurationError, GoogleCLException) + + +class TestGmailService: + """Tests for Gmail service.""" + + def test_email_dataclass(self): + """Test Email dataclass creation from API response.""" + from google_cl.services.gmail import Email + + mock_response = { + "id": "test123", + "threadId": "thread123", + "snippet": "Test snippet", + "labelIds": ["INBOX"], + "payload": { + "headers": [ + {"name": "Subject", "value": "Test Subject"}, + {"name": "From", "value": "test@example.com"}, + {"name": "To", "value": "recipient@example.com"}, + {"name": "Date", "value": "Mon, 1 Jan 2024 12:00:00 +0000"}, + ] + }, + } + + email = Email.from_api_response(mock_response) + assert email.id == "test123" + assert email.thread_id == "thread123" + assert email.subject == "Test Subject" + assert email.sender == "test@example.com" + assert email.to == "recipient@example.com" + + +class TestDriveService: + """Tests for Drive service.""" + + def test_drivefile_dataclass(self): + """Test DriveFile dataclass creation from API response.""" + from google_cl.services.drive import DriveFile + + mock_response = { + "id": "file123", + "name": "test.txt", + "mimeType": "text/plain", + "size": "1024", + "createdTime": "2024-01-01T12:00:00.000Z", + "modifiedTime": "2024-01-02T12:00:00.000Z", + "parents": ["folder123"], + "webViewLink": "https://drive.google.com/file/d/file123", + } + + drive_file = DriveFile.from_api_response(mock_response) + assert drive_file.id == "file123" + assert drive_file.name == "test.txt" + assert drive_file.size == 1024 + assert not drive_file.is_folder + + def test_folder_detection(self): + """Test that folders are properly detected.""" + from google_cl.services.drive import DriveFile + + mock_folder = { + "id": "folder123", + "name": "TestFolder", + "mimeType": "application/vnd.google-apps.folder", + "createdTime": "2024-01-01T12:00:00.000Z", + "modifiedTime": "2024-01-02T12:00:00.000Z", + } + + folder = DriveFile.from_api_response(mock_folder) + assert folder.is_folder + + +class TestCalendarService: + """Tests for Calendar service.""" + + def test_event_dataclass(self): + """Test Event dataclass creation from API response.""" + from google_cl.services.calendar import Event + + mock_response = { + "id": "event123", + "summary": "Test Event", + "description": "Test Description", + "location": "Test Location", + "start": {"dateTime": "2024-01-01T10:00:00"}, + "end": {"dateTime": "2024-01-01T11:00:00"}, + "status": "confirmed", + "htmlLink": "https://calendar.google.com/event/event123", + "creator": {"email": "creator@example.com"}, + } + + event = Event.from_api_response(mock_response) + assert event.id == "event123" + assert event.summary == "Test Event" + assert event.location == "Test Location" + assert not event.all_day + + def test_all_day_event(self): + """Test all-day event detection.""" + from google_cl.services.calendar import Event + + mock_response = { + "id": "event123", + "summary": "All Day Event", + "start": {"date": "2024-01-01"}, + "end": {"date": "2024-01-02"}, + "status": "confirmed", + "htmlLink": "", + "creator": {}, + } + + event = Event.from_api_response(mock_response) + assert event.all_day diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ab36336 --- /dev/null +++ b/uv.lock @@ -0,0 +1,802 @@ +version = 1 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[package]] +name = "cachetools" +version = "6.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/44/ca1675be2a83aeee1886ab745b28cda92093066590233cc501890eb8417a/cachetools-6.2.2.tar.gz", hash = "sha256:8e6d266b25e539df852251cfd6f990b4bc3a141db73b939058d809ebd2590fc6", size = 31571 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503 }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863 }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837 }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550 }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162 }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019 }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310 }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022 }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098 }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991 }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456 }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978 }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969 }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, +] + +[[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 = "coverage" +version = "7.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/0c/0dfe7f0487477d96432e4815537263363fb6dd7289743a796e8e51eabdf2/coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f", size = 217535 }, + { url = "https://files.pythonhosted.org/packages/9b/f5/f9a4a053a5bbff023d3bec259faac8f11a1e5a6479c2ccf586f910d8dac7/coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3", size = 218044 }, + { url = "https://files.pythonhosted.org/packages/95/c5/84fc3697c1fa10cd8571919bf9693f693b7373278daaf3b73e328d502bc8/coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e", size = 248440 }, + { url = "https://files.pythonhosted.org/packages/f4/36/2d93fbf6a04670f3874aed397d5a5371948a076e3249244a9e84fb0e02d6/coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7", size = 250361 }, + { url = "https://files.pythonhosted.org/packages/5d/49/66dc65cc456a6bfc41ea3d0758c4afeaa4068a2b2931bf83be6894cf1058/coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245", size = 252472 }, + { url = "https://files.pythonhosted.org/packages/35/1f/ebb8a18dffd406db9fcd4b3ae42254aedcaf612470e8712f12041325930f/coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b", size = 248592 }, + { url = "https://files.pythonhosted.org/packages/da/a8/67f213c06e5ea3b3d4980df7dc344d7fea88240b5fe878a5dcbdfe0e2315/coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64", size = 250167 }, + { url = "https://files.pythonhosted.org/packages/f0/00/e52aef68154164ea40cc8389c120c314c747fe63a04b013a5782e989b77f/coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742", size = 248238 }, + { url = "https://files.pythonhosted.org/packages/1f/a4/4d88750bcf9d6d66f77865e5a05a20e14db44074c25fd22519777cb69025/coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c", size = 247964 }, + { url = "https://files.pythonhosted.org/packages/a7/6b/b74693158899d5b47b0bf6238d2c6722e20ba749f86b74454fac0696bb00/coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984", size = 248862 }, + { url = "https://files.pythonhosted.org/packages/18/de/6af6730227ce0e8ade307b1cc4a08e7f51b419a78d02083a86c04ccceb29/coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6", size = 220033 }, + { url = "https://files.pythonhosted.org/packages/e2/a1/e7f63021a7c4fe20994359fcdeae43cbef4a4d0ca36a5a1639feeea5d9e1/coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4", size = 220966 }, + { url = "https://files.pythonhosted.org/packages/77/e8/deae26453f37c20c3aa0c4433a1e32cdc169bf415cce223a693117aa3ddd/coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc", size = 219637 }, + { url = "https://files.pythonhosted.org/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704 }, + { url = "https://files.pythonhosted.org/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064 }, + { url = "https://files.pythonhosted.org/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560 }, + { url = "https://files.pythonhosted.org/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318 }, + { url = "https://files.pythonhosted.org/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403 }, + { url = "https://files.pythonhosted.org/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984 }, + { url = "https://files.pythonhosted.org/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339 }, + { url = "https://files.pythonhosted.org/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489 }, + { url = "https://files.pythonhosted.org/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070 }, + { url = "https://files.pythonhosted.org/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929 }, + { url = "https://files.pythonhosted.org/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241 }, + { url = "https://files.pythonhosted.org/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051 }, + { url = "https://files.pythonhosted.org/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692 }, + { url = "https://files.pythonhosted.org/packages/b8/14/771700b4048774e48d2c54ed0c674273702713c9ee7acdfede40c2666747/coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", size = 217725 }, + { url = "https://files.pythonhosted.org/packages/17/a7/3aa4144d3bcb719bf67b22d2d51c2d577bf801498c13cb08f64173e80497/coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", size = 218098 }, + { url = "https://files.pythonhosted.org/packages/fc/9c/b846bbc774ff81091a12a10203e70562c91ae71badda00c5ae5b613527b1/coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", size = 249093 }, + { url = "https://files.pythonhosted.org/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", size = 251686 }, + { url = "https://files.pythonhosted.org/packages/cc/75/b095bd4b39d49c3be4bffbb3135fea18a99a431c52dd7513637c0762fecb/coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", size = 252930 }, + { url = "https://files.pythonhosted.org/packages/6e/f3/466f63015c7c80550bead3093aacabf5380c1220a2a93c35d374cae8f762/coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", size = 249296 }, + { url = "https://files.pythonhosted.org/packages/27/86/eba2209bf2b7e28c68698fc13437519a295b2d228ba9e0ec91673e09fa92/coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", size = 251068 }, + { url = "https://files.pythonhosted.org/packages/ec/55/ca8ae7dbba962a3351f18940b359b94c6bafdd7757945fdc79ec9e452dc7/coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", size = 249034 }, + { url = "https://files.pythonhosted.org/packages/7a/d7/39136149325cad92d420b023b5fd900dabdd1c3a0d1d5f148ef4a8cedef5/coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", size = 248853 }, + { url = "https://files.pythonhosted.org/packages/fe/b6/76e1add8b87ef60e00643b0b7f8f7bb73d4bf5249a3be19ebefc5793dd25/coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", size = 250619 }, + { url = "https://files.pythonhosted.org/packages/95/87/924c6dc64f9203f7a3c1832a6a0eee5a8335dbe5f1bdadcc278d6f1b4d74/coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", size = 220261 }, + { url = "https://files.pythonhosted.org/packages/91/77/dd4aff9af16ff776bf355a24d87eeb48fc6acde54c907cc1ea89b14a8804/coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", size = 221072 }, + { url = "https://files.pythonhosted.org/packages/70/49/5c9dc46205fef31b1b226a6e16513193715290584317fd4df91cdaf28b22/coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", size = 219702 }, + { url = "https://files.pythonhosted.org/packages/9b/62/f87922641c7198667994dd472a91e1d9b829c95d6c29529ceb52132436ad/coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", size = 218420 }, + { url = "https://files.pythonhosted.org/packages/85/dd/1cc13b2395ef15dbb27d7370a2509b4aee77890a464fb35d72d428f84871/coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", size = 218773 }, + { url = "https://files.pythonhosted.org/packages/74/40/35773cc4bb1e9d4658d4fb669eb4195b3151bef3bbd6f866aba5cd5dac82/coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", size = 260078 }, + { url = "https://files.pythonhosted.org/packages/ec/ee/231bb1a6ffc2905e396557585ebc6bdc559e7c66708376d245a1f1d330fc/coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", size = 262144 }, + { url = "https://files.pythonhosted.org/packages/28/be/32f4aa9f3bf0b56f3971001b56508352c7753915345d45fab4296a986f01/coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", size = 264574 }, + { url = "https://files.pythonhosted.org/packages/68/7c/00489fcbc2245d13ab12189b977e0cf06ff3351cb98bc6beba8bd68c5902/coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", size = 259298 }, + { url = "https://files.pythonhosted.org/packages/96/b4/f0760d65d56c3bea95b449e02570d4abd2549dc784bf39a2d4721a2d8ceb/coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", size = 262150 }, + { url = "https://files.pythonhosted.org/packages/c5/71/9a9314df00f9326d78c1e5a910f520d599205907432d90d1c1b7a97aa4b1/coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", size = 259763 }, + { url = "https://files.pythonhosted.org/packages/10/34/01a0aceed13fbdf925876b9a15d50862eb8845454301fe3cdd1df08b2182/coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", size = 258653 }, + { url = "https://files.pythonhosted.org/packages/8d/04/81d8fd64928acf1574bbb0181f66901c6c1c6279c8ccf5f84259d2c68ae9/coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", size = 260856 }, + { url = "https://files.pythonhosted.org/packages/f2/76/fa2a37bfaeaf1f766a2d2360a25a5297d4fb567098112f6517475eee120b/coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", size = 220936 }, + { url = "https://files.pythonhosted.org/packages/f9/52/60f64d932d555102611c366afb0eb434b34266b1d9266fc2fe18ab641c47/coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", size = 222001 }, + { url = "https://files.pythonhosted.org/packages/77/df/c303164154a5a3aea7472bf323b7c857fed93b26618ed9fc5c2955566bb0/coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", size = 220273 }, + { url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777 }, + { url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100 }, + { url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151 }, + { url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667 }, + { url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003 }, + { url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185 }, + { url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025 }, + { url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979 }, + { url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800 }, + { url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460 }, + { url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533 }, + { url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348 }, + { url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922 }, + { url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511 }, + { url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771 }, + { url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151 }, + { url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257 }, + { url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671 }, + { url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231 }, + { url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137 }, + { url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745 }, + { url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570 }, + { url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899 }, + { url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313 }, + { url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423 }, + { url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459 }, + { url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "google-api-core" +version = "2.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/da/83d7043169ac2c8c7469f0e375610d78ae2160134bf1b80634c482fa079c/google_api_core-2.28.1.tar.gz", hash = "sha256:2b405df02d68e68ce0fbc138559e6036559e685159d148ae5861013dc201baf8", size = 176759 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/d4/90197b416cb61cefd316964fd9e7bd8324bcbafabf40eef14a9f20b81974/google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c", size = 173706 }, +] + +[[package]] +name = "google-api-python-client" +version = "2.187.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/83/60cdacf139d768dd7f0fcbe8d95b418299810068093fdf8228c6af89bb70/google_api_python_client-2.187.0.tar.gz", hash = "sha256:e98e8e8f49e1b5048c2f8276473d6485febc76c9c47892a8b4d1afa2c9ec8278", size = 14068154 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/58/c1e716be1b055b504d80db2c8413f6c6a890a6ae218a65f178b63bc30356/google_api_python_client-2.187.0-py3-none-any.whl", hash = "sha256:d8d0f6d85d7d1d10bdab32e642312ed572bdc98919f72f831b44b9a9cebba32f", size = 14641434 }, +] + +[[package]] +name = "google-auth" +version = "2.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/ef/66d14cf0e01b08d2d51ffc3c20410c4e134a1548fc246a6081eae585a4fe/google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483", size = 296359 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/d1/385110a9ae86d91cc14c5282c61fe9f4dc41c0b9f7d423c6ad77038c4448/google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16", size = 223114 }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/83/7ef576d1c7ccea214e7b001e69c006bc75e058a3a1f2ab810167204b698b/google_auth_httplib2-0.2.1.tar.gz", hash = "sha256:5ef03be3927423c87fb69607b42df23a444e434ddb2555b73b3679793187b7de", size = 11086 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/a7/ca23dd006255f70e2bc469d3f9f0c82ea455335bfd682ad4d677adc435de/google_auth_httplib2-0.2.1-py3-none-any.whl", hash = "sha256:1be94c611db91c01f9703e7f62b0a59bbd5587a95571c7b6fade510d648bc08b", size = 9525 }, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/87/e10bf24f7bcffc1421b84d6f9c3377c30ec305d082cd737ddaa6d8f77f7c/google_auth_oauthlib-1.2.2.tar.gz", hash = "sha256:11046fb8d3348b296302dd939ace8af0a724042e8029c1b872d87fabc9f41684", size = 20955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/84/40ee070be95771acd2f4418981edb834979424565c3eec3cd88b6aa09d24/google_auth_oauthlib-1.2.2-py3-none-any.whl", hash = "sha256:fd619506f4b3908b5df17b65f39ca8d66ea56986e5472eb5978fd8f3786f00a2", size = 19072 }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515 }, +] + +[[package]] +name = "googlecl" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "google-api-python-client" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "google-auth-oauthlib" }, + { name = "rich" }, + { name = "typer" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "google-api-python-client", specifier = ">=2.150.0" }, + { name = "google-auth", specifier = ">=2.36.0" }, + { name = "google-auth-httplib2", specifier = ">=0.2.0" }, + { name = "google-auth-oauthlib", specifier = ">=1.2.1" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.13.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, + { name = "rich", specifier = ">=13.9.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.8.0" }, + { name = "typer", extras = ["all"], specifier = ">=0.12.0" }, +] + +[[package]] +name = "httplib2" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/77/6653db69c1f7ecfe5e3f9726fdadc981794656fcd7d98c4209fecfea9993/httplib2-0.31.0.tar.gz", hash = "sha256:ac7ab497c50975147d4f7b1ade44becc7df2f8954d42b38b3d69c515f531135c", size = 250759 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/a2/0d269db0f6163be503775dc8b6a6fa15820cc9fdc866f6ba608d86b721f2/httplib2-0.31.0-py3-none-any.whl", hash = "sha256:b9cd78abea9b4e43a7714c6e0f8b6b8561a6fc1e95d5dbd367f5bf0ef35f5d24", size = 91148 }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "librt" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/c3/cdff3c10e2e608490dc0a310ccf11ba777b3943ad4fcead2a2ade98c21e1/librt-0.6.3.tar.gz", hash = "sha256:c724a884e642aa2bbad52bb0203ea40406ad742368a5f90da1b220e970384aae", size = 54209 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/80/bc60fd16fe24910bf5974fb914778a2e8540cef55385ab2cb04a0dfe42c4/librt-0.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:61348cc488b18d1b1ff9f3e5fcd5ac43ed22d3e13e862489d2267c2337285c08", size = 27285 }, + { url = "https://files.pythonhosted.org/packages/88/3c/26335536ed9ba097c79cffcee148393592e55758fe76d99015af3e47a6d0/librt-0.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64645b757d617ad5f98c08e07620bc488d4bced9ced91c6279cec418f16056fa", size = 27629 }, + { url = "https://files.pythonhosted.org/packages/af/fd/2dcedeacfedee5d2eda23e7a49c1c12ce6221b5d58a13555f053203faafc/librt-0.6.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:26b8026393920320bb9a811b691d73c5981385d537ffc5b6e22e53f7b65d4122", size = 82039 }, + { url = "https://files.pythonhosted.org/packages/48/ff/6aa11914b83b0dc2d489f7636942a8e3322650d0dba840db9a1b455f3caa/librt-0.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d998b432ed9ffccc49b820e913c8f327a82026349e9c34fa3690116f6b70770f", size = 86560 }, + { url = "https://files.pythonhosted.org/packages/76/a1/d25af61958c2c7eb978164aeba0350719f615179ba3f428b682b9a5fdace/librt-0.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e18875e17ef69ba7dfa9623f2f95f3eda6f70b536079ee6d5763ecdfe6cc9040", size = 86494 }, + { url = "https://files.pythonhosted.org/packages/7d/4b/40e75d3b258c801908e64b39788f9491635f9554f8717430a491385bd6f2/librt-0.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a218f85081fc3f70cddaed694323a1ad7db5ca028c379c214e3a7c11c0850523", size = 88914 }, + { url = "https://files.pythonhosted.org/packages/97/6d/0070c81aba8a169224301c75fb5fb6c3c25ca67e6ced086584fc130d5a67/librt-0.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ef42ff4edd369e84433ce9b188a64df0837f4f69e3d34d3b34d4955c599d03f", size = 86944 }, + { url = "https://files.pythonhosted.org/packages/a6/94/809f38887941b7726692e0b5a083dbdc87dbb8cf893e3b286550c5f0b129/librt-0.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e0f2b79993fec23a685b3e8107ba5f8675eeae286675a216da0b09574fa1e47", size = 89852 }, + { url = "https://files.pythonhosted.org/packages/58/a3/b0e5b1cda675b91f1111d8ba941da455d8bfaa22f4d2d8963ba96ccb5b12/librt-0.6.3-cp311-cp311-win32.whl", hash = "sha256:fd98cacf4e0fabcd4005c452cb8a31750258a85cab9a59fb3559e8078da408d7", size = 19948 }, + { url = "https://files.pythonhosted.org/packages/cc/73/70011c2b37e3be3ece3affd3abc8ebe5cda482b03fd6b3397906321a901e/librt-0.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:e17b5b42c8045867ca9d1f54af00cc2275198d38de18545edaa7833d7e9e4ac8", size = 21406 }, + { url = "https://files.pythonhosted.org/packages/91/ee/119aa759290af6ca0729edf513ca390c1afbeae60f3ecae9b9d56f25a8a9/librt-0.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:87597e3d57ec0120a3e1d857a708f80c02c42ea6b00227c728efbc860f067c45", size = 20875 }, + { url = "https://files.pythonhosted.org/packages/b4/2c/b59249c566f98fe90e178baf59e83f628d6c38fb8bc78319301fccda0b5e/librt-0.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74418f718083009108dc9a42c21bf2e4802d49638a1249e13677585fcc9ca176", size = 27841 }, + { url = "https://files.pythonhosted.org/packages/40/e8/9db01cafcd1a2872b76114c858f81cc29ce7ad606bc102020d6dabf470fb/librt-0.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:514f3f363d1ebc423357d36222c37e5c8e6674b6eae8d7195ac9a64903722057", size = 27844 }, + { url = "https://files.pythonhosted.org/packages/59/4d/da449d3a7d83cc853af539dee42adc37b755d7eea4ad3880bacfd84b651d/librt-0.6.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cf1115207a5049d1f4b7b4b72de0e52f228d6c696803d94843907111cbf80610", size = 84091 }, + { url = "https://files.pythonhosted.org/packages/ea/6c/f90306906fb6cc6eaf4725870f0347115de05431e1f96d35114392d31fda/librt-0.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad8ba80cdcea04bea7b78fcd4925bfbf408961e9d8397d2ee5d3ec121e20c08c", size = 88239 }, + { url = "https://files.pythonhosted.org/packages/e7/ae/473ce7b423cfac2cb503851a89d9d2195bf615f534d5912bf86feeebbee7/librt-0.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4018904c83eab49c814e2494b4e22501a93cdb6c9f9425533fe693c3117126f9", size = 88815 }, + { url = "https://files.pythonhosted.org/packages/c4/6d/934df738c87fb9617cabefe4891eece585a06abe6def25b4bca3b174429d/librt-0.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8983c5c06ac9c990eac5eb97a9f03fe41dc7e9d7993df74d9e8682a1056f596c", size = 90598 }, + { url = "https://files.pythonhosted.org/packages/72/89/eeaa124f5e0f431c2b39119550378ae817a4b1a3c93fd7122f0639336fff/librt-0.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7769c579663a6f8dbf34878969ac71befa42067ce6bf78e6370bf0d1194997c", size = 88603 }, + { url = "https://files.pythonhosted.org/packages/4d/ed/c60b3c1cfc27d709bc0288af428ce58543fcb5053cf3eadbc773c24257f5/librt-0.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d3c9a07eafdc70556f8c220da4a538e715668c0c63cabcc436a026e4e89950bf", size = 92112 }, + { url = "https://files.pythonhosted.org/packages/c1/ab/f56169be5f716ef4ab0277be70bcb1874b4effc262e655d85b505af4884d/librt-0.6.3-cp312-cp312-win32.whl", hash = "sha256:38320386a48a15033da295df276aea93a92dfa94a862e06893f75ea1d8bbe89d", size = 20127 }, + { url = "https://files.pythonhosted.org/packages/ff/8d/222750ce82bf95125529eaab585ac7e2829df252f3cfc05d68792fb1dd2c/librt-0.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:c0ecf4786ad0404b072196b5df774b1bb23c8aacdcacb6c10b4128bc7b00bd01", size = 21545 }, + { url = "https://files.pythonhosted.org/packages/72/c9/f731ddcfb72f446a92a8674c6b8e1e2242773cce43a04f41549bd8b958ff/librt-0.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:9f2a6623057989ebc469cd9cc8fe436c40117a0147627568d03f84aef7854c55", size = 20946 }, + { url = "https://files.pythonhosted.org/packages/dd/aa/3055dd440f8b8b3b7e8624539a0749dd8e1913e978993bcca9ce7e306231/librt-0.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e716f9012148a81f02f46a04fc4c663420c6fbfeacfac0b5e128cf43b4413d3", size = 27874 }, + { url = "https://files.pythonhosted.org/packages/ef/93/226d7dd455eaa4c26712b5ccb2dfcca12831baa7f898c8ffd3a831e29fda/librt-0.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:669ff2495728009a96339c5ad2612569c6d8be4474e68f3f3ac85d7c3261f5f5", size = 27852 }, + { url = "https://files.pythonhosted.org/packages/4e/8b/db9d51191aef4e4cc06285250affe0bb0ad8b2ed815f7ca77951655e6f02/librt-0.6.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:349b6873ebccfc24c9efd244e49da9f8a5c10f60f07575e248921aae2123fc42", size = 84264 }, + { url = "https://files.pythonhosted.org/packages/8d/53/297c96bda3b5a73bdaf748f1e3ae757edd29a0a41a956b9c10379f193417/librt-0.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c74c26736008481c9f6d0adf1aedb5a52aff7361fea98276d1f965c0256ee70", size = 88432 }, + { url = "https://files.pythonhosted.org/packages/54/3a/c005516071123278e340f22de72fa53d51e259d49215295c212da16c4dc2/librt-0.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:408a36ddc75e91918cb15b03460bdc8a015885025d67e68c6f78f08c3a88f522", size = 89014 }, + { url = "https://files.pythonhosted.org/packages/8e/9b/ea715f818d926d17b94c80a12d81a79e95c44f52848e61e8ca1ff29bb9a9/librt-0.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e61ab234624c9ffca0248a707feffe6fac2343758a36725d8eb8a6efef0f8c30", size = 90807 }, + { url = "https://files.pythonhosted.org/packages/f0/fc/4e2e4c87e002fa60917a8e474fd13c4bac9a759df82be3778573bb1ab954/librt-0.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:324462fe7e3896d592b967196512491ec60ca6e49c446fe59f40743d08c97917", size = 88890 }, + { url = "https://files.pythonhosted.org/packages/70/7f/c7428734fbdfd4db3d5b9237fc3a857880b2ace66492836f6529fef25d92/librt-0.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36b2ec8c15030002c7f688b4863e7be42820d7c62d9c6eece3db54a2400f0530", size = 92300 }, + { url = "https://files.pythonhosted.org/packages/f9/0c/738c4824fdfe74dc0f95d5e90ef9e759d4ecf7fd5ba964d54a7703322251/librt-0.6.3-cp313-cp313-win32.whl", hash = "sha256:25b1b60cb059471c0c0c803e07d0dfdc79e41a0a122f288b819219ed162672a3", size = 20159 }, + { url = "https://files.pythonhosted.org/packages/f2/95/93d0e61bc617306ecf4c54636b5cbde4947d872563565c4abdd9d07a39d3/librt-0.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:10a95ad074e2a98c9e4abc7f5b7d40e5ecbfa84c04c6ab8a70fabf59bd429b88", size = 21484 }, + { url = "https://files.pythonhosted.org/packages/10/23/abd7ace79ab54d1dbee265f13529266f686a7ce2d21ab59a992f989009b6/librt-0.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:17000df14f552e86877d67e4ab7966912224efc9368e998c96a6974a8d609bf9", size = 20935 }, + { url = "https://files.pythonhosted.org/packages/83/14/c06cb31152182798ed98be73f54932ab984894f5a8fccf9b73130897a938/librt-0.6.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8e695f25d1a425ad7a272902af8ab8c8d66c1998b177e4b5f5e7b4e215d0c88a", size = 27566 }, + { url = "https://files.pythonhosted.org/packages/0c/b1/ce83ca7b057b06150519152f53a0b302d7c33c8692ce2f01f669b5a819d9/librt-0.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3e84a4121a7ae360ca4da436548a9c1ca8ca134a5ced76c893cc5944426164bd", size = 27753 }, + { url = "https://files.pythonhosted.org/packages/3b/ec/739a885ef0a2839b6c25f1b01c99149d2cb6a34e933ffc8c051fcd22012e/librt-0.6.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05f385a414de3f950886ea0aad8f109650d4b712cf9cc14cc17f5f62a9ab240b", size = 83178 }, + { url = "https://files.pythonhosted.org/packages/db/bd/dc18bb1489d48c0911b9f4d72eae2d304ea264e215ba80f1e6ba4a9fc41d/librt-0.6.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36a8e337461150b05ca2c7bdedb9e591dfc262c5230422cea398e89d0c746cdc", size = 87266 }, + { url = "https://files.pythonhosted.org/packages/94/f3/d0c5431b39eef15e48088b2d739ad84b17c2f1a22c0345c6d4c4a42b135e/librt-0.6.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcbe48f6a03979384f27086484dc2a14959be1613cb173458bd58f714f2c48f3", size = 87623 }, + { url = "https://files.pythonhosted.org/packages/3b/15/9a52e90834e4bd6ee16cdbaf551cb32227cbaad27398391a189c489318bc/librt-0.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4bca9e4c260233fba37b15c4ec2f78aa99c1a79fbf902d19dd4a763c5c3fb751", size = 89436 }, + { url = "https://files.pythonhosted.org/packages/c3/8a/a7e78e46e8486e023c50f21758930ef4793999115229afd65de69e94c9cc/librt-0.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:760c25ed6ac968e24803eb5f7deb17ce026902d39865e83036bacbf5cf242aa8", size = 87540 }, + { url = "https://files.pythonhosted.org/packages/49/01/93799044a1cccac31f1074b07c583e181829d240539657e7f305ae63ae2a/librt-0.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4aa4a93a353ccff20df6e34fa855ae8fd788832c88f40a9070e3ddd3356a9f0e", size = 90597 }, + { url = "https://files.pythonhosted.org/packages/a7/29/00c7f58b8f8eb1bad6529ffb6c9cdcc0890a27dac59ecda04f817ead5277/librt-0.6.3-cp314-cp314-win32.whl", hash = "sha256:cb92741c2b4ea63c09609b064b26f7f5d9032b61ae222558c55832ec3ad0bcaf", size = 18955 }, + { url = "https://files.pythonhosted.org/packages/d7/13/2739e6e197a9f751375a37908a6a5b0bff637b81338497a1bcb5817394da/librt-0.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:fdcd095b1b812d756fa5452aca93b962cf620694c0cadb192cec2bb77dcca9a2", size = 20263 }, + { url = "https://files.pythonhosted.org/packages/e1/73/393868fc2158705ea003114a24e73bb10b03bda31e9ad7b5c5ec6575338b/librt-0.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:822ca79e28720a76a935c228d37da6579edef048a17cd98d406a2484d10eda78", size = 19575 }, + { url = "https://files.pythonhosted.org/packages/48/6d/3c8ff3dec21bf804a205286dd63fd28dcdbe00b8dd7eb7ccf2e21a40a0b0/librt-0.6.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:078cd77064d1640cb7b0650871a772956066174d92c8aeda188a489b58495179", size = 28732 }, + { url = "https://files.pythonhosted.org/packages/f4/90/e214b8b4aa34ed3d3f1040719c06c4d22472c40c5ef81a922d5af7876eb4/librt-0.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5cc22f7f5c0cc50ed69f4b15b9c51d602aabc4500b433aaa2ddd29e578f452f7", size = 29065 }, + { url = "https://files.pythonhosted.org/packages/ab/90/ef61ed51f0a7770cc703422d907a757bbd8811ce820c333d3db2fd13542a/librt-0.6.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:14b345eb7afb61b9fdcdfda6738946bd11b8e0f6be258666b0646af3b9bb5916", size = 93703 }, + { url = "https://files.pythonhosted.org/packages/a8/ae/c30bb119c35962cbe9a908a71da99c168056fc3f6e9bbcbc157d0b724d89/librt-0.6.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d46aa46aa29b067f0b8b84f448fd9719aaf5f4c621cc279164d76a9dc9ab3e8", size = 98890 }, + { url = "https://files.pythonhosted.org/packages/d1/96/47a4a78d252d36f072b79d592df10600d379a895c3880c8cbd2ac699f0ad/librt-0.6.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b51ba7d9d5d9001494769eca8c0988adce25d0a970c3ba3f2eb9df9d08036fc", size = 98255 }, + { url = "https://files.pythonhosted.org/packages/e5/28/779b5cc3cd9987683884eb5f5672e3251676bebaaae6b7da1cf366eb1da1/librt-0.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ced0925a18fddcff289ef54386b2fc230c5af3c83b11558571124bfc485b8c07", size = 100769 }, + { url = "https://files.pythonhosted.org/packages/28/d7/771755e57c375cb9d25a4e106f570607fd856e2cb91b02418db1db954796/librt-0.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6bac97e51f66da2ca012adddbe9fd656b17f7368d439de30898f24b39512f40f", size = 98580 }, + { url = "https://files.pythonhosted.org/packages/d0/ec/8b157eb8fbc066339a2f34b0aceb2028097d0ed6150a52e23284a311eafe/librt-0.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b2922a0e8fa97395553c304edc3bd36168d8eeec26b92478e292e5d4445c1ef0", size = 101706 }, + { url = "https://files.pythonhosted.org/packages/82/a8/4aaead9a06c795a318282aebf7d3e3e578fa889ff396e1b640c3be4c7806/librt-0.6.3-cp314-cp314t-win32.whl", hash = "sha256:f33462b19503ba68d80dac8a1354402675849259fb3ebf53b67de86421735a3a", size = 19465 }, + { url = "https://files.pythonhosted.org/packages/3a/61/b7e6a02746c1731670c19ba07d86da90b1ae45d29e405c0b5615abf97cde/librt-0.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:04f8ce401d4f6380cfc42af0f4e67342bf34c820dae01343f58f472dbac75dcf", size = 21042 }, + { url = "https://files.pythonhosted.org/packages/0e/3d/72cc9ec90bb80b5b1a65f0bb74a0f540195837baaf3b98c7fa4a7aa9718e/librt-0.6.3-cp314-cp314t-win_arm64.whl", hash = "sha256:afb39550205cc5e5c935762c6bf6a2bb34f7d21a68eadb25e2db7bf3593fecc0", size = 20246 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "mypy" +version = "1.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/b5/b58cdc25fadd424552804bf410855d52324183112aa004f0732c5f6324cf/mypy-1.19.0.tar.gz", hash = "sha256:f6b874ca77f733222641e5c46e4711648c4037ea13646fd0cdc814c2eaec2528", size = 3579025 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/d2/010fb171ae5ac4a01cc34fbacd7544531e5ace95c35ca166dd8fd1b901d0/mypy-1.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a31e4c28e8ddb042c84c5e977e28a21195d086aaffaf08b016b78e19c9ef8106", size = 13010563 }, + { url = "https://files.pythonhosted.org/packages/41/6b/63f095c9f1ce584fdeb595d663d49e0980c735a1d2004720ccec252c5d47/mypy-1.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34ec1ac66d31644f194b7c163d7f8b8434f1b49719d403a5d26c87fff7e913f7", size = 12077037 }, + { url = "https://files.pythonhosted.org/packages/d7/83/6cb93d289038d809023ec20eb0b48bbb1d80af40511fa077da78af6ff7c7/mypy-1.19.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb64b0ba5980466a0f3f9990d1c582bcab8db12e29815ecb57f1408d99b4bff7", size = 12680255 }, + { url = "https://files.pythonhosted.org/packages/99/db/d217815705987d2cbace2edd9100926196d6f85bcb9b5af05058d6e3c8ad/mypy-1.19.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:120cffe120cca5c23c03c77f84abc0c14c5d2e03736f6c312480020082f1994b", size = 13421472 }, + { url = "https://files.pythonhosted.org/packages/4e/51/d2beaca7c497944b07594f3f8aad8d2f0e8fc53677059848ae5d6f4d193e/mypy-1.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7a500ab5c444268a70565e374fc803972bfd1f09545b13418a5174e29883dab7", size = 13651823 }, + { url = "https://files.pythonhosted.org/packages/aa/d1/7883dcf7644db3b69490f37b51029e0870aac4a7ad34d09ceae709a3df44/mypy-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:c14a98bc63fd867530e8ec82f217dae29d0550c86e70debc9667fff1ec83284e", size = 10049077 }, + { url = "https://files.pythonhosted.org/packages/11/7e/1afa8fb188b876abeaa14460dc4983f909aaacaa4bf5718c00b2c7e0b3d5/mypy-1.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0fb3115cb8fa7c5f887c8a8d81ccdcb94cff334684980d847e5a62e926910e1d", size = 13207728 }, + { url = "https://files.pythonhosted.org/packages/b2/13/f103d04962bcbefb1644f5ccb235998b32c337d6c13145ea390b9da47f3e/mypy-1.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3e19e3b897562276bb331074d64c076dbdd3e79213f36eed4e592272dabd760", size = 12202945 }, + { url = "https://files.pythonhosted.org/packages/e4/93/a86a5608f74a22284a8ccea8592f6e270b61f95b8588951110ad797c2ddd/mypy-1.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9d491295825182fba01b6ffe2c6fe4e5a49dbf4e2bb4d1217b6ced3b4797bc6", size = 12718673 }, + { url = "https://files.pythonhosted.org/packages/3d/58/cf08fff9ced0423b858f2a7495001fda28dc058136818ee9dffc31534ea9/mypy-1.19.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6016c52ab209919b46169651b362068f632efcd5eb8ef9d1735f6f86da7853b2", size = 13608336 }, + { url = "https://files.pythonhosted.org/packages/64/ed/9c509105c5a6d4b73bb08733102a3ea62c25bc02c51bca85e3134bf912d3/mypy-1.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f188dcf16483b3e59f9278c4ed939ec0254aa8a60e8fc100648d9ab5ee95a431", size = 13833174 }, + { url = "https://files.pythonhosted.org/packages/cd/71/01939b66e35c6f8cb3e6fdf0b657f0fd24de2f8ba5e523625c8e72328208/mypy-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:0e3c3d1e1d62e678c339e7ade72746a9e0325de42cd2cccc51616c7b2ed1a018", size = 10112208 }, + { url = "https://files.pythonhosted.org/packages/cb/0d/a1357e6bb49e37ce26fcf7e3cc55679ce9f4ebee0cd8b6ee3a0e301a9210/mypy-1.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7686ed65dbabd24d20066f3115018d2dce030d8fa9db01aa9f0a59b6813e9f9e", size = 13191993 }, + { url = "https://files.pythonhosted.org/packages/5d/75/8e5d492a879ec4490e6ba664b5154e48c46c85b5ac9785792a5ec6a4d58f/mypy-1.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd4a985b2e32f23bead72e2fb4bbe5d6aceee176be471243bd831d5b2644672d", size = 12174411 }, + { url = "https://files.pythonhosted.org/packages/71/31/ad5dcee9bfe226e8eaba777e9d9d251c292650130f0450a280aec3485370/mypy-1.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc51a5b864f73a3a182584b1ac75c404396a17eced54341629d8bdcb644a5bba", size = 12727751 }, + { url = "https://files.pythonhosted.org/packages/77/06/b6b8994ce07405f6039701f4b66e9d23f499d0b41c6dd46ec28f96d57ec3/mypy-1.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37af5166f9475872034b56c5efdcf65ee25394e9e1d172907b84577120714364", size = 13593323 }, + { url = "https://files.pythonhosted.org/packages/68/b1/126e274484cccdf099a8e328d4fda1c7bdb98a5e888fa6010b00e1bbf330/mypy-1.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:510c014b722308c9bd377993bcbf9a07d7e0692e5fa8fc70e639c1eb19fc6bee", size = 13818032 }, + { url = "https://files.pythonhosted.org/packages/f8/56/53a8f70f562dfc466c766469133a8a4909f6c0012d83993143f2a9d48d2d/mypy-1.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:cabbee74f29aa9cd3b444ec2f1e4fa5a9d0d746ce7567a6a609e224429781f53", size = 10120644 }, + { url = "https://files.pythonhosted.org/packages/b0/f4/7751f32f56916f7f8c229fe902cbdba3e4dd3f3ea9e8b872be97e7fc546d/mypy-1.19.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f2e36bed3c6d9b5f35d28b63ca4b727cb0228e480826ffc8953d1892ddc8999d", size = 13185236 }, + { url = "https://files.pythonhosted.org/packages/35/31/871a9531f09e78e8d145032355890384f8a5b38c95a2c7732d226b93242e/mypy-1.19.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a18d8abdda14035c5718acb748faec09571432811af129bf0d9e7b2d6699bf18", size = 12213902 }, + { url = "https://files.pythonhosted.org/packages/58/b8/af221910dd40eeefa2077a59107e611550167b9994693fc5926a0b0f87c0/mypy-1.19.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75e60aca3723a23511948539b0d7ed514dda194bc3755eae0bfc7a6b4887aa7", size = 12738600 }, + { url = "https://files.pythonhosted.org/packages/11/9f/c39e89a3e319c1d9c734dedec1183b2cc3aefbab066ec611619002abb932/mypy-1.19.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f44f2ae3c58421ee05fe609160343c25f70e3967f6e32792b5a78006a9d850f", size = 13592639 }, + { url = "https://files.pythonhosted.org/packages/97/6d/ffaf5f01f5e284d9033de1267e6c1b8f3783f2cf784465378a86122e884b/mypy-1.19.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63ea6a00e4bd6822adbfc75b02ab3653a17c02c4347f5bb0cf1d5b9df3a05835", size = 13799132 }, + { url = "https://files.pythonhosted.org/packages/fe/b0/c33921e73aaa0106224e5a34822411bea38046188eb781637f5a5b07e269/mypy-1.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ad925b14a0bb99821ff6f734553294aa6a3440a8cb082fe1f5b84dfb662afb1", size = 10269832 }, + { url = "https://files.pythonhosted.org/packages/09/0e/fe228ed5aeab470c6f4eb82481837fadb642a5aa95cc8215fd2214822c10/mypy-1.19.0-py3-none-any.whl", hash = "sha256:0c01c99d626380752e527d5ce8e69ffbba2046eb8a060db0329690849cf9b6f9", size = 2469714 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "proto-plus" +version = "1.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163 }, +] + +[[package]] +name = "protobuf" +version = "6.33.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/03/a1440979a3f74f16cab3b75b0da1a1a7f922d56a8ddea96092391998edc0/protobuf-6.33.1.tar.gz", hash = "sha256:97f65757e8d09870de6fd973aeddb92f85435607235d20b2dfed93405d00c85b", size = 443432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/f1/446a9bbd2c60772ca36556bac8bfde40eceb28d9cc7838755bc41e001d8f/protobuf-6.33.1-cp310-abi3-win32.whl", hash = "sha256:f8d3fdbc966aaab1d05046d0240dd94d40f2a8c62856d41eaa141ff64a79de6b", size = 425593 }, + { url = "https://files.pythonhosted.org/packages/a6/79/8780a378c650e3df849b73de8b13cf5412f521ca2ff9b78a45c247029440/protobuf-6.33.1-cp310-abi3-win_amd64.whl", hash = "sha256:923aa6d27a92bf44394f6abf7ea0500f38769d4b07f4be41cb52bd8b1123b9ed", size = 436883 }, + { url = "https://files.pythonhosted.org/packages/cd/93/26213ff72b103ae55bb0d73e7fb91ea570ef407c3ab4fd2f1f27cac16044/protobuf-6.33.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:fe34575f2bdde76ac429ec7b570235bf0c788883e70aee90068e9981806f2490", size = 427522 }, + { url = "https://files.pythonhosted.org/packages/c2/32/df4a35247923393aa6b887c3b3244a8c941c32a25681775f96e2b418f90e/protobuf-6.33.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:f8adba2e44cde2d7618996b3fc02341f03f5bc3f2748be72dc7b063319276178", size = 324445 }, + { url = "https://files.pythonhosted.org/packages/8e/d0/d796e419e2ec93d2f3fa44888861c3f88f722cde02b7c3488fcc6a166820/protobuf-6.33.1-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:0f4cf01222c0d959c2b399142deb526de420be8236f22c71356e2a544e153c53", size = 339161 }, + { url = "https://files.pythonhosted.org/packages/1d/2a/3c5f05a4af06649547027d288747f68525755de692a26a7720dced3652c0/protobuf-6.33.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:8fd7d5e0eb08cd5b87fd3df49bc193f5cfd778701f47e11d127d0afc6c39f1d1", size = 323171 }, + { url = "https://files.pythonhosted.org/packages/08/b4/46310463b4f6ceef310f8348786f3cff181cea671578e3d9743ba61a459e/protobuf-6.33.1-py3-none-any.whl", hash = "sha256:d595a9fd694fdeb061a62fbe10eb039cc1e444df81ec9bb70c7fc59ebcb1eafa", size = 170477 }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, +] + +[[package]] +name = "pyparsing" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890 }, +] + +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668 }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393 }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 }, +] + +[[package]] +name = "ruff" +version = "0.14.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540 }, + { url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384 }, + { url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917 }, + { url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112 }, + { url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559 }, + { url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379 }, + { url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786 }, + { url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029 }, + { url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037 }, + { url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390 }, + { url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793 }, + { url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039 }, + { url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158 }, + { url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550 }, + { url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332 }, + { url = "https://files.pythonhosted.org/packages/5d/93/2a5063341fa17054e5c86582136e9895db773e3c2ffb770dde50a09f35f0/ruff-0.14.8-py3-none-win32.whl", hash = "sha256:15f04cb45c051159baebb0f0037f404f1dc2f15a927418f29730f411a79bc4e7", size = 13151890 }, + { url = "https://files.pythonhosted.org/packages/02/1c/65c61a0859c0add13a3e1cbb6024b42de587456a43006ca2d4fd3d1618fe/ruff-0.14.8-py3-none-win_amd64.whl", hash = "sha256:9eeb0b24242b5bbff3011409a739929f497f3fb5fe3b5698aba5e77e8c833097", size = 14537826 }, + { url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236 }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084 }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832 }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052 }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555 }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128 }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445 }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165 }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891 }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796 }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121 }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070 }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859 }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296 }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124 }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698 }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819 }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766 }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771 }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586 }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792 }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909 }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946 }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705 }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244 }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637 }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925 }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045 }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835 }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109 }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930 }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964 }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065 }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088 }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193 }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488 }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669 }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709 }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563 }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756 }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408 }, +] + +[[package]] +name = "typer" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/28/7c85c8032b91dbe79725b6f17d2fffc595dff06a35c7a30a37bef73a1ab4/typer-0.20.0.tar.gz", hash = "sha256:1aaf6494031793e4876fb0bacfa6a912b551cf43c1e63c800df8b1a866720c37", size = 106492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488 }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, +] From 3fde7ebbf2edad04bd4d7fca5bd8f2626ece7f74 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Fri, 5 Dec 2025 21:53:33 +0530 Subject: [PATCH 2/6] push --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version 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 From a343ec856b2d46419e67ffb5c7766c837c3bbecd Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Fri, 5 Dec 2025 21:56:41 +0530 Subject: [PATCH 3/6] style: upgrade to Python 3.12+ syntax with pyupgrade - Remove obsolete utf-8 encoding declarations - Use collections.abc.Sequence instead of typing.Sequence - Use str | None instead of Optional[str] - Remove quotes from forward reference type hints --- src/google_cl/google_cl.py | 2 -- src/google_cl/main/application.py | 2 +- src/google_cl/main/auth.py | 2 +- src/google_cl/main/cli.py | 10 +++++----- src/google_cl/services/calendar.py | 4 ++-- src/google_cl/services/drive.py | 2 +- src/google_cl/services/gmail.py | 4 ++-- 7 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/google_cl/google_cl.py b/src/google_cl/google_cl.py index 7fbbae4..dd0b80e 100644 --- a/src/google_cl/google_cl.py +++ b/src/google_cl/google_cl.py @@ -1,3 +1 @@ -# -*- coding: utf-8 -*- - """Main module.""" diff --git a/src/google_cl/main/application.py b/src/google_cl/main/application.py index a1c8744..6c7618e 100644 --- a/src/google_cl/main/application.py +++ b/src/google_cl/main/application.py @@ -2,7 +2,7 @@ import logging import time -from typing import Sequence +from collections.abc import Sequence from google_cl import exceptions diff --git a/src/google_cl/main/auth.py b/src/google_cl/main/auth.py index 043259f..b2e7491 100644 --- a/src/google_cl/main/auth.py +++ b/src/google_cl/main/auth.py @@ -233,7 +233,7 @@ def revoke(self) -> bool: return True -def build_service(service_name: str, version: str, credentials: Credentials) -> "Resource": +def build_service(service_name: str, version: str, credentials: Credentials) -> Resource: """ Build a Google API service client. diff --git a/src/google_cl/main/cli.py b/src/google_cl/main/cli.py index 516b6bc..d9a8e03 100644 --- a/src/google_cl/main/cli.py +++ b/src/google_cl/main/cli.py @@ -314,7 +314,7 @@ def gmail_labels() -> None: @drive_app.command("list") def drive_list( count: Annotated[int, typer.Option("--count", "-n", help="Number of files to show")] = 20, - folder: Annotated[Optional[str], typer.Option("--folder", "-f", help="Folder ID")] = None, + folder: Annotated[str | None, typer.Option("--folder", "-f", help="Folder ID")] = None, ) -> None: """List files in Google Drive.""" from google_cl.services.drive import DriveService @@ -394,8 +394,8 @@ def drive_search( @drive_app.command("upload") def drive_upload( file_path: Annotated[Path, typer.Argument(help="Local file path to upload")], - name: Annotated[Optional[str], typer.Option("--name", "-n", help="Name in Drive")] = None, - folder: Annotated[Optional[str], typer.Option("--folder", "-f", help="Folder ID")] = None, + name: Annotated[str | None, typer.Option("--name", "-n", help="Name in Drive")] = None, + folder: Annotated[str | None, typer.Option("--folder", "-f", help="Folder ID")] = None, ) -> None: """Upload a file to Google Drive.""" from google_cl.services.drive import DriveService @@ -449,7 +449,7 @@ def drive_download( @drive_app.command("mkdir") def drive_mkdir( name: Annotated[str, typer.Argument(help="Folder name")], - parent: Annotated[Optional[str], typer.Option("--parent", "-p", help="Parent folder ID")] = None, + parent: Annotated[str | None, typer.Option("--parent", "-p", help="Parent folder ID")] = None, ) -> None: """Create a folder in Google Drive.""" from google_cl.services.drive import DriveService @@ -620,7 +620,7 @@ def calendar_upcoming( def calendar_add( title: Annotated[str, typer.Argument(help="Event title")], start: Annotated[str, typer.Option("--start", "-s", help="Start time (YYYY-MM-DD HH:MM)")], - end: Annotated[Optional[str], typer.Option("--end", "-e", help="End time")] = None, + end: Annotated[str | None, typer.Option("--end", "-e", help="End time")] = None, description: Annotated[str, typer.Option("--desc", "-d", help="Description")] = "", location: Annotated[str, typer.Option("--location", "-l", help="Location")] = "", all_day: Annotated[bool, typer.Option("--all-day", help="All-day event")] = False, diff --git a/src/google_cl/services/calendar.py b/src/google_cl/services/calendar.py index 183b35c..5616deb 100644 --- a/src/google_cl/services/calendar.py +++ b/src/google_cl/services/calendar.py @@ -29,7 +29,7 @@ class Calendar: foreground_color: str @classmethod - def from_api_response(cls, cal: dict[str, Any]) -> "Calendar": + def from_api_response(cls, cal: dict[str, Any]) -> Calendar: """Create Calendar from API response.""" return cls( id=cal.get("id", ""), @@ -60,7 +60,7 @@ class Event: recurrence: list[str] | None = None @classmethod - def from_api_response(cls, event: dict[str, Any]) -> "Event": + def from_api_response(cls, event: dict[str, Any]) -> Event: """Create Event from API response.""" # Handle start time (can be date or dateTime) start_data = event.get("start", {}) diff --git a/src/google_cl/services/drive.py b/src/google_cl/services/drive.py index c40b8bf..9db1ca3 100644 --- a/src/google_cl/services/drive.py +++ b/src/google_cl/services/drive.py @@ -65,7 +65,7 @@ class DriveFile: is_folder: bool @classmethod - def from_api_response(cls, file: dict[str, Any]) -> "DriveFile": + def from_api_response(cls, file: dict[str, Any]) -> DriveFile: """Create DriveFile from Drive API response.""" mime_type = file.get("mimeType", "") return cls( diff --git a/src/google_cl/services/gmail.py b/src/google_cl/services/gmail.py index 83667cb..725715a 100644 --- a/src/google_cl/services/gmail.py +++ b/src/google_cl/services/gmail.py @@ -33,7 +33,7 @@ class Email: labels: list[str] | None = None @classmethod - def from_api_response(cls, msg: dict[str, Any]) -> "Email": + def from_api_response(cls, msg: dict[str, Any]) -> Email: """Create Email from Gmail API response.""" headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])} @@ -74,7 +74,7 @@ class Label: messages_unread: int = 0 @classmethod - def from_api_response(cls, label: dict[str, Any]) -> "Label": + def from_api_response(cls, label: dict[str, Any]) -> Label: """Create Label from Gmail API response.""" return cls( id=label.get("id", ""), From 946e25b499918a5d822ed25553a4e34745c03aec Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Fri, 5 Dec 2025 21:57:21 +0530 Subject: [PATCH 4/6] fix: issues --- src/google_cl/main/cli.py | 41 ++++++++++++++---------------- src/google_cl/services/__init__.py | 4 +-- src/google_cl/services/calendar.py | 5 +--- tests/test_google_cl.py | 16 ++++++------ 4 files changed, 30 insertions(+), 36 deletions(-) diff --git a/src/google_cl/main/cli.py b/src/google_cl/main/cli.py index d9a8e03..5e6c511 100644 --- a/src/google_cl/main/cli.py +++ b/src/google_cl/main/cli.py @@ -10,20 +10,20 @@ import logging from datetime import datetime from pathlib import Path -from typing import Annotated, Optional +from typing import Annotated import typer +from rich import print as rprint from rich.console import Console -from rich.table import Table from rich.panel import Panel +from rich.table import Table from rich.text import Text -from rich import print as rprint from google_cl.main.auth import ( - GoogleAuth, DEFAULT_CREDENTIALS_FILE, DEFAULT_TOKEN_FILE, SCOPES, + GoogleAuth, ) # Initialize the main CLI app @@ -197,13 +197,13 @@ def gmail_read( # Create a styled panel for the email content = Text() - content.append(f"From: ", style="bold cyan") + content.append("From: ", style="bold cyan") content.append(f"{email.sender}\n") - content.append(f"To: ", style="bold cyan") + content.append("To: ", style="bold cyan") content.append(f"{email.to}\n") - content.append(f"Date: ", style="bold cyan") + content.append("Date: ", style="bold cyan") content.append(f"{email.date}\n") - content.append(f"Subject: ", style="bold cyan") + content.append("Subject: ", style="bold cyan") content.append(f"{email.subject}\n\n") content.append(email.body or email.snippet) @@ -230,7 +230,7 @@ def gmail_send( gmail = GmailService(auth.credentials) result = gmail.send_message(to=to, subject=subject, body=body, html=html) - rprint(f"[green]✓ Email sent successfully![/green]") + rprint("[green]✓ Email sent successfully![/green]") rprint(f" Message ID: {result.get('id')}") except Exception as e: @@ -405,7 +405,7 @@ def drive_upload( drive = DriveService(auth.credentials) result = drive.upload_file(file_path=file_path, name=name, folder_id=folder) - rprint(f"[green]✓ File uploaded successfully![/green]") + rprint("[green]✓ File uploaded successfully![/green]") rprint(f" Name: {result.name}") rprint(f" ID: {result.id}") if result.web_view_link: @@ -459,7 +459,7 @@ def drive_mkdir( drive = DriveService(auth.credentials) result = drive.create_folder(name=name, parent_id=parent) - rprint(f"[green]✓ Folder created![/green]") + rprint("[green]✓ Folder created![/green]") rprint(f" Name: {result.name}") rprint(f" ID: {result.id}") @@ -482,9 +482,9 @@ def drive_delete( drive.delete_file(file_id=file_id, permanent=permanent) if permanent: - rprint(f"[green]✓ File permanently deleted[/green]") + rprint("[green]✓ File permanently deleted[/green]") else: - rprint(f"[green]✓ File moved to trash[/green]") + rprint("[green]✓ File moved to trash[/green]") except Exception as e: rprint(f"[red]Error: {e}[/red]") @@ -556,10 +556,7 @@ def calendar_today() -> None: else: # Parse and format time start_str = str(event.start) - if "T" in start_str: - time_str = start_str.split("T")[1][:5] - else: - time_str = start_str + time_str = start_str.split("T")[1][:5] if "T" in start_str else start_str table.add_row(time_str, event.summary[:40], event.location[:30]) @@ -657,7 +654,7 @@ def calendar_add( all_day=all_day, ) - rprint(f"[green]✓ Event created![/green]") + rprint("[green]✓ Event created![/green]") rprint(f" Title: {event.summary}") rprint(f" ID: {event.id}") if event.html_link: @@ -681,7 +678,7 @@ def calendar_quick( event = calendar.quick_add(text) - rprint(f"[green]✓ Event created![/green]") + rprint("[green]✓ Event created![/green]") rprint(f" Title: {event.summary}") rprint(f" Start: {event.start}") if event.html_link: @@ -704,7 +701,7 @@ def calendar_delete( calendar = CalendarService(auth.credentials) calendar.delete_event(event_id) - rprint(f"[green]✓ Event deleted[/green]") + rprint("[green]✓ Event deleted[/green]") except Exception as e: rprint(f"[red]Error: {e}[/red]") @@ -754,9 +751,9 @@ def version() -> None: @app.command() def status() -> None: """Show status of all services.""" - from google_cl.services.gmail import GmailService - from google_cl.services.drive import DriveService from google_cl.services.calendar import CalendarService + from google_cl.services.drive import DriveService + from google_cl.services.gmail import GmailService auth = GoogleAuth() diff --git a/src/google_cl/services/__init__.py b/src/google_cl/services/__init__.py index 63a6268..8f35129 100644 --- a/src/google_cl/services/__init__.py +++ b/src/google_cl/services/__init__.py @@ -1,8 +1,8 @@ """Google services implementations.""" -from google_cl.services.gmail import GmailService -from google_cl.services.drive import DriveService from google_cl.services.calendar import CalendarService +from google_cl.services.drive import DriveService +from google_cl.services.gmail import GmailService __all__ = ["GmailService", "DriveService", "CalendarService"] diff --git a/src/google_cl/services/calendar.py b/src/google_cl/services/calendar.py index 5616deb..0c12bfa 100644 --- a/src/google_cl/services/calendar.py +++ b/src/google_cl/services/calendar.py @@ -73,10 +73,7 @@ def from_api_response(cls, event: dict[str, Any]) -> Event: # Handle end time end_data = event.get("end", {}) - if "dateTime" in end_data: - end = end_data["dateTime"] - else: - end = end_data.get("date", "") + end = end_data["dateTime"] if "dateTime" in end_data else end_data.get("date", "") creator = event.get("creator", {}) diff --git a/tests/test_google_cl.py b/tests/test_google_cl.py index d25e458..148fcc4 100644 --- a/tests/test_google_cl.py +++ b/tests/test_google_cl.py @@ -1,12 +1,12 @@ """Tests for GoogleCL package.""" +from unittest.mock import MagicMock, patch + import pytest from typer.testing import CliRunner -from unittest.mock import patch, MagicMock -from google_cl.main.cli import app from google_cl import __version__ - +from google_cl.main.cli import app runner = CliRunner() @@ -75,7 +75,7 @@ def test_scopes_defined(self): assert "sheets" in SCOPES # Each service should have at least one scope - for service, scopes in SCOPES.items(): + for _service, scopes in SCOPES.items(): assert len(scopes) > 0 assert all(s.startswith("https://www.googleapis.com/auth/") for s in scopes) @@ -96,12 +96,12 @@ class TestExceptions: def test_exception_hierarchy(self): """Test exception inheritance.""" from google_cl.exceptions import ( - GoogleCLException, - ExecutionError, - EarlyQuit, AuthenticationError, - ServiceError, ConfigurationError, + EarlyQuit, + ExecutionError, + GoogleCLException, + ServiceError, ) assert issubclass(ExecutionError, GoogleCLException) From 64273c28b9dd7c6636763e1b19da95b73315b356 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Fri, 5 Dec 2025 21:58:23 +0530 Subject: [PATCH 5/6] fix: add 'from None' to exception re-raises (B904) Fixes ruff B904 lint errors by properly suppressing exception chains when raising typer.Exit(1) in CLI error handlers. --- src/google_cl/main/cli.py | 46 +++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/google_cl/main/cli.py b/src/google_cl/main/cli.py index 5e6c511..efcf7d3 100644 --- a/src/google_cl/main/cli.py +++ b/src/google_cl/main/cli.py @@ -84,10 +84,10 @@ def auth_login( rprint("[green]✓ Successfully authenticated![/green]") except FileNotFoundError as e: rprint(f"[red]✗ {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None except Exception as e: rprint(f"[red]✗ Authentication failed: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @auth_app.command("logout") @@ -179,7 +179,7 @@ def gmail_inbox( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @gmail_app.command("read") @@ -212,7 +212,7 @@ def gmail_read( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @gmail_app.command("send") @@ -235,7 +235,7 @@ def gmail_send( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @gmail_app.command("search") @@ -271,7 +271,7 @@ def gmail_search( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @gmail_app.command("labels") @@ -303,7 +303,7 @@ def gmail_labels() -> None: except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None # ============================================================================ @@ -353,7 +353,7 @@ def drive_list( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @drive_app.command("search") @@ -388,7 +388,7 @@ def drive_search( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @drive_app.command("upload") @@ -413,10 +413,10 @@ def drive_upload( except FileNotFoundError: rprint(f"[red]Error: File not found: {file_path}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @drive_app.command("download") @@ -443,7 +443,7 @@ def drive_download( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @drive_app.command("mkdir") @@ -465,7 +465,7 @@ def drive_mkdir( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @drive_app.command("delete") @@ -488,7 +488,7 @@ def drive_delete( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @drive_app.command("quota") @@ -522,7 +522,7 @@ def format_size(size: int) -> str: except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None # ============================================================================ @@ -564,7 +564,7 @@ def calendar_today() -> None: except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @calendar_app.command("upcoming") @@ -610,7 +610,7 @@ def calendar_upcoming( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @calendar_app.command("add") @@ -634,7 +634,7 @@ def calendar_add( start_dt = datetime.fromisoformat(start.replace(" ", "T")) except ValueError: rprint("[red]Invalid start time format. Use: YYYY-MM-DD HH:MM[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None # Parse end time if provided end_dt = None @@ -643,7 +643,7 @@ def calendar_add( end_dt = datetime.fromisoformat(end.replace(" ", "T")) except ValueError: rprint("[red]Invalid end time format. Use: YYYY-MM-DD HH:MM[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None event = calendar.create_event( summary=title, @@ -662,7 +662,7 @@ def calendar_add( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @calendar_app.command("quick") @@ -686,7 +686,7 @@ def calendar_quick( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @calendar_app.command("delete") @@ -705,7 +705,7 @@ def calendar_delete( except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None @calendar_app.command("calendars") @@ -733,7 +733,7 @@ def calendar_list() -> None: except Exception as e: rprint(f"[red]Error: {e}[/red]") - raise typer.Exit(1) + raise typer.Exit(1) from None # ============================================================================ From 5e904bbc140fab66b17be626533e6d3579de9d54 Mon Sep 17 00:00:00 2001 From: Vinit Kumar Date: Fri, 5 Dec 2025 22:57:24 +0530 Subject: [PATCH 6/6] ci: modernize GitHub Actions with uv and ruff - Use uv for package management and Python installation - Use ruff for linting and formatting checks - Add mypy type checking job - Support Python 3.12, 3.13, and 3.14 - Test on Ubuntu, macOS, and Windows - Use latest actions/checkout@v4 and astral-sh/setup-uv@v4 --- .github/workflows/pythonpackage.yml | 88 +++++++++++++++++++---------- 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index d35fb76..2206b67 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -1,39 +1,69 @@ name: Python package -on: [push] +on: + push: + branches: [master, main, revival-poc] + pull_request: + branches: [master, main] jobs: - build: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + - name: Set up Python + run: uv python install 3.12 + - name: Install dependencies + run: uv sync --dev + - name: Lint with ruff + run: | + uv run ruff check src tests + uv run ruff format --check src tests + + test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - python-version: [3.7, 3.8, 3.9, pypy-3.8, 3.10.0, '3.11.0-alpha.3'] - os: [ - ubuntu-latest, - macOS-latest, - windows-latest, - ] + python-version: ['3.12', '3.13', '3.14'] + os: [ubuntu-latest, macos-latest, windows-latest] + exclude: + # Python 3.14 is in development, exclude on Windows for now + - os: windows-latest + python-version: '3.14' + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --dev + + - name: Run tests + run: uv run pytest tests/ -v + typecheck: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pytest - pip install -r requirements_dev.txt - python setup.py install - - name: Lint with flake8 - run: | - pip install flake8 - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - - name: Test with pytest - run: | - python setup.py test + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + - name: Set up Python + run: uv python install 3.12 + - name: Install dependencies + run: uv sync --dev + - name: Type check with mypy + run: uv run mypy src --ignore-missing-imports