diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/1_bug_report.yml similarity index 65% rename from .github/ISSUE_TEMPLATE/bug_report.yml rename to .github/ISSUE_TEMPLATE/1_bug_report.yml index 4d4a352..0cd76f0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1_bug_report.yml @@ -1,7 +1,6 @@ name: 🐛 Bug report description: Create a report to help us improve the project -assignees: - +labels: ["bug"] body: - type: markdown attributes: @@ -9,7 +8,7 @@ body: #### Before reporting a bug, please check that the issue hasn't already been addressed in [the existing and past issues](https://github.com/relaycli/relay/issues?q=is%3Aissue). - type: textarea attributes: - label: Bug description + label: Summary description: | A clear and concise description of what the bug is. @@ -23,18 +22,18 @@ body: attributes: label: Code snippet to reproduce the bug description: | - Sample code to reproduce the problem. + Minimal code snippet to reproduce the problem. Please wrap your code snippet with ```` ```triple quotes blocks``` ```` for readability. placeholder: | ```python - Sample code to reproduce the problem + Minimal code snippet to reproduce the problem ``` validations: required: true - type: textarea attributes: - label: Error traceback + label: Console output description: | The error message you received running the code snippet, with the full traceback. @@ -45,15 +44,34 @@ body: ``` validations: required: true + - type: input + attributes: + label: Platform + description: What operating system and architecture are you using? (see `uname -orsm`) + placeholder: e.g., macOS 14 arm64, Windows 11 x86_64, Ubuntu 20.04 amd64 + validations: + required: true + - type: input + attributes: + label: Version + description: What version of relaycli are you using? (see `relay version`) + placeholder: e.g., relay 0.0.1 + validations: + required: true + - type: input + attributes: + label: Python version + description: What version of Python are you using? (see `python --version`) + placeholder: e.g., Python 3.11.10 + validations: + required: false - type: textarea attributes: - label: Environment + label: Additional information description: | Being able to reproduce the behaviour is key to resolving bugs. Share a few information about your setup: placeholder: | - - OS: Mac/Windows/Linux - From source: Y/N - - Release version: - Commit hash: validations: required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/2_feature_request.yml similarity index 98% rename from .github/ISSUE_TEMPLATE/feature_request.yml rename to .github/ISSUE_TEMPLATE/2_feature_request.yml index 6cd158f..e212cdb 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/2_feature_request.yml @@ -1,5 +1,5 @@ name: 🚀 Feature request -description: Submit a proposal/request for a new feature for the companion API +description: Submit a proposal/request for a new feature for the CLI assignees: body: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7d774a3..0c352e8 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,11 @@ blank_issues_enabled: true contact_links: + - name: Documentation + url: https://docs.relaycli.com/ + about: Please consult the documentation before creating an issue. + - name: Community + url: https://discord.gg/T4zbT7RcVy + about: Join our Discord community to ask questions and collaborate. - name: Usage questions url: https://github.com/relaycli/relay/discussions about: Ask questions and discuss with other Relay community members diff --git a/.github/labeler.yml b/.github/labeler.yml index 25348a1..7ed5b26 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -90,12 +90,12 @@ # CLI-specific ######################################################## -'commands: account': +'commands: accounts': - changed-files: - any-glob-to-any-file: - relay/cli/commands/account/* -'commands: auth': +'commands: messages': - changed-files: - any-glob-to-any-file: - - relay/cli/commands/auth/* + - relay/cli/commands/messages/* diff --git a/.github/release.yml b/.github/release.yml index 567e08e..ab20040 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -3,17 +3,11 @@ changelog: labels: - ignore-for-release categories: - - title: Breaking Changes 🛠 + - title: Breaking changes 🛠 labels: ["type: breaking change"] - - title: New Features ✨ - labels: ["type: feat"] + - title: New features & enhancements ✨ + labels: ["type: feat", "type: enhancement"] - title: Bug Fixes 🐛 labels: ["type: fix"] - - title: Dependencies - labels: ["dependencies"] - title: Documentation 📖 labels: ["service: docs"] - - title: Improvements - labels: ["type: improvement"] - - title: Other changes - labels: ["*"] diff --git a/.github/workflows/core.yml b/.github/workflows/package.yml similarity index 85% rename from .github/workflows/core.yml rename to .github/workflows/package.yml index 23d5c37..6af58ef 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/package.yml @@ -1,22 +1,22 @@ -name: core +name: package on: push: branches: main paths: + - '.github/workflows/package.yml' - 'relay/**' - - 'cli/**' + - 'tests/**' - 'pyproject.toml' - 'Makefile' - - '.github/workflows/core.yml' pull_request: branches: main paths: + - '.github/workflows/package.yml' - 'relay/**' - - 'cli/**' + - 'tests/**' - 'pyproject.toml' - 'Makefile' - - '.github/workflows/core.yml' release: types: [published] @@ -28,12 +28,16 @@ env: jobs: install: if: github.event_name == 'pull_request' - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python: ['3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: ${{ env.PYTHON_VERSION }} + python-version: ${{ matrix.python }} architecture: x64 - uses: astral-sh/setup-uv@v6 with: @@ -126,13 +130,18 @@ jobs: fail_ci_if_error: true build: - if: github.event_name != 'release' - runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + needs: install + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python: ['3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: ${{ env.PYTHON_VERSION }} + python-version: ${{ matrix.python }} architecture: x64 - uses: astral-sh/setup-uv@v6 with: @@ -170,20 +179,21 @@ jobs: echo "package_version=${BUILD_VERSION}" >> $GITHUB_OUTPUT echo "BUILD_VERSION=${BUILD_VERSION}" >> $GITHUB_ENV - name: Publish to PyPI - env: - UV_PUBLISH_USERNAME: __token__ - UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN }} run: | make set-version make build && make publish verify-publish: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python: ['3.11', '3.12', '3.13'] needs: publish steps: - uses: actions/setup-python@v5 with: - python-version: ${{ env.PYTHON_VERSION }} + python-version: ${{ matrix.python }} architecture: x64 - uses: astral-sh/setup-uv@v6 with: diff --git a/Makefile b/Makefile index 9b56eac..cf02b1f 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ build: ${PYPROJECT_CONFIG_FILE} ## Build the package uv build ${ENGINE_DIR} publish: ${ENGINE_DIR} ## Publish the package to PyPI - uv publish + uv publish --trusted-publishing always lock: ${PYPROJECT_CONFIG_FILE} uv lock --project ${ENGINE_DIR} diff --git a/README.md b/README.md index a6d0d3a..38768e1 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,9 @@ Relay helps builds create apps on email workflows. See it as a crossover between ### Fetching your unread emails ```shell -$ relay messages ls --limit 10 --unread - +relay messages ls --limit 10 --unread +``` +``` Using account: piedpiper Messages from richard@piedpiper.com ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━┓ @@ -80,8 +81,9 @@ Showing 5 of 5 unread messages ### Reading email details ```shell -$ relay messages cat 15443 - +relay messages cat 15443 +``` +``` Using account: piedpiper Message Details @@ -96,7 +98,7 @@ BCC: N/A Message Body: The migration is done. Obviously. -While you were all probably panicking about downtime (which never happened), +While you were all probably panicking about downtime (which never happened), I successfully migrated our entire server infrastructure to the new data center. Key accomplishments: @@ -105,13 +107,13 @@ Key accomplishments: - Optimized database queries by 340% - Fixed 23 security vulnerabilities -Richard, the system is now running at 99.97% efficiency. The remaining 0.03% +Richard, the system is now running at 99.97% efficiency. The remaining 0.03% is due to the laws of physics, which even I cannot override. -Dinesh, I've documented everything in a way that even you might comprehend, +Dinesh, I've documented everything in a way that even you might comprehend, though I make no guarantees. -The servers are purring like a well-fed cat. You may now return to your +The servers are purring like a well-fed cat. You may now return to your regularly scheduled mediocrity. -- @@ -137,15 +139,16 @@ pip install relaycli ``` #### 2 - Connect your email account ```shell -relay account add +relay accounts add ``` Follow the instructions to connect your email account. #### 3 - Play with the CLI ```shell -$ relay messages --help - +relay messages --help +``` +``` Usage: relay messages [OPTIONS] COMMAND [ARGS]... Email message commands diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index ba68fa3..12f537a 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -13,8 +13,8 @@ The changelog below reflects new product developments and updates on a monthly b ### 🔧 CLI Commands - * `relay account add` - Connect your email accounts (Gmail, Outlook, Yahoo, custom IMAP) - * `relay account ls` - List all connected accounts + * `relay accounts add` - Connect your email accounts (Gmail, Outlook, Yahoo, custom IMAP) + * `relay accounts ls` - List all connected accounts * `relay messages ls` - Fetch and list recent emails * `relay messages grep` - Search your inbox with powerful text matching * `relay messages cat` - Read full message content and metadata @@ -40,6 +40,6 @@ The changelog below reflects new product developments and updates on a monthly b ### 🚀 Getting Started - Install with `pip install relaycli` and run `relay account add` to connect your first inbox. + Install with `pip install relaycli` and run `relay accounts add` to connect your first inbox. Check out our [quickstart guide](/documentation/getting-started/quickstart) for a 5-minute setup walkthrough. diff --git a/docs/docs.json b/docs/docs.json index ec21fca..a01cf7d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -36,6 +36,18 @@ } ] }, + { + "tab": "Reference", + "groups": [ + { + "group": "CLI reference", + "pages": [ + "reference/cli/accounts", + "reference/cli/messages" + ] + } + ] + }, { "tab": "Changelog", "groups": [ @@ -76,8 +88,9 @@ "navbar": { "links": [ { - "label": "Support", - "href": "mailto:hi@relaycli.com" + "label": "Community", + "icon": "discord", + "href": "https://discord.gg/T4zbT7RcVy" } ], "primary": { diff --git a/docs/documentation/getting-started/introduction.mdx b/docs/documentation/getting-started/introduction.mdx index 9e3ff64..9b77263 100644 --- a/docs/documentation/getting-started/introduction.mdx +++ b/docs/documentation/getting-started/introduction.mdx @@ -24,7 +24,7 @@ Your credentials stay local. Your data stays yours. No gatekeepers, no vendor lo ## Why we built it -Email is a billion-person communication protocol, but building on it sucks. +Email is a billion-person communication protocol, but building on it sucks. We hit this wall building a voice assistant for founders when we received the bill for the security audit to validate Gmail permissions. You can read the full story [here](https://relaycli.com/manifesto). So we built Relay: a clean, local-first CLI and Python library that handles the complexity while keeping you in control. No middlemen, no data harvesting, no artificial limits. diff --git a/docs/documentation/getting-started/quickstart.mdx b/docs/documentation/getting-started/quickstart.mdx index 91ee96a..de07e88 100644 --- a/docs/documentation/getting-started/quickstart.mdx +++ b/docs/documentation/getting-started/quickstart.mdx @@ -27,40 +27,20 @@ description: 'From zero to "Hello inbox" in under 5 minutes' - - - - ```shell title="curl" icon="code" - curl -sSL https://github.com/relaycli/relay/releases/download/v0.0.1-alpha.2/install.sh | bash - ``` - ```shell title="wget" icon="code" - wget -qO- https://github.com/relaycli/relay/releases/download/v0.0.1-alpha.2/install.sh | bash - ``` - ```shell title="pip" icon="python" - pip install relaycli - ``` - ```shell title="uv" icon="python" - uv pip install --system relaycli - ``` - - If you encounter issues, please follow the installation steps for either [pip](https://pip.pypa.io/en/stable/installation/) or [uv](https://docs.astral.sh/uv/getting-started/installation) and then run the corresponding installation command. - - - - ```shell title="pip" icon="python" - pip install relaycli - ``` - ```shell title="uv" icon="python" - uv pip install --system relaycli - ``` - - If you encounter issues, please follow the installation steps for either [pip](https://pip.pypa.io/en/stable/installation/) or [uv](https://docs.astral.sh/uv/getting-started/installation) and then run the corresponding installation command. - - + + ```shell title="pip" icon="python" + pip install relaycli + ``` + ```shell title="uv" icon="zap" + uv pip install --system relaycli + ``` + + + If you encounter issues, please follow the installation steps for either [pip](https://pip.pypa.io/en/stable/installation/) or [uv](https://docs.astral.sh/uv/getting-started/installation) and then run the corresponding installation command. ```shell - relay account add + relay accounts add ``` @@ -113,7 +93,7 @@ description: 'From zero to "Hello inbox" in under 5 minutes' Message Body: The migration is done. Obviously. - While you were all probably panicking about downtime (which never happened), + While you were all probably panicking about downtime (which never happened), I successfully migrated our entire server infrastructure to the new data center. Key accomplishments: @@ -122,13 +102,13 @@ description: 'From zero to "Hello inbox" in under 5 minutes' - Optimized database queries by 340% - Fixed 23 security vulnerabilities - Richard, the system is now running at 99.97% efficiency. The remaining 0.03% + Richard, the system is now running at 99.97% efficiency. The remaining 0.03% is due to the laws of physics, which even I cannot override. - Dinesh, I've documented everything in a way that even you might comprehend, + Dinesh, I've documented everything in a way that even you might comprehend, though I make no guarantees. - The servers are purring like a well-fed cat. You may now return to your + The servers are purring like a well-fed cat. You may now return to your regularly scheduled mediocrity. -- diff --git a/docs/reference/cli/accounts.mdx b/docs/reference/cli/accounts.mdx new file mode 100644 index 0000000..c335561 --- /dev/null +++ b/docs/reference/cli/accounts.mdx @@ -0,0 +1,152 @@ +--- +title: 'Accounts' +description: 'Manage the connections to your email accounts' +icon: 'user' +--- + +What those CLI commands are for: +- connecting email accounts +- testing the connection with your credentials (stored locally, encrypted with a key you own) + +What they're not for: +- creating or deleting email accounts + +Should you need help with the CLI syntax, you can always invoke: +```shell +relay accounts --help +``` + +## Connect an email account + +Add a new IMAP account with interactive setup. The CLI will auto-detect your email provider and configure the appropriate settings. + + + +```shell +relay accounts add +``` + +Follow the prompts to enter your account details. The CLI will guide you through: +- Account name (for reference) +- Email address +- Provider selection (auto-detected when possible) +- Password (with confirmation) +- Server settings (auto-configured for known providers) + + + +```shell +relay accounts add \ + --name "work" \ + --email "user@company.com" \ + --provider custom \ + --imap-server "imap.company.com" \ + --imap-port 993 +``` + + +For Gmail, Outlook, Yahoo, and iCloud, the CLI will automatically configure the correct server settings for `imap-server` and `imap-port`. + + + + +### Supported providers + + + +Automatically configures `imap.gmail.com:993` + + + +Automatically configures `outlook.office365.com:993` + + + +Automatically configures `imap.mail.yahoo.com:993` + + + +Automatically configures `imap.mail.me.com:993` + + + + +For Gmail, you'll need to use an App Password instead of your regular password. Generate one at [myaccount.google.com/apppasswords](https://myaccount.google.com/apppasswords). + + +### Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--name` | `-n` | Account name for reference | +| `--email` | `-e` | Email address | +| `--provider` | `-p` | Email provider (gmail, outlook, yahoo, icloud, custom) | +| `--imap-server` | | IMAP server address (for custom providers) | +| `--imap-port` | | IMAP port (for custom providers) | + +## List email account connections + +Display all configured email accounts in a formatted table. + +```shell +relay account list +``` + +**Alias:** `relay account ls` + +The output shows: +- Account name +- Email address +- Provider type +- IMAP server +- IMAP port + + +If no accounts are configured, the CLI will display a helpful message prompting you to add one. + + +## Disconnect an email account + +Remove an account and its stored credentials from your system. + + + +```shell +relay account remove work +``` + +The CLI will ask for confirmation before removing the account. + + + +```shell +relay account remove work --force +``` + +**Aliases:** `--force`, `-f`, `-y` + +Removes the account without asking for confirmation. + + + + +This action is irreversible. You'll need to re-add the account if you want to use it again. + + +## Test a connection + +Verify that your account credentials are working and the server is reachable. + +```shell +relay account test work +``` + +This command will: +- Connect to the IMAP server +- Authenticate with your stored credentials +- Verify the connection is working +- Display success or error messages + + +Use this command to troubleshoot connection issues or verify your setup after adding an account. + diff --git a/docs/reference/cli/messages.mdx b/docs/reference/cli/messages.mdx new file mode 100644 index 0000000..797bdc9 --- /dev/null +++ b/docs/reference/cli/messages.mdx @@ -0,0 +1,181 @@ +--- +title: 'Messages' +description: 'Manage your emails from your console' +icon: 'message' +--- + +What those CLI commands are for: +- reading emails from your connected accounts +- sorting/processing them + +What they're not for: +- sending bulk email campaigns (we love [Resend](https://resend.com) for that) + +Should you need help with the CLI syntax, you can always invoke: +```shell +relay messages --help +``` + +In IMAP, emails are bound to a folder. So if you move an email to another folder, it will create a new UID. +Consider the MIME email head Message-ID if you want a global unique identifier for an email + +## List your recent emails + +Display your recent emails in a formatted table with UID, timestamp, sender, subject, and snippet. + +```shell +relay messages list +``` + +**Alias:** `relay messages ls` + +Shows the 20 most recent emails from your first configured account. + +### Options + +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--account` | `-a` | Account name to use | First configured account | +| `--limit` | `-l` | Number of messages to fetch | 20 | +| `--unread` | `-u` | Show only unread messages | false | + + +The table displays: UID, Timestamp (UTC), From, Subject, and Snippet for easy scanning. + + +## Search your inbox + +Search for messages containing specific text in the subject, sender, or body. + + +```shell +relay messages search "meeting" +``` + +**Aliases:** `relay messages find`, `relay messages grep` + +Searches through the most recent 100 messages for the term "meeting". + +### Search behavior + +The search function: +- Searches in subject lines, sender addresses, and message body text +- Is case-insensitive +- Returns results sorted by date (newest first) +- Shows the same table format as the list command + + +Use specific search terms to narrow down results. The search looks through subject, sender, and body content. + + +### Options + +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--account` | `-a` | Account name to use | First configured account | +| `--limit` | `-l` | Number of messages to search | 100 | + +## Read a specific email + +Display the full content of a specific email message by its UID. + +```shell +relay messages open 12345 +``` + +**Alias:** `relay messages cat` + +Opens the message with UID 12345 from your first configured account. + +### Message display + +The command shows: +- **Message Details**: UID, timestamp, subject +- **Headers**: From, CC, BCC +- **Body**: Plain text content +- **Attachments**: List with filenames, content types, and sizes + + +The UID is specific to each account and folder. You can get UIDs from the `relay messages list` command. + + +### Options + +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--account` | `-a` | Account name to use | First configured account | + +## Move an email to trash + +Move a specific email message to the trash folder. + +```shell +relay messages trash 12345 +``` + +**Alias:** `relay messages rm` + +Moves the message with UID 12345 to trash. + + +This moves the email to your email provider's trash folder. The behavior depends on your email provider's trash handling. + + +### Options + +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--account` | `-a` | Account name to use | First configured account | + +## Mark an email as spam + +Mark a specific email message as spam. + +```shell +relay messages spam 12345 +``` + +### Options + +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--account` | `-a` | Account name to use | First configured account | + +## Mark an email as read/unread status + +Change the read status of a specific email message. + + + +```shell +relay messages mark read 12345 +``` + +Marks the message with UID 12345 as read. + + + +```shell +relay messages mark unread 12345 +``` + +Marks the message with UID 12345 as unread. + + + +### Status options + +| Status | Description | +|--------|-------------| +| `read` | Mark the message as read | +| `unread` | Mark the message as unread | + +### Options + +| Option | Short | Description | Default | +|--------|-------|-------------|---------| +| `--account` | `-a` | Account name to use | First configured account | + + +The read/unread status is synchronized with your email provider, so changes will be reflected in other email clients. + diff --git a/pyproject.toml b/pyproject.toml index ad74617..5897243 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "uv_build" name = "relaycli" version = "0.0.1.dev0" description = "Open-source IMAP-native replacement for Gmail API" -requires-python = ">=3.10,<4.0" +requires-python = ">=3.11,<4.0" license = { file = "LICENSE" } authors = [{ name = "Relay team", email = "support@relaycli.com" }] readme = "README.md" @@ -33,7 +33,6 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", diff --git a/relay/cli/commands/account.py b/relay/cli/commands/accounts.py similarity index 93% rename from relay/cli/commands/account.py rename to relay/cli/commands/accounts.py index 4386f6c..8bccb24 100644 --- a/relay/cli/commands/account.py +++ b/relay/cli/commands/accounts.py @@ -22,8 +22,8 @@ ServerConnectionError, ValidationError, ) -from relay.models.account import PROVIDER_CONFIGS, AccountCreate, EmailProvider -from relay.providers.utils import resolve_provider +from relay.models.account import AccountCreate, EmailProvider +from relay.providers.utils import EMAIL_TO_PROVIDER, PROVIDER_INFO from .._utils import AliasGroup, create_accounts_table @@ -117,24 +117,24 @@ def connect_account( final_provider = provider if not final_provider: email_domain = email.rpartition("@")[-1].lower() - final_provider = resolve_provider("", f"user@{email_domain}") - if final_provider == EmailProvider.CUSTOM: + final_provider = EMAIL_TO_PROVIDER.get(email_domain) + if final_provider is None: console.print(f"[yellow]Unknown provider for domain: {email_domain}[/yellow]") final_provider = _get_provider_choice() # Get server settings final_imap_server = imap_server if imap_server is None: - if final_provider in PROVIDER_CONFIGS: - final_imap_server = PROVIDER_CONFIGS[final_provider]["imap_server"] + if final_provider in PROVIDER_INFO: + final_imap_server = PROVIDER_INFO[final_provider]["imap"]["server"] console.print(f"[green]Using {final_provider.value} settings for IMAP server: {final_imap_server}[/green]") else: final_imap_server = Prompt.ask("IMAP server", default="imap.gmail.com") final_imap_port = imap_port if imap_port is None: - if final_provider in PROVIDER_CONFIGS: - final_imap_port = PROVIDER_CONFIGS[final_provider]["imap_port"] + if final_provider in PROVIDER_INFO: + final_imap_port = PROVIDER_INFO[final_provider]["imap"]["port"] console.print(f"[green]Using {final_provider.value} settings for IMAP port: {final_imap_port}[/green]") else: final_imap_port = int(Prompt.ask("IMAP port", default="993")) diff --git a/relay/cli/main.py b/relay/cli/main.py index aae2611..a84c7a9 100644 --- a/relay/cli/main.py +++ b/relay/cli/main.py @@ -10,7 +10,7 @@ import relay -from .commands.account import app as account_app +from .commands.accounts import app as account_app from .commands.messages import app as messages_app console = Console() @@ -21,7 +21,7 @@ ) # Add subcommands -app.add_typer(account_app, name="account") +app.add_typer(account_app, name="accounts") app.add_typer(messages_app, name="messages") diff --git a/relay/models/account.py b/relay/models/account.py index 39ad3ac..d7dcaea 100644 --- a/relay/models/account.py +++ b/relay/models/account.py @@ -9,21 +9,12 @@ from pydantic import BaseModel, EmailStr, Field, field_validator -from ..providers.utils import resolve_provider +from ..providers.utils import EMAIL_TO_PROVIDER, PROVIDER_INFO from .base import EmailProvider __all__ = ["Account", "AccountCreate", "AccountInfo"] -# Common IMAP server configurations -PROVIDER_CONFIGS = { - EmailProvider.GMAIL: {"imap_server": "imap.gmail.com", "imap_port": 993}, - EmailProvider.OUTLOOK: {"imap_server": "outlook.office365.com", "imap_port": 993}, - EmailProvider.YAHOO: {"imap_server": "imap.mail.yahoo.com", "imap_port": 993}, - EmailProvider.ICLOUD: {"imap_server": "imap.mail.me.com", "imap_port": 993}, -} - - class AccountBase(BaseModel): """Base account model with common fields.""" @@ -51,17 +42,17 @@ def __init__(self, **data) -> None: # Auto-detect provider from email if not set if "provider" not in data or data["provider"] == "custom": email = data.get("email", "") - data["provider"] = resolve_provider("", email) + data["provider"] = EMAIL_TO_PROVIDER.get(email.rpartition("@")[-1].lower(), EmailProvider.CUSTOM) # Auto-fill server settings based on provider provider = data.get("provider", EmailProvider.CUSTOM) if isinstance(provider, str): provider = EmailProvider(provider) - if provider in PROVIDER_CONFIGS and not data.get("imap_server"): - data["imap_server"] = PROVIDER_CONFIGS[provider]["imap_server"] - if provider in PROVIDER_CONFIGS and not data.get("imap_port"): - data["imap_port"] = PROVIDER_CONFIGS[provider]["imap_port"] + if provider in PROVIDER_INFO and not data.get("imap_server") and provider != EmailProvider.CUSTOM: + data["imap_server"] = PROVIDER_INFO[provider]["imap"]["server"] + if provider in PROVIDER_INFO and not data.get("imap_port") and provider != EmailProvider.CUSTOM: + data["imap_port"] = PROVIDER_INFO[provider]["imap"]["port"] super().__init__(**data) diff --git a/relay/providers/imap.py b/relay/providers/imap.py index a50f07d..222cdf8 100644 --- a/relay/providers/imap.py +++ b/relay/providers/imap.py @@ -14,101 +14,79 @@ from typing import Any, cast from bs4 import BeautifulSoup +from email_validator import EmailNotValidError, validate_email from html2text import html2text from ..exceptions import AuthenticationError, ServerConnectionError, ValidationError from ..models.account import EmailProvider -from .utils import resolve_provider +from .utils import EMAIL_TO_PROVIDER, IMAP_TO_PROVIDER, PROVIDER_INFO EMAIL_PATTERN = r"<[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}>" __all__ = ["IMAPClient"] -EMAIL_PROVIDERS = { - EmailProvider.GMAIL: { - "email_domains": ["gmail.com", "googlemail.com"], - "server_domains": ["gmail.com"], - "folders": { - "inbox": "INBOX", - "trash": "[Gmail]/Trash", - "spam": "[Gmail]/Spam", - "sent": "[Gmail]/Sent Mail", - "drafts": "[Gmail]/Drafts", - }, - }, - EmailProvider.OUTLOOK: { - "email_domains": ["outlook.com", "hotmail.com", "hotmail.fr", "live.com"], - "server_domains": ["outlook.com", "office365.com"], - "folders": { - "inbox": "INBOX", - "trash": "Deleted Items", - "spam": "Junk Email", - "sent": "Sent Items", - "drafts": "Drafts", - }, - }, - EmailProvider.YAHOO: { - "email_domains": ["yahoo.com", "yahoo.co.uk", "yahoo.ca"], - "server_domains": ["yahoo.com"], - "folders": {"inbox": "INBOX", "trash": "Trash", "spam": "Bulk Mail", "sent": "Sent", "drafts": "Draft"}, - }, - EmailProvider.ICLOUD: { - "email_domains": ["icloud.com", "me.com", "mac.com"], - "server_domains": ["icloud.com", "me.com"], - "folders": { - "inbox": "INBOX", - "trash": "Deleted Messages", - "spam": "Junk", - "sent": "Sent Messages", - "drafts": "Drafts", - }, - }, -} - MIN_HEADERS = {"Message-ID", "Message-Id", "From", "To", "Subject", "Date", "CC", "BCC"} EXTRA_HEADERS = {"Delivered-To", "Sender", "References", "In-Reply-To", "Thread-Topic"} SORTING_HEADERS = {"List-Unsubscribe", "List-Id", "Precedence", "Auto-Submitted", "Reply-To", "Return-Path"} class IMAPClient: - """IMAP client for managing emails. - - Args: - imap_server: IMAP server address - email_address: Email address - password: Password - imap_port: IMAP port - provider: Email provider - kwargs: Additional IMAP parameters - - Raises: - ServerConnectionError: If IMAP server is not found - AuthenticationError: If IMAP credentials are invalid - """ + """IMAP client for managing emails.""" def __init__( self, - imap_server: str, email_address: str, password: str, - imap_port: int = 993, provider: str | EmailProvider | None = None, + imap_server: str | None = None, + imap_port: int = 993, **kwargs, ) -> None: - """Initialize the IMAP client.""" + """Initialize the IMAP client. + + Args: + email_address: Email address + password: Password + provider: Email provider + imap_server: IMAP server address + imap_port: IMAP port + kwargs: Additional IMAP parameters + + Raises: + ServerConnectionError: If IMAP server is not found + AuthenticationError: If IMAP credentials are invalid + ValueError: If there are missing information + """ + # Validate email format + try: + validate_email(email_address, check_deliverability=False) + except EmailNotValidError: + raise ValueError("Invalid email address") + + # Provider resolution + if provider is None: + # Try with email + email_domain = email_address.rpartition("@")[-1].lower() + provider = EMAIL_TO_PROVIDER.get(email_domain) + if provider is None: + if imap_server is None: + raise ValueError("Either specify a provider or IMAP server address") + provider = IMAP_TO_PROVIDER.get(imap_server, EmailProvider.CUSTOM) + # Convert string provider to EmailProvider enum if isinstance(provider, str): provider = EmailProvider(provider) - # Auto-detect provider if not provided - if not provider: - provider = resolve_provider(imap_server, email_address) + if provider == EmailProvider.CUSTOM and imap_server is None: + raise ValueError("For custom provider, please specify a IMAP server address") + imap_server = imap_server or PROVIDER_INFO[provider]["imap"]["server"] + imap_port = imap_port or PROVIDER_INFO[provider]["imap"]["port"] # IMAP try: self._imap = IMAP4_SSL(imap_server, imap_port, **kwargs) except gaierror: - raise ServerConnectionError("IMAP server not found") + raise ServerConnectionError(f"Unable to connect to IMAP server {imap_server}:{imap_port}") # Prevent ASCII encoding errors # cf. https://github.com/trac-hacks/tracsql/issues/3 password = password.replace("\xa0", " ") @@ -118,17 +96,6 @@ def __init__( raise AuthenticationError("Invalid IMAP credentials") self.provider = provider - self.config = EMAIL_PROVIDERS.get( - provider, - { - "folders": { - "inbox": "INBOX", - "trash": "Trash", - "spam": "Spam", - "sent": "Sent", - }, - }, - ) def logout(self) -> None: """Logout from the IMAP server.""" @@ -341,7 +308,7 @@ def move_to_trash(self, uid: str) -> None: self._select("INBOX", readonly=False) # First copy to trash folder - self._copy(uid, self.config["folders"]["trash"]) + self._copy(uid, PROVIDER_INFO[self.provider]["folders"]["trash"]) # Then mark as deleted self._flags(uid, "+", "\\Deleted") @@ -381,7 +348,7 @@ def mark_as_spam(self, uid: str) -> None: self._select("INBOX", readonly=False) # First copy to spam folder - self._copy(uid, self.config["folders"]["spam"]) + self._copy(uid, PROVIDER_INFO[self.provider]["folders"]["spam"]) # Then mark as deleted from inbox self._flags(uid, "+", "\\Deleted") diff --git a/relay/providers/smtp.py b/relay/providers/smtp.py index 94404a5..5080769 100644 --- a/relay/providers/smtp.py +++ b/relay/providers/smtp.py @@ -7,41 +7,61 @@ from email.utils import formataddr from smtplib import SMTP_SSL +from email_validator import EmailNotValidError, validate_email + from ..exceptions import AuthenticationError -from .utils import resolve_provider +from ..models.account import EmailProvider +from .utils import EMAIL_TO_PROVIDER, PROVIDER_INFO __all__ = ["SMTPClient"] class SMTPClient: - """SMTP client for sending emails. - - Args: - smtp_server: SMTP server address - email_address: Email address - password: Password - smtp_port: SMTP port - sender_name: Sender name - provider: Email provider - kwargs: Additional SMTP parameters - - Raises: - AuthenticationError: If SMTP credentials are invalid - """ + """SMTP client for sending emails.""" def __init__( self, - smtp_server: str, email_address: str, password: str, + provider: str | EmailProvider | None = None, + smtp_server: str | None = None, smtp_port: int = 465, sender_name: str | None = None, - provider: str | None = None, **kwargs, ) -> None: - """Initialize the SMTP client.""" - if not provider: - provider = resolve_provider(smtp_server, email_address) + """Initialize the SMTP client. + + Args: + email_address: Email address + password: Password + provider: Email provider + smtp_server: SMTP server address + smtp_port: SMTP port + sender_name: Sender name + kwargs: Additional SMTP parameters + + Raises: + AuthenticationError: If SMTP credentials are invalid + ValueError: If there are missing information + """ + # Validate email format + try: + validate_email(email_address, check_deliverability=False) + except EmailNotValidError: + raise ValueError("Invalid email address") + + # Server resolution + if smtp_server is None: + if provider is None: + # Try with email + email_domain = email_address.rpartition("@")[-1].lower() + provider = EMAIL_TO_PROVIDER.get(email_domain) + if provider is None: + raise ValueError("Either specify a provider or SMTP server address") + else: + provider = EmailProvider(provider) + smtp_server = smtp_server or PROVIDER_INFO[provider]["smtp"]["server"] + smtp_port = smtp_port or PROVIDER_INFO[provider]["smtp"]["port"] # SMTP self._smtp = SMTP_SSL(smtp_server, smtp_port, **kwargs) # Prevent ASCII encoding errors diff --git a/relay/providers/utils.py b/relay/providers/utils.py index 3f99edf..1d5de74 100644 --- a/relay/providers/utils.py +++ b/relay/providers/utils.py @@ -6,12 +6,32 @@ from ..models.base import EmailProvider -__all__ = ["resolve_provider"] +__all__ = ["EMAIL_TO_PROVIDER", "IMAP_TO_PROVIDER", "PROVIDER_INFO", "SMTP_TO_PROVIDER"] -EMAIL_PROVIDERS = { + +PROVIDER_DOMAINS = { + EmailProvider.GMAIL: ["gmail.com", "googlemail.com"], + EmailProvider.OUTLOOK: ["outlook.com", "hotmail.com", "hotmail.fr", "live.com"], + EmailProvider.YAHOO: ["yahoo.com", "yahoo.co.uk", "yahoo.ca"], + EmailProvider.ICLOUD: ["icloud.com", "me.com", "mac.com"], +} + +EMAIL_TO_PROVIDER: dict[str, EmailProvider] = { + domain: provider for provider, domains in PROVIDER_DOMAINS.items() for domain in domains +} + + +PROVIDER_INFO = { + # https://support.google.com/a/answer/9003945 EmailProvider.GMAIL: { - "email_domains": ["gmail.com", "googlemail.com"], - "server_domains": ["gmail.com"], + "imap": { + "server": "imap.gmail.com", + "port": 993, + }, + "smtp": { + "server": "smtp.gmail.com", + "port": 465, + }, "folders": { "inbox": "INBOX", "trash": "[Gmail]/Trash", @@ -20,9 +40,16 @@ "drafts": "[Gmail]/Drafts", }, }, + # https://support.microsoft.com/en-us/office/pop-imap-and-smtp-settings-for-outlook-com-d088b986-291d-42b8-9564-9c414e2aa040 EmailProvider.OUTLOOK: { - "email_domains": ["outlook.com", "hotmail.com", "hotmail.fr", "live.com"], - "server_domains": ["outlook.com", "office365.com"], + "imap": { + "server": "outlook.office365.com", + "port": 993, + }, + "smtp": { + "server": "smtp-mail.outlook.com", + "port": 465, + }, "folders": { "inbox": "INBOX", "trash": "Deleted Items", @@ -31,14 +58,28 @@ "drafts": "Drafts", }, }, + # https://help.yahoo.com/kb/SLN4075.html EmailProvider.YAHOO: { - "email_domains": ["yahoo.com", "yahoo.co.uk", "yahoo.ca"], - "server_domains": ["yahoo.com"], + "imap": { + "server": "imap.mail.yahoo.com", + "port": 993, + }, + "smtp": { + "server": "smtp.mail.yahoo.com", + "port": 465, + }, "folders": {"inbox": "INBOX", "trash": "Trash", "spam": "Bulk Mail", "sent": "Sent", "drafts": "Draft"}, }, + # https://support.apple.com/en-us/102525 EmailProvider.ICLOUD: { - "email_domains": ["icloud.com", "me.com", "mac.com"], - "server_domains": ["icloud.com", "me.com"], + "imap": { + "server": "imap.mail.me.com", + "port": 993, + }, + "smtp": { + "server": "smtp.mail.me.com", + "port": 465, + }, "folders": { "inbox": "INBOX", "trash": "Deleted Messages", @@ -47,30 +88,20 @@ "drafts": "Drafts", }, }, + EmailProvider.CUSTOM: { + "folders": { + "inbox": "INBOX", + "trash": "Trash", + "spam": "Junk", + "sent": "Sent", + "drafts": "Drafts", + }, + }, } - -def resolve_provider(server: str, email_address: str) -> EmailProvider: - """Detect email provider from server address or email domain. - - Args: - server: Server address - email_address: Email address - - Returns: - Email provider - """ - # First try to detect by server address - for provider, config in EMAIL_PROVIDERS.items(): - if any(server.endswith(domain) for domain in config["server_domains"]): - return provider - - # Fall back to email domain detection - if "@" in email_address: - email_domain = email_address.rpartition("@")[-1].lower() - for provider, config in EMAIL_PROVIDERS.items(): - if email_domain in config["email_domains"]: - return provider - - # Default to custom if no match found - return EmailProvider.CUSTOM +IMAP_TO_PROVIDER: dict[str, EmailProvider] = { + config["imap"]["server"]: provider for provider, config in PROVIDER_INFO.items() if provider != EmailProvider.CUSTOM +} +SMTP_TO_PROVIDER: dict[str, EmailProvider] = { + config["smtp"]["server"]: provider for provider, config in PROVIDER_INFO.items() if provider != EmailProvider.CUSTOM +} diff --git a/scripts/install.sh b/scripts/install.sh index 450bb1a..000946d 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -31,14 +31,14 @@ check_python() { if ! command -v python3 &> /dev/null; then error "Python 3 is required but not installed" fi - + local python_version=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') local required_version="3.10" - + if ! python3 -c "import sys; exit(0 if sys.version_info >= (3, 10) else 1)" 2>/dev/null; then error "Python ${MIN_PYTHON_VERSION}+ is required, but ${python_version} is installed" fi - + log "Python ${python_version} detected ✓" } @@ -48,7 +48,7 @@ install_uv() { log "UV already installed ✓" return fi - + log "Installing UV package manager..." if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" ]]; then powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" @@ -64,52 +64,52 @@ install_uv() { fi export PATH="$HOME/.cargo/bin:$PATH" fi - + if ! command -v uv &> /dev/null; then error "Failed to install UV" fi - + log "UV installed successfully ✓" } # Install the package install_package() { log "Installing ${PACKAGE_NAME}..." - + if ! uv pip install --system "${PACKAGE_NAME}"; then error "Failed to install ${PACKAGE_NAME}" fi - + log "${PACKAGE_NAME} installed successfully ✓" } # Verify installation verify_installation() { log "Verifying installation..." - + if ! command -v relay &> /dev/null; then error "relay command not found in PATH" fi - + if ! relay --help &> /dev/null; then error "relay command failed to run" fi - + log "Installation verified ✓" } main() { log "Installing Relay CLI..." - + check_python install_uv install_package verify_installation - + log "" log "🎉 Relay CLI installed successfully!" log "Run 'relay account add' to get started" log "Documentation: https://docs.relaycli.com" } -main "$@" \ No newline at end of file +main "$@" diff --git a/tests/providers/test_imap.py b/tests/providers/test_imap.py index 85b1fda..708df33 100644 --- a/tests/providers/test_imap.py +++ b/tests/providers/test_imap.py @@ -7,11 +7,14 @@ from base64 import b64decode from email.message import EmailMessage +from imaplib import IMAP4 +from socket import gaierror import pytest from pytest_mock import MockerFixture from relay.exceptions import AuthenticationError, ServerConnectionError +from relay.models.account import EmailProvider from relay.providers.imap import ( IMAPClient, clear_quoted_body, @@ -50,35 +53,91 @@ def multipart_email() -> EmailMessage: return msg -def test_imap_client_init(mocker: MockerFixture): - """Test successful IMAP client initialization and login.""" +@pytest.mark.parametrize( + ("email_address", "provider", "imap_server", "expected_server", "expected_provider"), + [ + # Gmail provider detection by email + ("user@gmail.com", None, None, "imap.gmail.com", EmailProvider.GMAIL), + ("user@googlemail.com", None, None, "imap.gmail.com", EmailProvider.GMAIL), + # Outlook provider detection by email + ("user@outlook.com", None, None, "outlook.office365.com", EmailProvider.OUTLOOK), + ("user@hotmail.com", None, None, "outlook.office365.com", EmailProvider.OUTLOOK), + # Yahoo provider detection by email + ("user@yahoo.com", None, None, "imap.mail.yahoo.com", EmailProvider.YAHOO), + # iCloud provider detection by email + ("user@icloud.com", None, None, "imap.mail.me.com", EmailProvider.ICLOUD), + # Explicit provider + ("user@example.com", EmailProvider.GMAIL, None, "imap.gmail.com", EmailProvider.GMAIL), + ("user@example.com", "gmail", None, "imap.gmail.com", EmailProvider.GMAIL), + # Custom IMAP server + ("user@example.com", None, "imap.example.com", "imap.example.com", EmailProvider.CUSTOM), + # Provider detection by IMAP server + ("user@example.com", None, "imap.gmail.com", "imap.gmail.com", EmailProvider.GMAIL), + ], +) +def test_imap_client_init_parametrized( + mocker: MockerFixture, + email_address: str, + provider: EmailProvider | str | None, + imap_server: str | None, + expected_server: str, + expected_provider: EmailProvider, +): + """Test IMAP client initialization with various parameter combinations.""" mock_imap_ssl = mocker.patch("relay.providers.imap.IMAP4_SSL") mock_imap_instance = mock_imap_ssl.return_value mock_imap_instance.login.return_value = ("OK", [b"Login successful"]) - client = IMAPClient("imap.example.com", "user@example.com", "password") + client = IMAPClient(email_address, "password", provider=provider, imap_server=imap_server) - mock_imap_ssl.assert_called_once_with("imap.example.com", 993) - mock_imap_instance.login.assert_called_once_with("user@example.com", "password") - assert client is not None + mock_imap_ssl.assert_called_once_with(expected_server, 993) + mock_imap_instance.login.assert_called_once_with(email_address, "password") + assert client.provider == expected_provider -def test_imap_client_login_failure(mocker: MockerFixture): - """Test IMAP client login failure.""" - mock_imap_ssl = mocker.patch("relay.providers.imap.IMAP4_SSL") - mock_imap_instance = mock_imap_ssl.return_value - mock_imap_instance.login.side_effect = AuthenticationError("Invalid IMAP credentials") +@pytest.mark.parametrize( + ("email_address", "provider", "imap_server", "expected_error"), + [ + # Invalid email address + ("invalid-email", None, None, ValueError), + # Missing provider and server + ("user@unknown.com", None, None, ValueError), + # Invalid provider string + ("user@example.com", "invalid_provider", None, ValueError), + ], +) +def test_imap_client_init_errors( + mocker: MockerFixture, + email_address: str, + provider: str | None, + imap_server: str | None, + expected_error: type[Exception], +): + """Test IMAP client initialization error cases.""" + mocker.patch("relay.providers.imap.IMAP4_SSL") - with pytest.raises(AuthenticationError): - IMAPClient("imap.example.com", "user@example.com", "wrong-password") + with pytest.raises(expected_error): + IMAPClient(email_address, "password", provider=provider, imap_server=imap_server) def test_imap_client_connection_failure(mocker: MockerFixture): """Test IMAP client connection failure.""" - mocker.patch("relay.providers.imap.IMAP4_SSL", side_effect=ServerConnectionError("IMAP server not found")) - with pytest.raises(ServerConnectionError): - IMAPClient("imap.example.com", "user@example.com", "password") + mocker.patch("relay.providers.imap.IMAP4_SSL", side_effect=gaierror("Name resolution failed")) + + with pytest.raises(ServerConnectionError, match="Unable to connect to IMAP server"): + IMAPClient("user@gmail.com", "password") + + +def test_imap_client_login_failure(mocker: MockerFixture): + """Test IMAP client login failure.""" + + mock_imap_ssl = mocker.patch("relay.providers.imap.IMAP4_SSL") + mock_imap_instance = mock_imap_ssl.return_value + mock_imap_instance.login.side_effect = IMAP4.error("Authentication failed") + + with pytest.raises(AuthenticationError, match="Invalid IMAP credentials"): + IMAPClient("user@gmail.com", "password") def test_list_email_uids(mocker: MockerFixture): @@ -88,11 +147,11 @@ def test_list_email_uids(mocker: MockerFixture): # Simulate login, select, search, and close mock_imap_instance.login.return_value = ("OK", [b"Login successful"]) - mock_imap_instance.select.return_value = ("OK", [b"123"]) # Total messages + mock_imap_instance.select.return_value = ("OK", [b"123"]) mock_imap_instance.uid.return_value = ("OK", [b"1 2 3 4 5"]) mock_imap_instance.close.return_value = ("OK", [b""]) - client = IMAPClient("imap.example.com", "user@example.com", "password") + client = IMAPClient("user@gmail.com", "password") uids = client.list_email_uids() mock_imap_instance.select.assert_called_once_with("INBOX", readonly=True) @@ -101,6 +160,40 @@ def test_list_email_uids(mocker: MockerFixture): assert mock_imap_instance.close.called +def test_list_email_uids_unseen_only(mocker: MockerFixture): + """Test listing only unseen email UIDs.""" + mock_imap_ssl = mocker.patch("relay.providers.imap.IMAP4_SSL") + mock_imap_instance = mock_imap_ssl.return_value + + mock_imap_instance.login.return_value = ("OK", [b"Login successful"]) + mock_imap_instance.select.return_value = ("OK", [b"123"]) + mock_imap_instance.uid.return_value = ("OK", [b"4 5"]) + mock_imap_instance.close.return_value = ("OK", [b""]) + + client = IMAPClient("user@gmail.com", "password") + uids = client.list_email_uids(unseen_only=True) + + mock_imap_instance.uid.assert_called_once_with("SEARCH", None, "UNSEEN") + assert uids == ["4", "5"] + + +def test_mark_as_read(mocker: MockerFixture): + """Test marking email as read.""" + mock_imap_ssl = mocker.patch("relay.providers.imap.IMAP4_SSL") + mock_imap_instance = mock_imap_ssl.return_value + + mock_imap_instance.login.return_value = ("OK", [b"Login successful"]) + mock_imap_instance.select.return_value = ("OK", [b"123"]) + mock_imap_instance.uid.return_value = ("OK", [b"STORE completed"]) + mock_imap_instance.close.return_value = ("OK", [b""]) + + client = IMAPClient("user@gmail.com", "password") + client.mark_as_read("123") + + mock_imap_instance.select.assert_called_once_with("INBOX", readonly=False) + mock_imap_instance.uid.assert_called_once_with("STORE", "123", "+FLAGS", "\\Seen") + + def test_parse_email_parts_simple(simple_email: EmailMessage): """Test parse_email_parts with a simple email.""" parts = parse_email_parts(simple_email) @@ -115,29 +208,35 @@ def test_parse_email_parts_multipart(multipart_email: EmailMessage): parts = parse_email_parts(multipart_email) assert parts["text_plain"].strip() == "This is the plain text part." assert parts["text_html"].strip() == "

This is the HTML part.

" - # The expected output from html2text might need adjustment based on its version assert "This is the **HTML** part." in parts["parsed_html"] assert len(parts["attachments"]) == 1 attachment = parts["attachments"][0] assert attachment["filename"] == "test.txt" assert attachment["content_type"] == "application/octet-stream" - # Content is base64 encoded assert b64decode(attachment["content"]) == b"attachment content" -def test_resolve_thread_id(): - """Test resolve_thread_id logic.""" - # With References - msg_with_refs = {"References": " ", "Message-ID": ""} - assert resolve_thread_id(msg_with_refs) == "" - - # With In-Reply-To but no References - msg_with_in_reply_to = {"In-Reply-To": "", "Message-ID": ""} - assert resolve_thread_id(msg_with_in_reply_to) is None - - # With only Message-ID - msg_with_id = {"Message-ID": ""} - assert resolve_thread_id(msg_with_id) == "" +@pytest.mark.parametrize( + ("email_headers", "expected_thread_id"), + [ + # With References + ( + {"References": " ", "Message-ID": ""}, + "", + ), + # With In-Reply-To but no References + ({"In-Reply-To": "", "Message-ID": ""}, None), + # With only Message-ID + ({"Message-ID": ""}, ""), + # With Message-Id (alternative casing) + ({"Message-Id": ""}, ""), + # Empty References + ({"References": "", "Message-ID": ""}, ""), + ], +) +def test_resolve_thread_id_parametrized(email_headers: dict[str, str], expected_thread_id: str | None): + """Test resolve_thread_id with various header combinations.""" + assert resolve_thread_id(email_headers) == expected_thread_id def test_parse_html_body(): @@ -147,19 +246,37 @@ def test_parse_html_body(): assert text.strip() == "# Title\n\nSome text with a link." -def test_clear_quoted_body(): - """Test clear_quoted_body.""" - original_text = ( - "This is my reply.\n\n" - "On Wed, Nov 15, 2023 at 10:00 AM, Sender wrote:\n" - "> This is the original message.\n" - ">\n" - "> > Quoted text inside." - ) - cleaned_text = clear_quoted_body(original_text) - assert "This is my reply." in cleaned_text - assert "On Wed, Nov 15, 2023" not in cleaned_text - assert "This is the original message." not in cleaned_text - - text_with_no_quote = "This is just a regular email body." - assert clear_quoted_body(text_with_no_quote) == text_with_no_quote +@pytest.mark.parametrize( + ("input_text", "expected_contains", "expected_not_contains"), + [ + # Standard quoted reply + ( + "This is my reply.\n\nOn Wed, Nov 15, 2023 at 10:00 AM, Sender wrote:\n> Original message", + ["This is my reply."], + ["On Wed, Nov 15, 2023", "Original message"], + ), + # No quoted content + ( + "This is just a regular email body.", + ["This is just a regular email body."], + [], + ), + # Multiple email addresses + ( + "Reply text.\n\nFrom: \nTo: \n> Quoted text", + ["Reply text."], + ["Quoted text"], + ), + ], +) +def test_clear_quoted_body_parametrized( + input_text: str, expected_contains: list[str], expected_not_contains: list[str] +): + """Test clear_quoted_body with various input patterns.""" + cleaned_text = clear_quoted_body(input_text) + + for text in expected_contains: + assert text in cleaned_text + + for text in expected_not_contains: + assert text not in cleaned_text diff --git a/tests/providers/test_smtp.py b/tests/providers/test_smtp.py new file mode 100644 index 0000000..f1e1ffc --- /dev/null +++ b/tests/providers/test_smtp.py @@ -0,0 +1,250 @@ +# Copyright (C) 2025, Relay. + +# This program is licensed under the Apache License 2.0. +# See LICENSE or go to for full license details. + +"""Tests for SMTP provider.""" + +import pytest +from pytest_mock import MockerFixture + +from relay.exceptions import AuthenticationError +from relay.models.account import EmailProvider +from relay.providers.smtp import SMTPClient + + +@pytest.mark.parametrize( + ("email_address", "provider", "smtp_server", "expected_server", "expected_provider"), + [ + # Gmail provider detection by email + ("user@gmail.com", None, None, "smtp.gmail.com", EmailProvider.GMAIL), + ("user@googlemail.com", None, None, "smtp.gmail.com", EmailProvider.GMAIL), + # Outlook provider detection by email + ("user@outlook.com", None, None, "smtp-mail.outlook.com", EmailProvider.OUTLOOK), + ("user@hotmail.com", None, None, "smtp-mail.outlook.com", EmailProvider.OUTLOOK), + # Yahoo provider detection by email + ("user@yahoo.com", None, None, "smtp.mail.yahoo.com", EmailProvider.YAHOO), + # iCloud provider detection by email + ("user@icloud.com", None, None, "smtp.mail.me.com", EmailProvider.ICLOUD), + # Explicit provider + ("user@example.com", EmailProvider.GMAIL, None, "smtp.gmail.com", EmailProvider.GMAIL), + ("user@example.com", "gmail", None, "smtp.gmail.com", EmailProvider.GMAIL), + # Custom SMTP server + ("user@example.com", None, "smtp.example.com", "smtp.example.com", None), + ], +) +def test_smtp_client_init_parametrized( + mocker: MockerFixture, + email_address: str, + provider: EmailProvider | str | None, + smtp_server: str | None, + expected_server: str, + expected_provider: EmailProvider | None, +): + """Test SMTP client initialization with various parameter combinations.""" + mock_smtp_ssl = mocker.patch("relay.providers.smtp.SMTP_SSL") + mock_smtp_instance = mock_smtp_ssl.return_value + mock_smtp_instance.login.return_value = (235, b"Authentication successful") + mock_smtp_instance.user = email_address + + client = SMTPClient(email_address, "password", provider=provider, smtp_server=smtp_server) + + mock_smtp_ssl.assert_called_once_with(expected_server, 465) + mock_smtp_instance.login.assert_called_once_with(email_address, "password") + assert client is not None + + +@pytest.mark.parametrize( + ("email_address", "provider", "smtp_server", "expected_error"), + [ + # Invalid email address + ("invalid-email", None, None, ValueError), + # Missing provider and server + ("user@unknown.com", None, None, ValueError), + # Invalid provider string + ("user@example.com", "invalid_provider", None, ValueError), + ], +) +def test_smtp_client_init_errors( + mocker: MockerFixture, + email_address: str, + provider: str | None, + smtp_server: str | None, + expected_error: type[Exception], +): + """Test SMTP client initialization error cases.""" + mocker.patch("relay.providers.smtp.SMTP_SSL") + + with pytest.raises(expected_error): + SMTPClient(email_address, "password", provider=provider, smtp_server=smtp_server) + + +def test_smtp_client_login_failure(mocker: MockerFixture): + """Test SMTP client login failure.""" + mock_smtp_ssl = mocker.patch("relay.providers.smtp.SMTP_SSL") + mock_smtp_instance = mock_smtp_ssl.return_value + mock_smtp_instance.login.return_value = (535, b"Authentication failed") + + with pytest.raises(AuthenticationError, match="Invalid SMTP credentials"): + SMTPClient("user@gmail.com", "password") + + +def test_smtp_client_with_sender_name(mocker: MockerFixture): + """Test SMTP client initialization with sender name.""" + mock_smtp_ssl = mocker.patch("relay.providers.smtp.SMTP_SSL") + mock_smtp_instance = mock_smtp_ssl.return_value + mock_smtp_instance.login.return_value = (235, b"Authentication successful") + mock_smtp_instance.user = "user@gmail.com" + + client = SMTPClient("user@gmail.com", "password", sender_name="John Doe") + + assert client.sender_name == "John Doe" + + +def test_send_email_basic(mocker: MockerFixture): + """Test basic email sending.""" + mock_smtp_ssl = mocker.patch("relay.providers.smtp.SMTP_SSL") + mock_smtp_instance = mock_smtp_ssl.return_value + mock_smtp_instance.login.return_value = (235, b"Authentication successful") + mock_smtp_instance.user = "sender@gmail.com" + mock_smtp_instance.sendmail.return_value = {} + + client = SMTPClient("sender@gmail.com", "password") + client.send_email("Hello, World!", ["recipient@example.com"], Subject="Test Email") + + # Verify sendmail was called with correct parameters + mock_smtp_instance.sendmail.assert_called_once() + call_args = mock_smtp_instance.sendmail.call_args[0] + assert call_args[0] == "sender@gmail.com" # from_addr + assert call_args[1] == ["recipient@example.com"] # to_addrs + + # Verify the message content + message_content = call_args[2] + assert "Hello, World!" in message_content + assert "Subject: Test Email" in message_content + assert "From: sender@gmail.com" in message_content + + +def test_send_email_with_sender_name(mocker: MockerFixture): + """Test email sending with sender name.""" + mock_smtp_ssl = mocker.patch("relay.providers.smtp.SMTP_SSL") + mock_smtp_instance = mock_smtp_ssl.return_value + mock_smtp_instance.login.return_value = (235, b"Authentication successful") + mock_smtp_instance.user = "sender@gmail.com" + mock_smtp_instance.sendmail.return_value = {} + + client = SMTPClient("sender@gmail.com", "password", sender_name="John Doe") + client.send_email("Hello, World!", ["recipient@example.com"], Subject="Test Email") + + # Verify the message includes formatted sender name + call_args = mock_smtp_instance.sendmail.call_args[0] + message_content = call_args[2] + assert "From: John Doe " in message_content + + +def test_send_email_html(mocker: MockerFixture): + """Test HTML email sending.""" + mock_smtp_ssl = mocker.patch("relay.providers.smtp.SMTP_SSL") + mock_smtp_instance = mock_smtp_ssl.return_value + mock_smtp_instance.login.return_value = (235, b"Authentication successful") + mock_smtp_instance.user = "sender@gmail.com" + mock_smtp_instance.sendmail.return_value = {} + + client = SMTPClient("sender@gmail.com", "password") + html_content = "

Hello, World!

" + client.send_email(html_content, ["recipient@example.com"], text_subtype="html", Subject="HTML Email") + + # Verify the message content type + call_args = mock_smtp_instance.sendmail.call_args[0] + message_content = call_args[2] + assert "Content-Type: text/html" in message_content + assert html_content in message_content + + +def test_send_email_multiple_recipients(mocker: MockerFixture): + """Test email sending to multiple recipients.""" + mock_smtp_ssl = mocker.patch("relay.providers.smtp.SMTP_SSL") + mock_smtp_instance = mock_smtp_ssl.return_value + mock_smtp_instance.login.return_value = (235, b"Authentication successful") + mock_smtp_instance.user = "sender@gmail.com" + mock_smtp_instance.sendmail.return_value = {} + + client = SMTPClient("sender@gmail.com", "password") + recipients = ["recipient1@example.com", "recipient2@example.com", "recipient3@example.com"] + client.send_email("Hello, World!", recipients, Subject="Test Email") + + # Verify sendmail was called with all recipients + call_args = mock_smtp_instance.sendmail.call_args[0] + assert call_args[1] == recipients + + +def test_send_email_custom_headers(mocker: MockerFixture): + """Test email sending with custom headers.""" + mock_smtp_ssl = mocker.patch("relay.providers.smtp.SMTP_SSL") + mock_smtp_instance = mock_smtp_ssl.return_value + mock_smtp_instance.login.return_value = (235, b"Authentication successful") + mock_smtp_instance.user = "sender@gmail.com" + mock_smtp_instance.sendmail.return_value = {} + + client = SMTPClient("sender@gmail.com", "password") + client.send_email( + "Hello, World!", + ["recipient@example.com"], + Subject="Test Email", + **{"Reply-To": "noreply@example.com", "X-Priority": "1"}, + ) + + # Verify custom headers are included + call_args = mock_smtp_instance.sendmail.call_args[0] + message_content = call_args[2] + assert "Reply-To: noreply@example.com" in message_content + assert "X-Priority: 1" in message_content + + +def test_send_email_custom_from_header(mocker: MockerFixture): + """Test email sending with custom From header.""" + mock_smtp_ssl = mocker.patch("relay.providers.smtp.SMTP_SSL") + mock_smtp_instance = mock_smtp_ssl.return_value + mock_smtp_instance.login.return_value = (235, b"Authentication successful") + mock_smtp_instance.user = "sender@gmail.com" + mock_smtp_instance.sendmail.return_value = {} + + client = SMTPClient("sender@gmail.com", "password") + client.send_email( + "Hello, World!", ["recipient@example.com"], Subject="Test Email", From="Custom Sender " + ) + + # Verify custom From header is used + call_args = mock_smtp_instance.sendmail.call_args[0] + message_content = call_args[2] + assert "From: Custom Sender " in message_content + + +def test_quit_smtp_client(mocker: MockerFixture): + """Test SMTP client quit method.""" + mock_smtp_ssl = mocker.patch("relay.providers.smtp.SMTP_SSL") + mock_smtp_instance = mock_smtp_ssl.return_value + mock_smtp_instance.login.return_value = (235, b"Authentication successful") + mock_smtp_instance.user = "sender@gmail.com" + + client = SMTPClient("sender@gmail.com", "password") + client.quit() + + mock_smtp_instance.quit.assert_called_once() + + +def test_password_ascii_encoding_fix(mocker: MockerFixture): + """Test password ASCII encoding fix.""" + mock_smtp_ssl = mocker.patch("relay.providers.smtp.SMTP_SSL") + mock_smtp_instance = mock_smtp_ssl.return_value + mock_smtp_instance.login.return_value = (235, b"Authentication successful") + mock_smtp_instance.user = "sender@gmail.com" + + # Password with non-breaking space character + password_with_nbsp = "password\xa0with\xa0nbsp" # noqa: S105 + expected_cleaned_password = "password with nbsp" # noqa: S105 + + SMTPClient("sender@gmail.com", password_with_nbsp) + + # Verify the password was cleaned before being passed to login + mock_smtp_instance.login.assert_called_once_with("sender@gmail.com", expected_cleaned_password) diff --git a/uv.lock b/uv.lock index e12ee0c..38ff7e5 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 2 -requires-python = ">=3.10, <4.0" +requires-python = ">=3.11, <4.0" [[package]] name = "annotated-types" @@ -33,18 +33,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191, upload-time = "2024-09-04T20:43:30.027Z" }, - { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592, upload-time = "2024-09-04T20:43:32.108Z" }, - { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024, upload-time = "2024-09-04T20:43:34.186Z" }, - { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188, upload-time = "2024-09-04T20:43:36.286Z" }, - { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571, upload-time = "2024-09-04T20:43:38.586Z" }, - { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687, upload-time = "2024-09-04T20:43:40.084Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211, upload-time = "2024-09-04T20:43:41.526Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325, upload-time = "2024-09-04T20:43:43.117Z" }, - { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784, upload-time = "2024-09-04T20:43:45.256Z" }, - { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564, upload-time = "2024-09-04T20:43:46.779Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804, upload-time = "2024-09-04T20:43:48.186Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299, upload-time = "2024-09-04T20:43:49.812Z" }, { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, @@ -117,16 +105,6 @@ version = "7.9.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/04/b7/c0465ca253df10a9e8dae0692a4ae6e9726d245390aaef92360e1d6d3832/coverage-7.9.2.tar.gz", hash = "sha256:997024fa51e3290264ffd7492ec97d0690293ccd2b45a6cd7d82d945a4a80c8b", size = 813556, upload-time = "2025-07-03T10:54:15.101Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a1/0d/5c2114fd776c207bd55068ae8dc1bef63ecd1b767b3389984a8e58f2b926/coverage-7.9.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:66283a192a14a3854b2e7f3418d7db05cdf411012ab7ff5db98ff3b181e1f912", size = 212039, upload-time = "2025-07-03T10:52:38.955Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ad/dc51f40492dc2d5fcd31bb44577bc0cc8920757d6bc5d3e4293146524ef9/coverage-7.9.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4e01d138540ef34fcf35c1aa24d06c3de2a4cffa349e29a10056544f35cca15f", size = 212428, upload-time = "2025-07-03T10:52:41.36Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a3/55cb3ff1b36f00df04439c3993d8529193cdf165a2467bf1402539070f16/coverage-7.9.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f22627c1fe2745ee98d3ab87679ca73a97e75ca75eb5faee48660d060875465f", size = 241534, upload-time = "2025-07-03T10:52:42.956Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c9/a8410b91b6be4f6e9c2e9f0dce93749b6b40b751d7065b4410bf89cb654b/coverage-7.9.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b1c2d8363247b46bd51f393f86c94096e64a1cf6906803fa8d5a9d03784bdbf", size = 239408, upload-time = "2025-07-03T10:52:44.199Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c4/6f3e56d467c612b9070ae71d5d3b114c0b899b5788e1ca3c93068ccb7018/coverage-7.9.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c10c882b114faf82dbd33e876d0cbd5e1d1ebc0d2a74ceef642c6152f3f4d547", size = 240552, upload-time = "2025-07-03T10:52:45.477Z" }, - { url = "https://files.pythonhosted.org/packages/fd/20/04eda789d15af1ce79bce5cc5fd64057c3a0ac08fd0576377a3096c24663/coverage-7.9.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:de3c0378bdf7066c3988d66cd5232d161e933b87103b014ab1b0b4676098fa45", size = 240464, upload-time = "2025-07-03T10:52:46.809Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5a/217b32c94cc1a0b90f253514815332d08ec0812194a1ce9cca97dda1cd20/coverage-7.9.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1e2f097eae0e5991e7623958a24ced3282676c93c013dde41399ff63e230fcf2", size = 239134, upload-time = "2025-07-03T10:52:48.149Z" }, - { url = "https://files.pythonhosted.org/packages/34/73/1d019c48f413465eb5d3b6898b6279e87141c80049f7dbf73fd020138549/coverage-7.9.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28dc1f67e83a14e7079b6cea4d314bc8b24d1aed42d3582ff89c0295f09b181e", size = 239405, upload-time = "2025-07-03T10:52:49.687Z" }, - { url = "https://files.pythonhosted.org/packages/49/6c/a2beca7aa2595dad0c0d3f350382c381c92400efe5261e2631f734a0e3fe/coverage-7.9.2-cp310-cp310-win32.whl", hash = "sha256:bf7d773da6af9e10dbddacbf4e5cab13d06d0ed93561d44dae0188a42c65be7e", size = 214519, upload-time = "2025-07-03T10:52:51.036Z" }, - { url = "https://files.pythonhosted.org/packages/fc/c8/91e5e4a21f9a51e2c7cdd86e587ae01a4fcff06fc3fa8cde4d6f7cf68df4/coverage-7.9.2-cp310-cp310-win_amd64.whl", hash = "sha256:0c0378ba787681ab1897f7c89b415bd56b0b2d9a47e5a3d8dc0ea55aac118d6c", size = 215400, upload-time = "2025-07-03T10:52:52.313Z" }, { url = "https://files.pythonhosted.org/packages/39/40/916786453bcfafa4c788abee4ccd6f592b5b5eca0cd61a32a4e5a7ef6e02/coverage-7.9.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a7a56a2964a9687b6aba5b5ced6971af308ef6f79a91043c05dd4ee3ebc3e9ba", size = 212152, upload-time = "2025-07-03T10:52:53.562Z" }, { url = "https://files.pythonhosted.org/packages/9f/66/cc13bae303284b546a030762957322bbbff1ee6b6cb8dc70a40f8a78512f/coverage-7.9.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:123d589f32c11d9be7fe2e66d823a236fe759b0096f5db3fb1b75b2fa414a4fa", size = 212540, upload-time = "2025-07-03T10:52:55.196Z" }, { url = "https://files.pythonhosted.org/packages/0f/3c/d56a764b2e5a3d43257c36af4a62c379df44636817bb5f89265de4bf8bd7/coverage-7.9.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:333b2e0ca576a7dbd66e85ab402e35c03b0b22f525eed82681c4b866e2e2653a", size = 245097, upload-time = "2025-07-03T10:52:56.509Z" }, @@ -213,12 +191,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8b/34394337abe4566848a2bd49b26bcd4b07fd466afd3e8cce4cb79a390869/cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd", size = 3575762, upload-time = "2025-07-02T13:05:53.166Z" }, - { url = "https://files.pythonhosted.org/packages/8b/5d/a19441c1e89afb0f173ac13178606ca6fab0d3bd3ebc29e9ed1318b507fc/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097", size = 4140906, upload-time = "2025-07-02T13:05:55.914Z" }, - { url = "https://files.pythonhosted.org/packages/4b/db/daceb259982a3c2da4e619f45b5bfdec0e922a23de213b2636e78ef0919b/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e", size = 4374411, upload-time = "2025-07-02T13:05:57.814Z" }, - { url = "https://files.pythonhosted.org/packages/6a/35/5d06ad06402fc522c8bf7eab73422d05e789b4e38fe3206a85e3d6966c11/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30", size = 4140942, upload-time = "2025-07-02T13:06:00.137Z" }, - { url = "https://files.pythonhosted.org/packages/65/79/020a5413347e44c382ef1f7f7e7a66817cd6273e3e6b5a72d18177b08b2f/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e", size = 4374079, upload-time = "2025-07-02T13:06:02.043Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c5/c0e07d84a9a2a8a0ed4f865e58f37c71af3eab7d5e094ff1b21f3f3af3bc/cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d", size = 3321362, upload-time = "2025-07-02T13:06:04.463Z" }, { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878, upload-time = "2025-07-02T13:06:06.339Z" }, { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" }, { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" }, @@ -258,18 +230,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, -] - [[package]] name = "filelock" version = "3.18.0" @@ -433,19 +393,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/92/b31726561b5dae176c2d2c2dc43a9c5bfba5d32f96f8b4c0a600dd492447/pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8", size = 2028817, upload-time = "2025-04-23T18:30:43.919Z" }, - { url = "https://files.pythonhosted.org/packages/a3/44/3f0b95fafdaca04a483c4e685fe437c6891001bf3ce8b2fded82b9ea3aa1/pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d", size = 1861357, upload-time = "2025-04-23T18:30:46.372Z" }, - { url = "https://files.pythonhosted.org/packages/30/97/e8f13b55766234caae05372826e8e4b3b96e7b248be3157f53237682e43c/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d", size = 1898011, upload-time = "2025-04-23T18:30:47.591Z" }, - { url = "https://files.pythonhosted.org/packages/9b/a3/99c48cf7bafc991cc3ee66fd544c0aae8dc907b752f1dad2d79b1b5a471f/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572", size = 1982730, upload-time = "2025-04-23T18:30:49.328Z" }, - { url = "https://files.pythonhosted.org/packages/de/8e/a5b882ec4307010a840fb8b58bd9bf65d1840c92eae7534c7441709bf54b/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02", size = 2136178, upload-time = "2025-04-23T18:30:50.907Z" }, - { url = "https://files.pythonhosted.org/packages/e4/bb/71e35fc3ed05af6834e890edb75968e2802fe98778971ab5cba20a162315/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b", size = 2736462, upload-time = "2025-04-23T18:30:52.083Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/c8f7593e6bc7066289bbc366f2235701dcbebcd1ff0ef8e64f6f239fb47d/pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2", size = 2005652, upload-time = "2025-04-23T18:30:53.389Z" }, - { url = "https://files.pythonhosted.org/packages/d2/7a/996d8bd75f3eda405e3dd219ff5ff0a283cd8e34add39d8ef9157e722867/pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a", size = 2113306, upload-time = "2025-04-23T18:30:54.661Z" }, - { url = "https://files.pythonhosted.org/packages/ff/84/daf2a6fb2db40ffda6578a7e8c5a6e9c8affb251a05c233ae37098118788/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac", size = 2073720, upload-time = "2025-04-23T18:30:56.11Z" }, - { url = "https://files.pythonhosted.org/packages/77/fb/2258da019f4825128445ae79456a5499c032b55849dbd5bed78c95ccf163/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a", size = 2244915, upload-time = "2025-04-23T18:30:57.501Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/925ff73756031289468326e355b6fa8316960d0d65f8b5d6b3a3e7866de7/pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b", size = 2241884, upload-time = "2025-04-23T18:30:58.867Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b0/249ee6d2646f1cdadcb813805fe76265745c4010cf20a8eba7b0e639d9b2/pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22", size = 1910496, upload-time = "2025-04-23T18:31:00.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/ff/172ba8f12a42d4b552917aa65d1f2328990d3ccfc01d5b7c943ec084299f/pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640", size = 1955019, upload-time = "2025-04-23T18:31:01.335Z" }, { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, @@ -491,15 +438,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, - { url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982, upload-time = "2025-04-23T18:32:53.14Z" }, - { url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412, upload-time = "2025-04-23T18:32:55.52Z" }, - { url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749, upload-time = "2025-04-23T18:32:57.546Z" }, - { url = "https://files.pythonhosted.org/packages/12/73/8cd57e20afba760b21b742106f9dbdfa6697f1570b189c7457a1af4cd8a0/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e", size = 2067527, upload-time = "2025-04-23T18:32:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d5/0bb5d988cc019b3cba4a78f2d4b3854427fc47ee8ec8e9eaabf787da239c/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c", size = 2108225, upload-time = "2025-04-23T18:33:04.51Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c5/00c02d1571913d496aabf146106ad8239dc132485ee22efe08085084ff7c/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec", size = 2069490, upload-time = "2025-04-23T18:33:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/22/a8/dccc38768274d3ed3a59b5d06f59ccb845778687652daa71df0cab4040d7/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052", size = 2237525, upload-time = "2025-04-23T18:33:08.44Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e7/4f98c0b125dda7cf7ccd14ba936218397b44f50a56dd8c16a3091df116c3/pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c", size = 2238446, upload-time = "2025-04-23T18:33:10.313Z" }, - { url = "https://files.pythonhosted.org/packages/ce/91/2ec36480fdb0b783cd9ef6795753c1dea13882f2e68e73bce76ae8c21e6a/pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808", size = 2066678, upload-time = "2025-04-23T18:33:12.224Z" }, { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, @@ -526,12 +464,10 @@ version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } wheels = [ @@ -583,15 +519,6 @@ version = "6.0.2" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758, upload-time = "2024-08-06T20:31:42.173Z" }, - { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463, upload-time = "2024-08-06T20:31:44.263Z" }, - { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280, upload-time = "2024-08-06T20:31:50.199Z" }, - { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239, upload-time = "2024-08-06T20:31:52.292Z" }, - { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802, upload-time = "2024-08-06T20:31:53.836Z" }, - { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527, upload-time = "2024-08-06T20:31:55.565Z" }, - { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052, upload-time = "2024-08-06T20:31:56.914Z" }, - { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774, upload-time = "2024-08-06T20:31:58.304Z" }, { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, @@ -688,7 +615,6 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" } wheels = [