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 = [