diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
new file mode 100644
index 0000000..2f1a15d
--- /dev/null
+++ b/.github/workflows/build.yaml
@@ -0,0 +1,31 @@
+name: Build
+
+on:
+ # push:
+ workflow_call:
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v3
+
+ - name: Build distributions
+ run: uv build
+
+ - name: Upload sdist artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: sdist
+ path: ./dist/*.tar.gz
+
+ - name: Upload wheels artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: wheels
+ path: ./dist/*.whl
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
new file mode 100644
index 0000000..ca6ce7b
--- /dev/null
+++ b/.github/workflows/ci.yaml
@@ -0,0 +1,31 @@
+name: CI
+
+on:
+ push:
+ workflow_dispatch:
+
+concurrency:
+ group: ci-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ name: Lint
+ uses: ./.github/workflows/lint.yaml
+
+ build:
+ name: Build
+ uses: ./.github/workflows/build.yaml
+
+ test:
+ name: Test
+ uses: ./.github/workflows/test.yaml
+
+ release:
+ name: Release
+ if: startsWith(github.ref, 'refs/tags/v')
+ needs: [lint, build, test]
+ permissions:
+ contents: write
+ id-token: write
+ uses: ./.github/workflows/release.yaml
diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
new file mode 100644
index 0000000..4f19eb6
--- /dev/null
+++ b/.github/workflows/lint.yaml
@@ -0,0 +1,32 @@
+name: Lint
+
+on:
+ # push:
+ # pull_request:
+ workflow_call:
+ workflow_dispatch:
+
+concurrency: lint-${{ github.sha }}
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ env:
+ PYTHON_VERSION: "3.9"
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python ${{ env.PYTHON_VERSION }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v3
+
+ - name: Install the project
+ run: uv sync --locked --all-extras --dev
+
+ - name: Run pre-commit hooks
+ uses: pre-commit/action@v3.0.1
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..8dae8cd
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,58 @@
+name: Release
+
+on:
+ # push:
+ # tags:
+ # - v*
+ workflow_call:
+
+concurrency: release-${{ github.ref }}
+
+jobs:
+ release:
+ if: startsWith(github.ref, 'refs/tags/v')
+ name: Create release
+ runs-on: ubuntu-latest
+ environment: Release
+ permissions:
+ contents: write
+ steps:
+ - name: Download sdist artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: sdist
+ path: ./dist
+
+ - name: Download wheels artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: wheels
+ path: ./dist
+
+ - name: Create release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: ./dist/*
+
+ publish:
+ if: startsWith(github.ref, 'refs/tags/v')
+ name: Publish package to PyPI
+ runs-on: ubuntu-latest
+ environment: Release
+ permissions:
+ id-token: write
+ steps:
+ - name: Download sdist artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: sdist
+ path: ./dist
+
+ - name: Download wheels artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: wheels
+ path: ./dist
+
+ - name: Upload distributions to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100644
index 0000000..6c244b8
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,32 @@
+name: Test
+
+on:
+ # push:
+ # pull_request:
+ workflow_call:
+ workflow_dispatch:
+
+concurrency: test-${{ github.sha }}
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ env:
+ PYTHON_VERSION: "3.9"
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python ${{ env.PYTHON_VERSION }}
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v3
+
+ - name: Install the project
+ run: uv sync --locked --all-extras --dev
+
+ - name: Run tests
+ run: uv run pytest tests
diff --git a/.gitignore b/.gitignore
index e7d06ee..8a52af8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+.vscode/
+
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
@@ -14,8 +16,6 @@ takeout/.DS_Store
# Test code
test.py
-*.lock
-*.toml
# Distribution / packaging
.Python
@@ -238,4 +238,4 @@ fabric.properties
.idea/httpRequests
# Android studio 3.1+ serialized cache file
-.idea/caches/build_file_checksums.ser
\ No newline at end of file
+.idea/caches/build_file_checksums.ser
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..9b8fb29
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,22 @@
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v5.0.0
+ hooks:
+ - id: check-added-large-files
+ - id: check-case-conflict
+ - id: check-toml
+ - id: check-yaml
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+ args: [--markdown-linebreak-ext=md]
+
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.6.9
+ hooks:
+ - id: ruff
+ - id: ruff-format
+
+ - repo: https://github.com/RobertCraigie/pyright-python
+ rev: v1.1.384
+ hooks:
+ - id: pyright
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..2905f88
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 Contiguity
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 42a986d..1e8cfdb 100644
--- a/README.md
+++ b/README.md
@@ -7,18 +7,22 @@
Contiguity's official Python SDK.
## Installation 🏗 & Setup 🛠
+
You can install the SDK using pip
+
```shell
-$ pip install contiguity
+pip install contiguity
```
Then, import & initialize it like this:
+
```js
import contiguity
client = contiguity.login("your_token_here")
```
You can also initialize it with the optional 'debug' flag:
+
```js
client = contiguity.login("your_token_here", True)
```
@@ -43,6 +47,7 @@ client.send.email(email_object)
```
To send an email with a text body, it's very similar. Just switch "html" to "text".
+
```python
email_object = {
"to": "example@example.com",
@@ -54,8 +59,9 @@ email_object = {
client.send.email(email_object)
```
-### Optional fields:
-- `replyTo` allows you set a reply-to email address.
+### Optional fields
+
+- `replyTo` allows you set a reply-to email address.
- `cc` allows you to CC an email address
You can also fetch a local email template using `client.template.local(file)`:
@@ -95,13 +101,15 @@ client.send.text(text_object)
Contiguity aims to make communications extremely simple and elegant. In doing so, we're providing an OTP API to send one time codes - for free (no additional charge, the text message is still billed / added to quota)
To send your first OTP, first create one:
+
```python
-otp_id = client.otp.send({
- 'to': "+15555555555",
- 'language': "en",
- 'name': "Contiguity"
+otp_id = client.otp.send({
+ 'to': "+15555555555",
+ 'language': "en",
+ 'name': "Contiguity"
})
```
+
Contiguity supports 33 languages for OTPs, including `English (en)`, `Afrikaans (af)`, `Arabic (ar)`, `Catalan (ca)`, `Chinese / Mandarin (zh)`, `Cantonese (zh-hk)`, `Croatian (hr)`, `Czech (cs)`, `Danish (da)`, `Dutch (nl)`, `Finnish (fi)`, `French (fr)`, `German (de)`, `Greek (el)`, `Hebrew (he)`, `Hindi (hi)`, `Hungarian (hu)`, `Indonesian (id)`, `Italian (it)`, `Japanese (ja)`, `Korean (ko)`, `Malay (ms)`, `Norwegian (nb)`, `Polish (pl)`, `Portuguese - Brazil (pt-br)`, `Portuguese (pt)`, `Romanian (ro)`, `Russian (ru)`, `Spanish (es)`, `Swedish (sv)`, `Tagalog (tl)`, `Thai (th)`, `Turkish (tr)`, and `Vietnamese (vi)`
_The `name` parameter is optional, it customizes the message to say "Your \[name] code is ..."_
@@ -114,31 +122,39 @@ verify = client.otp.verify({
'otp': input # the 6 digits your user inputted.
})
```
+
It will return a boolean (true/false). The OTP expires 15 minutes after sending it.
Want to resend an OTP? Use `client.otp.resend()`:
+
```py
resend = client.otp.resend({
'otp_id': otp_id # you received this when you called client.otp.send(),
})
```
+
OTP expiry does not renew.
## Verify formatting
+
Contiguity provides two functions that verify phone number and email formatting, which are:
```py
client.verify.number("number")
```
+
and
+
```py
client.verify.email("example@example.com")
```
+
They return a boolean (true/false)
**Note**: _This occurs locally, and is not part of Contiguity's online verification service._
## Email analytics
+
If you sent an HTML email, and chose Contiguity to track it, you can fetch an email's status (delivered/read) using:
```py
@@ -146,6 +162,7 @@ client.email_analytics.retrieve("email_id")
```
## Quota
+
If you'd like to retrieve your quota, whether you're on our free tier or Unlimited, you can fetch it using:
```py
@@ -155,10 +172,11 @@ client.quota.retrieve()
You'll receive an object similar to the `crumbs` the API provides on completion of every request.
## Roadmap 🚦
+
- Contiguity Identity will be supported
- Adding support for calls
- Adding support for webhooks
- Adding support for online templates
- and way more.
-### See complete examples in [examples/](https://github.com/use-contiguity/python/tree/main/examples)
\ No newline at end of file
+### See complete examples in [examples/](https://github.com/use-contiguity/python/tree/main/examples)
diff --git a/contiguity/__init__.py b/contiguity/__init__.py
deleted file mode 100644
index 94dd960..0000000
--- a/contiguity/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from .main import Contiguity, Verify, EmailAnalytics, Quota, OTP, Template, Send
-
-def login(token, debug=False):
- return Contiguity(token, debug)
-
-__all__ = ['Contiguity', 'Send', 'Verify', 'EmailAnalytics', 'Quota', 'OTP', 'Template']
diff --git a/contiguity/main.py b/contiguity/main.py
deleted file mode 100644
index 67508e2..0000000
--- a/contiguity/main.py
+++ /dev/null
@@ -1,355 +0,0 @@
-import re
-import requests
-import phonenumbers
-import json
-from htmlmin import minify
-
-class Contiguity:
- """
- Create a new instance of the Contiguity class.
- Args:
- token (str): The authentication token.
- debug (bool, optional): A flag indicating whether to enable debug mode. Default is False.
- """
- def __init__(self, token, debug=False):
- self.token = token.strip()
- self.debug = debug
- self.baseURL = "https://api.contiguity.co"
- self.orwellBaseURL = "https://orwell.contiguity.co"
- self.headers = {"Content-Type": "application/json",
- "Authorization": f"Token {token}"}
-
- @property
- def send(self):
- """
- Returns an instance of the Send class.
- """
- return Send(self.token, self.baseURL, self.headers, self.debug)
-
- @property
- def verify(self):
- """
- Returns an instance of the Verify class.
- """
- return Verify(self.token)
-
- @property
- def email_analytics(self):
- """
- Returns an instance of the EmailAnalytics class.
- """
- return EmailAnalytics(self.token, self.orwellBaseURL, self.headers, self.debug)
-
- @property
- def quota(self):
- """
- Returns an instance of the Quota class.
- """
- return Quota(self.token, self.baseURL, self.headers, self.debug)
-
- @property
- def otp(self):
- """
- Returns an instance of the OTP class.
- """
- return OTP(self.token, self.baseURL, self.headers, self.debug)
-
- @property
- def template(self):
- """
- Returns an instance of the Template class.
- """
- return Template()
-
-
-class Send:
- """
- Send class for Contiguity.
- """
-
- def __init__(self, token, baseURL, headers, debug=False):
- self.token = token
- self.baseURL = baseURL
- self.debug = debug
- self.headers = headers
-
- def text(self, obj):
- """
- Send a text message.
- Args:
- obj (dict): The object containing the message details.
- obj['to'] (str): The recipient's phone number.
- obj['message'] (str): The message to send.
- Returns:
- dict: The response object.
- Raises:
- ValueError: Raises an error if required fields are missing or sending the message fails.
- """
- if 'to' not in obj:
- raise ValueError("Contiguity requires a recipient to be specified.")
- if 'message' not in obj:
- raise ValueError("Contiguity requires a message to be provided.")
- if not self.token:
- raise ValueError("Contiguity requires a token/API key to be provided via contiguity.login('token')")
-
- try:
- parsed_number = phonenumbers.parse(obj['to'], None)
- if not phonenumbers.is_valid_number(parsed_number):
- raise ValueError("Contiguity requires phone numbers to follow the E.164 format. Formatting failed.")
- except phonenumbers.phonenumberutil.NumberParseException:
- raise ValueError("Contiguity requires phone numbers to follow the E.164 format. Parsing failed.")
-
- text_handler = requests.post(
- f"{self.baseURL}/send/text",
- json={
- 'to': phonenumbers.format_number(parsed_number, phonenumbers.PhoneNumberFormat.E164),
- 'message': obj['message'],
- },
- headers= self.headers
- )
- text_handler_response = text_handler.json()
-
- if text_handler.status_code != 200:
- raise ValueError(
- f"Contiguity couldn't send your message. Received: {text_handler.status_code} with reason: \"{text_handler_response['message']}\"")
- if self.debug:
- print(
- f"Contiguity successfully sent your text to {obj['to']}. Crumbs:\n\n{json.dumps(text_handler_response)}")
-
- return text_handler_response
-
- def email(self, obj):
- """
- Send an email.
- Args:
- obj (dict): The object containing the email details.
- obj['to'] (str): The recipient's email address.
- obj['from'] (str): The sender's name. The email address is selected automatically. Configure at contiguity.co/dashboard
- obj['subject'] (str): The email subject.
- obj['text'] (str, optional): The plain text email body. Provide one body, or HTML will be prioritized if both are present.
- obj['html'] (str, optional): The HTML email body. Provide one body.
- obj['replyTo'] (str, optional): The reply-to email address.
- obj['cc'] (str, optional): The CC email addresses.
- Returns:
- dict: The response object.
- Raises:
- ValueError: Raises an error if required fields are missing or sending the email fails.
- """
- if 'to' not in obj:
- raise ValueError("Contiguity requires a recipient to be specified.")
- if 'from' not in obj:
- raise ValueError("Contiguity requires a sender to be specified.")
- if 'subject' not in obj:
- raise ValueError("Contiguity requires a subject to be specified.")
- if 'text' not in obj and 'html' not in obj:
- raise ValueError("Contiguity requires an email body (text or HTML) to be provided.")
- if not self.token:
- raise ValueError("Contiguity requires a token/API key to be provided via contiguity.login('token')")
-
- email_payload = {
- 'to': obj['to'],
- 'from': obj['from'],
- 'subject': obj['subject'],
- 'body': minify(obj['html']) if 'html' in obj else obj['text'],
- 'contentType': 'html' if 'html' in obj else 'text',
- }
-
- if 'replyTo' in obj:
- email_payload['replyTo'] = obj['replyTo']
-
- if 'cc' in obj:
- email_payload['cc'] = obj['cc']
-
- email_handler = requests.post(
- f"{self.baseURL}/send/email",
- json=email_payload,
- headers= self.headers
- )
-
- email_handler_response = email_handler.json()
-
- if email_handler.status_code != 200:
- raise ValueError(
- f"Contiguity couldn't send your email. Received: {email_handler.status_code} with reason: \"{email_handler_response['message']}\"")
- if self.debug:
- print(
- f"Contiguity successfully sent your email to {obj['to']}. Crumbs:\n\n{json.dumps(email_handler_response)}")
-
- return email_handler_response
-
-
-class Verify:
- def __init__(self, token):
- self.token = token
-
- def number(self, number):
- try:
- validity = phonenumbers.is_valid_number(phonenumbers.parse(number))
- return validity
- except Exception as e:
- return False
-
- def email(self, email):
- email_pattern = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
- return bool(email_pattern.match(email))
-
-
-class EmailAnalytics:
- def __init__(self, token, orwellBaseURL, headers, debug=False):
- self.token = token
- self.orwellBaseURL = orwellBaseURL
- self.debug = debug
- self.headers = headers
-
- def retrieve(self, id):
- if not self.token:
- raise ValueError("Contiguity requires a token/API key to be provided via contiguity.login('token')")
- if not id:
- raise ValueError("Contiguity Analytics requires an email ID.")
-
- status = requests.get(
- f"{self.orwellBaseURL}/email/status/{id}",
- headers= self.headers
- )
-
- json_data = status.json()
-
- if status.status_code != 200:
- raise ValueError(f"Contiguity Analytics couldn't find an email with ID {id}")
- if self.debug:
- print(f"Contiguity successfully found your email. Data:\n\n{json.dumps(json_data)}")
-
- return json_data
-
-
-class Quota:
- def __init__(self, token, baseURL, headers, debug=False):
- self.token = token
- self.baseURL = baseURL
- self.debug = debug
- self.headers = headers
-
- def retrieve(self):
- if not self.token:
- raise ValueError("Contiguity requires a token/API key to be provided via contiguity.login('token')")
-
- quota = requests.get(
- f"{self.baseURL}/user/get/quota",
- headers= self.headers
- )
-
- json_data = quota.json()
-
- if quota.status_code != 200:
- raise ValueError(
- f"Contiguity had an issue finding your quota. Received {quota.status_code} with reason: \"{json_data['message']}\"")
- if self.debug:
- print(f"Contiguity successfully found your quota. Data:\n\n{json.dumps(json_data)}")
-
- return json_data
-
-
-class OTP:
- def __init__(self, token, baseURL, headers, debug=False):
- self.token = token
- self.baseURL = baseURL
- self.debug = debug
- self.headers = headers
-
- def send(self, obj):
- if not self.token:
- raise ValueError("Contiguity requires a token/API key to be provided via contiguity.login('token')")
- if "to" not in obj:
- raise ValueError("Contiguity requires a recipient to be specified.")
- if "language" not in obj:
- raise ValueError("Contiguity requires a language to be specified.")
-
- e164 = phonenumbers.format_number(phonenumbers.parse(obj["to"]), phonenumbers.PhoneNumberFormat.E164)
-
- otp_handler = requests.post(
- f"{self.baseURL}/otp/new",
- json={
- "to": e164,
- "language": obj["language"],
- "name": obj.get("name"),
- },
- headers= self.headers
- )
-
- otp_handler_response = otp_handler.json()
-
- if otp_handler.status_code != 200:
- raise ValueError(
- f"Contiguity couldn't send your OTP. Received: {otp_handler.status_code} with reason: \"{otp_handler_response['message']}\"")
- if self.debug:
- print(f"Contiguity successfully sent your OTP to {obj['to']} with OTP ID {otp_handler_response['otp_id']}")
-
- return otp_handler_response["otp_id"]
-
- def verify(self, obj):
- if not self.token:
- raise ValueError("Contiguity requires a token/API key to be provided via contiguity.login('token')")
- if "otp_id" not in obj:
- raise ValueError("Contiguity requires an OTP ID to be specified.")
- if "otp" not in obj:
- raise ValueError("Contiguity requires an OTP (user input) to be specified.")
-
- otp_handler = requests.post(
- f"{self.baseURL}/otp/verify",
- json={
- "otp": obj["otp"],
- "otp_id": obj["otp_id"],
- },
- headers= self.headers
- )
-
- otp_handler_response = otp_handler.json()
-
- if otp_handler.status_code != 200:
- raise ValueError(
- f"Contiguity couldn't verify your OTP. Received: {otp_handler.status_code} with reason: \"{otp_handler_response['message']}\"")
- if self.debug:
- print(
- f"Contiguity 'verified' your OTP ({obj['otp']}) with boolean verified status: {otp_handler_response['verified']}")
-
- return otp_handler_response["verified"]
-
- def resend(self, obj):
- if not self.token:
- raise ValueError("Contiguity requires a token/API key to be provided via contiguity.login('token')")
- if "otp_id" not in obj:
- raise ValueError("Contiguity requires an OTP ID to be specified.")
-
- otp_handler = requests.post(
- f"{self.baseURL}/otp/resend",
- json={
- "otp_id": obj["otp_id"],
- },
- headers= self.headers
- )
-
- otp_handler_response = otp_handler.json()
-
- if otp_handler.status_code != 200:
- raise ValueError(
- f"Contiguity couldn't resend your OTP. Received: {otp_handler.status_code} with reason: \"{otp_handler_response['message']}\"")
- if self.debug:
- print(
- f"Contiguity resent your OTP ({obj['otp']}) with boolean resent status: {otp_handler_response['verified']}")
-
- return otp_handler_response["verified"]
-
-
-class Template:
- def local(self, file):
- try:
- with open(file, "r") as f:
- file_content = f.read()
- mini = minify(file_content, minify_js=True, minify_css=True)
- return mini
- except IOError:
- raise ValueError("Getting contents from files is not supported in the current environment.")
-
- async def online(self, file_name):
- # Coming soon
- pass
\ No newline at end of file
diff --git a/examples/__init__.py b/examples/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/examples/base/__init__.py b/examples/base/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/examples/base/basic_usage.py b/examples/base/basic_usage.py
new file mode 100644
index 0000000..c673c22
--- /dev/null
+++ b/examples/base/basic_usage.py
@@ -0,0 +1,40 @@
+# ruff: noqa: T201
+from contiguity import Base
+
+# Create a Base instance.
+db = Base("my-base")
+
+# Put an item with a specific key.
+put_result = db.put({"key": "foo", "value": "Hello world!"})
+print("Put result:", put_result)
+
+# Put multiple items.
+put_result = db.put({"key": "bar", "value": "Bar"}, {"key": "baz", "value": "Baz"})
+print("Put many result:", put_result)
+
+# Insert an item with a specific key.
+insert_result = db.insert({"key": "john-doe", "name": "John Doe", "age": 30})
+print("Insert result:", insert_result)
+
+# Insert an item with a specific key that expires in 1 hour.
+expiring_insert_result = db.insert({"key": "jane-doe", "name": "Jane Doe", "age": 28}, expire_in=3600)
+print("Insert with expiry result:", expiring_insert_result)
+
+# Get an item using a key.
+get_result = db.get("foo")
+print("Get result:", get_result)
+
+# Update an item.
+update_result = db.update({"age": db.util.increment(2), "name": "Mr. Doe"}, key="john-doe")
+print("Update result:", update_result)
+
+# Query items.
+query_result = db.query({"age?gt": 25}, limit=10)
+print("Query result:", query_result)
+
+# Delete an item.
+db.delete("jane-doe-py")
+
+# Delete all items.
+for item in db.query().items:
+ db.delete(str(item["key"]))
diff --git a/examples/base/pydantic_usage.py b/examples/base/pydantic_usage.py
new file mode 100644
index 0000000..305c750
--- /dev/null
+++ b/examples/base/pydantic_usage.py
@@ -0,0 +1,69 @@
+# ruff: noqa: T201
+from pydantic import BaseModel
+
+from contiguity import Base
+
+
+# Create a Pydantic model for the item.
+class MyItem(BaseModel):
+ key: str # Make sure to include the key field.
+ name: str
+ age: int
+ interests: list[str] = []
+
+
+# Create a Base instance.
+# Static type checking will work with the Pydantic model.
+db = Base("members", item_type=MyItem)
+
+# Put an item with a specific key.
+put_result = db.put(
+ MyItem(
+ key="foo",
+ name="John Doe",
+ age=30,
+ interests=["Python", "JavaScript"],
+ ),
+)
+print("Put result:", put_result)
+
+# Put multiple items.
+put_result = db.put(
+ MyItem(key="bar", name="Jane Doe", age=28),
+ MyItem(key="baz", name="Alice", age=25),
+)
+print("Put many result:", put_result)
+
+# Insert an item with a specific key.
+insert_result = db.insert(
+ MyItem(
+ key="xyz",
+ name="Arthur",
+ age=33,
+ interests=["Anime", "Music"],
+ ),
+)
+print("Insert result:", insert_result)
+
+# Insert an item with a specific key that expires in 1 hour.
+expiring_insert_result = db.insert(MyItem(key="abc", name="David", age=20), expire_in=3600)
+print("Insert with expiry result:", expiring_insert_result)
+
+# Get an item using a key.
+get_result = db.get("foo")
+print("Get result:", get_result)
+
+# Update an item.
+update_result = db.update({"age": db.util.increment(2), "name": "Mr. Doe"}, key="foo")
+print("Update result:", update_result)
+
+# Query items.
+query_result = db.query({"age?gt": 25}, limit=10)
+print("Query result:", query_result)
+
+# Delete an item.
+db.delete("bar")
+
+# Delete all items.
+for item in db.query().items:
+ db.delete(str(item.key))
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..80e29b6
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,77 @@
+[build-system]
+requires = [
+ "setuptools>=61",
+]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "contiguity"
+dynamic = ["version"]
+description = "Contiguity's official Python SDK"
+readme = "README.md"
+license = {file = "LICENSE.txt"}
+requires-python = ">=3.9"
+authors = [
+ {name = "Contiguity", email = "help@contiguity.support"},
+]
+keywords = [
+ "python",
+ "contiguity",
+ "sms",
+ "email",
+ "otp",
+ "deta",
+ "base",
+ "database",
+]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Intended Audience :: Developers",
+ "License :: OSI Approved :: MIT License",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3",
+ "Topic :: Utilities",
+ "Typing :: Typed",
+]
+dependencies = [
+ "htmlmin>=0.1.12",
+ "httpx>=0.27.2",
+ "phonenumbers>=8.13.47,<9.0.0",
+ "pydantic>=2.9.0,<3.0.0",
+ "typing-extensions>=4.12.2,<5.0.0",
+]
+
+[project.urls]
+Repository = "https://github.com/contiguity/python"
+
+[tool.setuptools]
+package-data = {contiguity = ["py.typed"]}
+
+[tool.setuptools.dynamic]
+version = {attr = "contiguity.__version__"}
+
+[tool.uv]
+dev-dependencies = [
+ "pre-commit~=3.8.0",
+ "pytest~=8.3.3",
+ "pytest-cov~=5.0.0",
+ "python-dotenv~=1.0.1",
+ "pytest-asyncio~=0.24.0",
+]
+
+[tool.ruff]
+src = ["src"]
+line-length = 119
+target-version = "py39"
+
+[tool.ruff.lint]
+select = ["ALL"]
+ignore = ["A", "D", "T201"]
+
+[tool.pyright]
+venvPath = "."
+venv = ".venv"
+reportUnnecessaryTypeIgnoreComment = true
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 1460076..0000000
--- a/setup.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from setuptools import setup, find_packages
-
-VERSION = '1.0.2'
-DESCRIPTION = "Contiguity's official Python SDK"
-LONG_DESCRIPTION = "Contiguity's official Python SDK makes using Contiguity easier than ever. See more information at https://github.com/use-contiguity/python"
-
-setup(
- name="contiguity",
- version=VERSION,
- author="Contiguity",
- author_email="",
- url="https://github.com/use-contiguity/python",
- description=DESCRIPTION,
- long_description=LONG_DESCRIPTION,
- packages=find_packages(),
- install_requires=["requests", "phonenumbers", "htmlmin"],
- keywords=['python', 'contiguity', 'sms', 'email', 'otp'],
-)
-
-# python3 setup.py sdist bdist_wheel
-# twine upload dist/*
\ No newline at end of file
diff --git a/src/contiguity/__init__.py b/src/contiguity/__init__.py
new file mode 100644
index 0000000..b6658d7
--- /dev/null
+++ b/src/contiguity/__init__.py
@@ -0,0 +1,66 @@
+from ._client import ApiClient
+from .analytics import EmailAnalytics
+from .base import AsyncBase, Base, BaseItem, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
+from .otp import OTP
+from .quota import Quota
+from .send import Send
+from .template import Template
+from .verify import Verify
+
+
+class Contiguity:
+ """
+ Create a new instance of the Contiguity class.
+ Args:
+ token (str): The authentication token.
+ debug (bool, optional): A flag indicating whether to enable debug mode. Default is False.
+ """
+
+ def __init__(
+ self,
+ *,
+ token: str,
+ base_url: str = "https://api.contiguity.co",
+ orwell_base_url: str = "https://orwell.contiguity.co",
+ debug: bool = False,
+ ) -> None:
+ if not token:
+ msg = "Contiguity requires a token/API key to be provided via contiguity.login('token')"
+ raise ValueError(msg)
+ self.token = token
+ self.base_url = base_url
+ self.orwell_base_url = orwell_base_url
+ self.debug = debug
+ self.client = ApiClient(base_url=self.base_url, api_key=token.strip())
+ self.orwell_client = ApiClient(base_url=self.orwell_base_url, api_key=token.strip())
+
+ self.send = Send(client=self.client, debug=self.debug)
+ self.verify = Verify()
+ self.email_analytics = EmailAnalytics(client=self.orwell_client, debug=self.debug)
+ self.quota = Quota(client=self.client, debug=self.debug)
+ self.otp = OTP(client=self.client, debug=self.debug)
+ self.template = Template()
+
+
+def login(token: str, /, *, debug: bool = False) -> Contiguity:
+ return Contiguity(token=token, debug=debug)
+
+
+__all__ = (
+ "AsyncBase",
+ "Contiguity",
+ "Send",
+ "Verify",
+ "EmailAnalytics",
+ "Quota",
+ "OTP",
+ "Template",
+ "Base",
+ "BaseItem",
+ "InvalidKeyError",
+ "ItemConflictError",
+ "ItemNotFoundError",
+ "QueryResponse",
+ "login",
+)
+__version__ = "2.0.0"
diff --git a/src/contiguity/_auth.py b/src/contiguity/_auth.py
new file mode 100644
index 0000000..8e533e4
--- /dev/null
+++ b/src/contiguity/_auth.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+import os
+
+
+def _get_env_var(var_name: str, friendly_name: str | None = None) -> str:
+ value = os.getenv(var_name, "")
+ if not value:
+ msg = f"no {friendly_name or var_name} provided"
+ raise ValueError(msg)
+ return value
+
+
+def get_contiguity_token() -> str:
+ return _get_env_var("CONTIGUITY_TOKEN", "Contiguity token")
+
+
+def get_data_key() -> str:
+ return _get_env_var("CONTIGUITY_DATA_KEY", "data key")
+
+
+def get_project_id() -> str:
+ return _get_env_var("CONTIGUITY_PROJECT_ID", "project ID")
diff --git a/src/contiguity/_client.py b/src/contiguity/_client.py
new file mode 100644
index 0000000..5ffbbdb
--- /dev/null
+++ b/src/contiguity/_client.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from httpx import AsyncClient as HttpxAsyncClient
+from httpx import Client as HttpxClient
+
+from ._auth import get_contiguity_token
+
+
+class ApiError(Exception):
+ pass
+
+
+class ApiClient(HttpxClient):
+ def __init__(
+ self: ApiClient,
+ *,
+ base_url: str = "https://api.contiguity.co",
+ api_key: str | None = None,
+ timeout: int = 5,
+ ) -> None:
+ if not api_key:
+ api_key = get_contiguity_token()
+ super().__init__(
+ headers={
+ "Content-Type": "application/json",
+ "X-API-Key": api_key,
+ "Authorization": f"Token {api_key}",
+ },
+ timeout=timeout,
+ base_url=base_url,
+ )
+
+
+class AsyncApiClient(HttpxAsyncClient):
+ def __init__(
+ self: AsyncApiClient,
+ *,
+ base_url: str = "https://api.contiguity.co",
+ api_key: str | None = None,
+ timeout: int = 5,
+ ) -> None:
+ if not api_key:
+ api_key = get_contiguity_token()
+ super().__init__(
+ headers={
+ "Content-Type": "application/json",
+ "X-API-Key": api_key,
+ "Authorization": f"Token {api_key}",
+ },
+ timeout=timeout,
+ base_url=base_url,
+ )
diff --git a/src/contiguity/_common.py b/src/contiguity/_common.py
new file mode 100644
index 0000000..f0d3392
--- /dev/null
+++ b/src/contiguity/_common.py
@@ -0,0 +1,8 @@
+from pydantic import BaseModel
+
+
+class Crumbs(BaseModel):
+ plan: str
+ quota: int
+ type: str
+ ad: bool
diff --git a/src/contiguity/analytics.py b/src/contiguity/analytics.py
new file mode 100644
index 0000000..1408d2c
--- /dev/null
+++ b/src/contiguity/analytics.py
@@ -0,0 +1,28 @@
+from http import HTTPStatus
+
+from typing_extensions import deprecated
+
+from ._client import ApiClient
+
+
+class EmailAnalytics:
+ def __init__(self, *, client: ApiClient, debug: bool = False) -> None:
+ self._client = client
+ self.debug = debug
+
+ @deprecated("email analytics will be removed in a future release")
+ def retrieve(self, id: str) -> dict:
+ if not id:
+ msg = "Contiguity Analytics requires an email ID."
+ raise ValueError(msg)
+
+ response = self._client.get(f"/email/status/{id}")
+ data = response.json()
+
+ if response.status_code != HTTPStatus.OK:
+ msg = f"Contiguity Analytics couldn't find an email with ID {id}"
+ raise ValueError(msg)
+ if self.debug:
+ print(f"Contiguity successfully found your email. Data:\n{data}")
+
+ return data
diff --git a/src/contiguity/base/__init__.py b/src/contiguity/base/__init__.py
new file mode 100644
index 0000000..8aada10
--- /dev/null
+++ b/src/contiguity/base/__init__.py
@@ -0,0 +1,14 @@
+from .async_base import AsyncBase
+from .base import Base
+from .common import BaseItem, QueryResponse
+from .exceptions import InvalidKeyError, ItemConflictError, ItemNotFoundError
+
+__all__ = (
+ "AsyncBase",
+ "Base",
+ "BaseItem",
+ "InvalidKeyError",
+ "ItemConflictError",
+ "ItemNotFoundError",
+ "QueryResponse",
+)
diff --git a/src/contiguity/base/async_base.py b/src/contiguity/base/async_base.py
new file mode 100644
index 0000000..3a0c3c1
--- /dev/null
+++ b/src/contiguity/base/async_base.py
@@ -0,0 +1,318 @@
+from __future__ import annotations
+
+import json
+import os
+from collections.abc import Mapping, Sequence
+from datetime import datetime, timedelta, timezone
+from http import HTTPStatus
+from typing import TYPE_CHECKING, Generic, Literal, overload
+from warnings import warn
+
+from httpx import HTTPStatusError
+from pydantic import BaseModel, TypeAdapter
+from pydantic import JsonValue as DataType
+from typing_extensions import deprecated
+
+from contiguity._auth import get_data_key, get_project_id
+from contiguity._client import ApiError, AsyncApiClient
+
+from .common import (
+ UNSET,
+ DefaultItemT,
+ ItemT,
+ QueryResponse,
+ QueryType,
+ TimestampType,
+ Unset,
+ UpdateOperation,
+ UpdatePayload,
+ Updates,
+ check_key,
+)
+from .exceptions import ItemConflictError, ItemNotFoundError
+
+if TYPE_CHECKING:
+ from httpx import Response as HttpxResponse
+ from typing_extensions import Self
+
+
+class AsyncBase(Generic[ItemT]):
+ EXPIRES_ATTRIBUTE = "__expires"
+ PUT_LIMIT = 30
+
+ @overload
+ def __init__(
+ self: Self,
+ name: str,
+ /,
+ *,
+ item_type: type[ItemT] | None = None,
+ data_key: str | None = None,
+ project_id: str | None = None,
+ host: str | None = None,
+ api_version: str = "v1",
+ json_decoder: type[json.JSONDecoder] = json.JSONDecoder,
+ ) -> None: ...
+
+ @overload
+ @deprecated("The `project_key` parameter has been renamed to `data_key`.")
+ def __init__(
+ self: Self,
+ name: str,
+ /,
+ *,
+ item_type: type[ItemT] | None = None,
+ project_key: str | None = None,
+ project_id: str | None = None,
+ host: str | None = None,
+ api_version: str = "v1",
+ json_decoder: type[json.JSONDecoder] = json.JSONDecoder,
+ ) -> None: ...
+
+ def __init__( # noqa: PLR0913
+ self: Self,
+ name: str,
+ /,
+ *,
+ item_type: type[ItemT] | None = None,
+ data_key: str | None = None,
+ project_key: str | None = None, # Deprecated.
+ project_id: str | None = None,
+ host: str | None = None,
+ api_version: str = "v1",
+ json_decoder: type[json.JSONDecoder] = json.JSONDecoder, # Only used when item_type is not a Pydantic model.
+ ) -> None:
+ if not name:
+ msg = f"invalid Base name '{name}'"
+ raise ValueError(msg)
+
+ self.name = name
+ self.item_type = item_type
+ self.data_key = data_key or project_key or get_data_key()
+ self.project_id = project_id or get_project_id()
+ self.host = host or os.getenv("CONTIGUITY_BASE_HOST") or "api.base.contiguity.co"
+ self.api_version = api_version
+ self.json_decoder = json_decoder
+ self.util = Updates()
+ self._client = AsyncApiClient(
+ base_url=f"https://{self.host}/{api_version}/{self.project_id}/{self.name}",
+ api_key=self.data_key,
+ timeout=300,
+ )
+
+ @overload
+ def _response_as_item_type(
+ self: Self,
+ response: HttpxResponse,
+ /,
+ *,
+ sequence: Literal[False] = False,
+ ) -> ItemT: ...
+ @overload
+ def _response_as_item_type(
+ self: Self,
+ response: HttpxResponse,
+ /,
+ *,
+ sequence: Literal[True] = True,
+ ) -> Sequence[ItemT]: ...
+
+ def _response_as_item_type(
+ self: Self,
+ response: HttpxResponse,
+ /,
+ *,
+ sequence: bool = False,
+ ) -> ItemT | Sequence[ItemT]:
+ try:
+ response.raise_for_status()
+ except HTTPStatusError as exc:
+ raise ApiError(exc.response.text) from exc
+ if self.item_type:
+ if sequence:
+ return TypeAdapter(Sequence[self.item_type]).validate_json(response.content)
+ return TypeAdapter(self.item_type).validate_json(response.content)
+ return response.json(cls=self.json_decoder)
+
+ def _insert_expires_attr(
+ self: Self,
+ item: ItemT | Mapping[str, DataType],
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> dict[str, DataType]:
+ if expire_in and expire_at:
+ msg = "cannot use both expire_in and expire_at"
+ raise ValueError(msg)
+
+ item_dict = item.model_dump() if isinstance(item, BaseModel) else dict(item)
+
+ if not expire_in and not expire_at:
+ return item_dict
+ if expire_in:
+ expire_at = datetime.now(tz=timezone.utc) + timedelta(seconds=expire_in)
+ if isinstance(expire_at, datetime):
+ expire_at = int(expire_at.replace(microsecond=0).timestamp())
+ if not isinstance(expire_at, int):
+ msg = "expire_at should be a datetime or int"
+ raise TypeError(msg)
+
+ item_dict[self.EXPIRES_ATTRIBUTE] = expire_at
+ return item_dict
+
+ @overload
+ async def get(self: Self, key: str, /) -> ItemT | None: ...
+
+ @overload
+ async def get(self: Self, key: str, /, *, default: ItemT) -> ItemT: ...
+
+ @overload
+ async def get(self: Self, key: str, /, *, default: DefaultItemT) -> ItemT | DefaultItemT: ...
+
+ async def get(
+ self: Self,
+ key: str,
+ /,
+ *,
+ default: ItemT | DefaultItemT | Unset = UNSET,
+ ) -> ItemT | DefaultItemT | None:
+ key = check_key(key)
+ response = await self._client.get(f"/items/{key}")
+ if response.status_code == HTTPStatus.NOT_FOUND:
+ if not isinstance(default, Unset):
+ return default
+ msg = (
+ "ItemNotFoundError will be raised in the future."
+ " To receive None for non-existent keys, set default=None."
+ )
+ warn(DeprecationWarning(msg), stacklevel=2)
+ return None
+
+ return self._response_as_item_type(response, sequence=False)
+
+ async def delete(self: Self, key: str, /) -> None:
+ """Delete an item from the Base."""
+ key = check_key(key)
+ response = await self._client.delete(f"/items/{key}")
+ try:
+ response.raise_for_status()
+ except HTTPStatusError as exc:
+ raise ApiError(exc.response.text) from exc
+
+ async def insert(
+ self: Self,
+ item: ItemT,
+ /,
+ *,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> ItemT:
+ item_dict = self._insert_expires_attr(item, expire_in=expire_in, expire_at=expire_at)
+ response = await self._client.post("/items", json={"item": item_dict})
+
+ if response.status_code == HTTPStatus.CONFLICT:
+ raise ItemConflictError(str(item_dict.get("key")))
+
+ if not (returned_item := self._response_as_item_type(response, sequence=True)):
+ msg = "expected a single item, got an empty response"
+ raise ApiError(msg)
+ return returned_item[0]
+
+ async def put(
+ self: Self,
+ *items: ItemT,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> Sequence[ItemT]:
+ """store (put) an item in the database. Overrides an item if key already exists.
+ `key` could be provided as function argument or a field in the data dict.
+ If `key` is not provided, the server will generate a random 12 chars key.
+ """
+ if not items:
+ return []
+ if len(items) > self.PUT_LIMIT:
+ msg = f"cannot put more than {self.PUT_LIMIT} items at a time"
+ raise ValueError(msg)
+
+ item_dicts = [self._insert_expires_attr(item, expire_in=expire_in, expire_at=expire_at) for item in items]
+ response = await self._client.put("/items", json={"items": item_dicts})
+ return self._response_as_item_type(response, sequence=True)
+
+ @deprecated("This method will be removed in a future release. You can pass multiple items to `put`.")
+ async def put_many(
+ self: Self,
+ items: Sequence[ItemT],
+ /,
+ *,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> Sequence[ItemT]:
+ return await self.put(*items, expire_in=expire_in, expire_at=expire_at)
+
+ async def update(
+ self: Self,
+ updates: Mapping[str, DataType | UpdateOperation],
+ /,
+ *,
+ key: str,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> ItemT:
+ """update an item in the database
+ `updates` specifies the attribute names and values to update,add or remove
+ `key` is the key of the item to be updated
+ """
+ key = check_key(key)
+ if not updates:
+ msg = "no updates provided"
+ raise ValueError(msg)
+
+ payload = UpdatePayload.from_updates_mapping(updates)
+ payload.set = self._insert_expires_attr(
+ payload.set,
+ expire_in=expire_in,
+ expire_at=expire_at,
+ )
+
+ response = await self._client.patch(f"/items/{key}", json={"updates": payload.model_dump()})
+ if response.status_code == HTTPStatus.NOT_FOUND:
+ raise ItemNotFoundError(key)
+
+ return self._response_as_item_type(response, sequence=False)
+
+ async def query(
+ self: Self,
+ *queries: QueryType,
+ limit: int = 1000,
+ last: str | None = None,
+ ) -> QueryResponse[ItemT]:
+ """fetch items from the database.
+ `query` is an optional filter or list of filters. Without filter, it will return the whole db.
+ """
+
+ payload = {
+ "limit": limit,
+ "last_key": last,
+ }
+
+ if queries:
+ payload["query"] = queries
+
+ response = await self._client.post("/query", json=payload)
+ try:
+ response.raise_for_status()
+ except HTTPStatusError as exc:
+ raise ApiError(exc.response.text) from exc
+ query_response = QueryResponse[ItemT].model_validate_json(response.content)
+ if self.item_type:
+ # HACK: Pydantic model_validate_json doesn't validate Sequence[ItemT] properly. # noqa: FIX004
+ query_response.items = TypeAdapter(Sequence[self.item_type]).validate_python(query_response.items)
+ return query_response
+
+ @deprecated("This method has been renamed to `query` and will be removed in a future release.")
+ async def fetch(
+ self: Self,
+ *queries: QueryType,
+ limit: int = 1000,
+ last: str | None = None,
+ ) -> QueryResponse[ItemT]:
+ return await self.query(*queries, limit=limit, last=last)
diff --git a/src/contiguity/base/base.py b/src/contiguity/base/base.py
new file mode 100644
index 0000000..819899d
--- /dev/null
+++ b/src/contiguity/base/base.py
@@ -0,0 +1,327 @@
+# TODO @lemonyte: todo list. # noqa: TD003, FIX002
+# - [ ] new docstrings
+# - [ ] test expiring items
+# - [ ] support dataclasses
+# - [ ] support models for queries
+# - [ ] examples
+# - [ ] add async
+# - [ ] add drive support
+
+from __future__ import annotations
+
+import json
+import os
+from collections.abc import Mapping, Sequence
+from datetime import datetime, timedelta, timezone
+from http import HTTPStatus
+from typing import TYPE_CHECKING, Generic, Literal, overload
+from warnings import warn
+
+from httpx import HTTPStatusError
+from pydantic import BaseModel, TypeAdapter
+from pydantic import JsonValue as DataType
+from typing_extensions import deprecated
+
+from contiguity._auth import get_data_key, get_project_id
+from contiguity._client import ApiClient, ApiError
+
+from .common import (
+ UNSET,
+ DefaultItemT,
+ ItemT,
+ QueryResponse,
+ QueryType,
+ TimestampType,
+ Unset,
+ UpdateOperation,
+ UpdatePayload,
+ Updates,
+ check_key,
+)
+from .exceptions import ItemConflictError, ItemNotFoundError
+
+if TYPE_CHECKING:
+ from httpx import Response as HttpxResponse
+ from typing_extensions import Self
+
+
+class Base(Generic[ItemT]):
+ EXPIRES_ATTRIBUTE = "__expires"
+ PUT_LIMIT = 30
+
+ @overload
+ def __init__(
+ self: Self,
+ name: str,
+ /,
+ *,
+ item_type: type[ItemT] | None = None,
+ data_key: str | None = None,
+ project_id: str | None = None,
+ host: str | None = None,
+ api_version: str = "v1",
+ json_decoder: type[json.JSONDecoder] = json.JSONDecoder,
+ ) -> None: ...
+
+ @overload
+ @deprecated("The `project_key` parameter has been renamed to `data_key`.")
+ def __init__(
+ self: Self,
+ name: str,
+ /,
+ *,
+ item_type: type[ItemT] | None = None,
+ project_key: str | None = None,
+ project_id: str | None = None,
+ host: str | None = None,
+ api_version: str = "v1",
+ json_decoder: type[json.JSONDecoder] = json.JSONDecoder,
+ ) -> None: ...
+
+ def __init__( # noqa: PLR0913
+ self: Self,
+ name: str,
+ /,
+ *,
+ item_type: type[ItemT] | None = None,
+ data_key: str | None = None,
+ project_key: str | None = None, # Deprecated.
+ project_id: str | None = None,
+ host: str | None = None,
+ api_version: str = "v1",
+ json_decoder: type[json.JSONDecoder] = json.JSONDecoder, # Only used when item_type is not a Pydantic model.
+ ) -> None:
+ if not name:
+ msg = f"invalid Base name '{name}'"
+ raise ValueError(msg)
+
+ self.name = name
+ self.item_type = item_type
+ self.data_key = data_key or project_key or get_data_key()
+ self.project_id = project_id or get_project_id()
+ self.host = host or os.getenv("CONTIGUITY_BASE_HOST") or "api.base.contiguity.co"
+ self.api_version = api_version
+ self.json_decoder = json_decoder
+ self.util = Updates()
+ self._client = ApiClient(
+ base_url=f"https://{self.host}/{api_version}/{self.project_id}/{self.name}",
+ api_key=self.data_key,
+ timeout=300,
+ )
+
+ @overload
+ def _response_as_item_type(
+ self: Self,
+ response: HttpxResponse,
+ /,
+ *,
+ sequence: Literal[False] = False,
+ ) -> ItemT: ...
+ @overload
+ def _response_as_item_type(
+ self: Self,
+ response: HttpxResponse,
+ /,
+ *,
+ sequence: Literal[True] = True,
+ ) -> Sequence[ItemT]: ...
+
+ def _response_as_item_type(
+ self: Self,
+ response: HttpxResponse,
+ /,
+ *,
+ sequence: bool = False,
+ ) -> ItemT | Sequence[ItemT]:
+ try:
+ response.raise_for_status()
+ except HTTPStatusError as exc:
+ raise ApiError(exc.response.text) from exc
+ if self.item_type:
+ if sequence:
+ return TypeAdapter(Sequence[self.item_type]).validate_json(response.content)
+ return TypeAdapter(self.item_type).validate_json(response.content)
+ return response.json(cls=self.json_decoder)
+
+ def _insert_expires_attr(
+ self: Self,
+ item: ItemT | Mapping[str, DataType],
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> dict[str, DataType]:
+ if expire_in and expire_at:
+ msg = "cannot use both expire_in and expire_at"
+ raise ValueError(msg)
+
+ item_dict = item.model_dump() if isinstance(item, BaseModel) else dict(item)
+
+ if not expire_in and not expire_at:
+ return item_dict
+ if expire_in:
+ expire_at = datetime.now(tz=timezone.utc) + timedelta(seconds=expire_in)
+ if isinstance(expire_at, datetime):
+ expire_at = int(expire_at.replace(microsecond=0).timestamp())
+ if not isinstance(expire_at, int):
+ msg = "expire_at should be a datetime or int"
+ raise TypeError(msg)
+
+ item_dict[self.EXPIRES_ATTRIBUTE] = expire_at
+ return item_dict
+
+ @overload
+ def get(self: Self, key: str, /) -> ItemT | None: ...
+
+ @overload
+ def get(self: Self, key: str, /, *, default: ItemT) -> ItemT: ...
+
+ @overload
+ def get(self: Self, key: str, /, *, default: DefaultItemT) -> ItemT | DefaultItemT: ...
+
+ def get(
+ self: Self,
+ key: str,
+ /,
+ *,
+ default: ItemT | DefaultItemT | Unset = UNSET,
+ ) -> ItemT | DefaultItemT | None:
+ key = check_key(key)
+ response = self._client.get(f"/items/{key}")
+ if response.status_code == HTTPStatus.NOT_FOUND:
+ if not isinstance(default, Unset):
+ return default
+ msg = (
+ "ItemNotFoundError will be raised in the future."
+ " To receive None for non-existent keys, set default=None."
+ )
+ warn(DeprecationWarning(msg), stacklevel=2)
+ return None
+
+ return self._response_as_item_type(response, sequence=False)
+
+ def delete(self: Self, key: str, /) -> None:
+ """Delete an item from the Base."""
+ key = check_key(key)
+ response = self._client.delete(f"/items/{key}")
+ try:
+ response.raise_for_status()
+ except HTTPStatusError as exc:
+ raise ApiError(exc.response.text) from exc
+
+ def insert(
+ self: Self,
+ item: ItemT,
+ /,
+ *,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> ItemT:
+ item_dict = self._insert_expires_attr(item, expire_in=expire_in, expire_at=expire_at)
+ response = self._client.post("/items", json={"item": item_dict})
+
+ if response.status_code == HTTPStatus.CONFLICT:
+ raise ItemConflictError(str(item_dict.get("key")))
+
+ if not (returned_item := self._response_as_item_type(response, sequence=True)):
+ msg = "expected a single item, got an empty response"
+ raise ApiError(msg)
+ return returned_item[0]
+
+ def put(
+ self: Self,
+ *items: ItemT,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> Sequence[ItemT]:
+ """store (put) an item in the database. Overrides an item if key already exists.
+ `key` could be provided as function argument or a field in the data dict.
+ If `key` is not provided, the server will generate a random 12 chars key.
+ """
+ if not items:
+ return []
+ if len(items) > self.PUT_LIMIT:
+ msg = f"cannot put more than {self.PUT_LIMIT} items at a time"
+ raise ValueError(msg)
+
+ item_dicts = [self._insert_expires_attr(item, expire_in=expire_in, expire_at=expire_at) for item in items]
+ response = self._client.put("/items", json={"items": item_dicts})
+ return self._response_as_item_type(response, sequence=True)
+
+ @deprecated("This method will be removed in a future release. You can pass multiple items to `put`.")
+ def put_many(
+ self: Self,
+ items: Sequence[ItemT],
+ /,
+ *,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> Sequence[ItemT]:
+ return self.put(*items, expire_in=expire_in, expire_at=expire_at)
+
+ def update(
+ self: Self,
+ updates: Mapping[str, DataType | UpdateOperation],
+ /,
+ *,
+ key: str,
+ expire_in: int | None = None,
+ expire_at: TimestampType | None = None,
+ ) -> ItemT:
+ """update an item in the database
+ `updates` specifies the attribute names and values to update,add or remove
+ `key` is the key of the item to be updated
+ """
+ key = check_key(key)
+ if not updates:
+ msg = "no updates provided"
+ raise ValueError(msg)
+
+ payload = UpdatePayload.from_updates_mapping(updates)
+ payload.set = self._insert_expires_attr(
+ payload.set,
+ expire_in=expire_in,
+ expire_at=expire_at,
+ )
+
+ response = self._client.patch(f"/items/{key}", json={"updates": payload.model_dump()})
+ if response.status_code == HTTPStatus.NOT_FOUND:
+ raise ItemNotFoundError(key)
+
+ return self._response_as_item_type(response, sequence=False)
+
+ def query(
+ self: Self,
+ *queries: QueryType,
+ limit: int = 1000,
+ last: str | None = None,
+ ) -> QueryResponse[ItemT]:
+ """fetch items from the database.
+ `query` is an optional filter or list of filters. Without filter, it will return the whole db.
+ """
+
+ payload = {
+ "limit": limit,
+ "last_key": last,
+ }
+
+ if queries:
+ payload["query"] = queries
+
+ response = self._client.post("/query", json=payload)
+ try:
+ response.raise_for_status()
+ except HTTPStatusError as exc:
+ raise ApiError(exc.response.text) from exc
+ query_response = QueryResponse[ItemT].model_validate_json(response.content)
+ if self.item_type:
+ # HACK: Pydantic model_validate_json doesn't validate Sequence[ItemT] properly. # noqa: FIX004
+ query_response.items = TypeAdapter(Sequence[self.item_type]).validate_python(query_response.items)
+ return query_response
+
+ @deprecated("This method has been renamed to `query` and will be removed in a future release.")
+ def fetch(
+ self: Self,
+ *queries: QueryType,
+ limit: int = 1000,
+ last: str | None = None,
+ ) -> QueryResponse[ItemT]:
+ return self.query(*queries, limit=limit, last=last)
diff --git a/src/contiguity/base/common.py b/src/contiguity/base/common.py
new file mode 100644
index 0000000..dc0b61e
--- /dev/null
+++ b/src/contiguity/base/common.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+from collections.abc import Mapping, Sequence
+from datetime import datetime
+from typing import Any, Generic, TypeVar, Union
+from urllib.parse import quote
+
+from pydantic import BaseModel
+from pydantic import JsonValue as DataType
+from typing_extensions import Self
+
+from .exceptions import InvalidKeyError
+
+TimestampType = Union[int, datetime]
+QueryType = Mapping[str, DataType]
+
+ItemType = Union[Mapping[str, Any], BaseModel]
+ItemT = TypeVar("ItemT", bound=ItemType)
+DefaultItemT = TypeVar("DefaultItemT")
+
+
+class Unset:
+ pass
+
+
+UNSET = Unset()
+
+
+class BaseItem(BaseModel):
+ key: str
+
+
+class QueryResponse(BaseModel, Generic[ItemT]):
+ count: int = 0
+ last_key: Union[str, None] = None # noqa: UP007 Pydantic doesn't support `X | Y` syntax in Python 3.9.
+ items: Sequence[ItemT] = []
+
+
+class UpdateOperation:
+ pass
+
+
+class Trim(UpdateOperation):
+ pass
+
+
+class Increment(UpdateOperation):
+ def __init__(self: Increment, value: int = 1, /) -> None:
+ self.value = value
+
+
+class Append(UpdateOperation):
+ def __init__(self: Append, value: DataType, /) -> None:
+ if isinstance(value, (list, tuple)):
+ self.value = value
+ else:
+ self.value = [value]
+
+
+class Prepend(Append):
+ pass
+
+
+class Updates:
+ @staticmethod
+ def trim() -> Trim:
+ return Trim()
+
+ @staticmethod
+ def increment(value: int = 1, /) -> Increment:
+ return Increment(value)
+
+ @staticmethod
+ def append(value: DataType, /) -> Append:
+ return Append(value)
+
+ @staticmethod
+ def prepend(value: DataType, /) -> Prepend:
+ return Prepend(value)
+
+
+class UpdatePayload(BaseModel):
+ set: dict[str, DataType] = {}
+ increment: dict[str, int] = {}
+ append: dict[str, Sequence[DataType]] = {}
+ prepend: dict[str, Sequence[DataType]] = {}
+ delete: list[str] = []
+
+ @classmethod
+ def from_updates_mapping(cls: type[Self], updates: Mapping[str, DataType | UpdateOperation], /) -> Self:
+ set = {}
+ increment = {}
+ append = {}
+ prepend = {}
+ delete = []
+ for attr, value in updates.items():
+ if isinstance(value, UpdateOperation):
+ if isinstance(value, Trim):
+ delete.append(attr)
+ elif isinstance(value, Increment):
+ increment[attr] = value.value
+ # Prepend must be checked before Append because it's a subclass of Append.
+ elif isinstance(value, Prepend):
+ prepend[attr] = value.value
+ elif isinstance(value, Append):
+ append[attr] = value.value
+ else:
+ set[attr] = value
+ return cls(
+ set=set,
+ increment=increment,
+ append=append,
+ prepend=prepend,
+ delete=delete,
+ )
+
+
+def check_key(key: str, /) -> str:
+ if not key:
+ raise InvalidKeyError(key)
+ return quote(key, safe="")
diff --git a/src/contiguity/base/exceptions.py b/src/contiguity/base/exceptions.py
new file mode 100644
index 0000000..9e6cc86
--- /dev/null
+++ b/src/contiguity/base/exceptions.py
@@ -0,0 +1,16 @@
+from contiguity._client import ApiError
+
+
+class ItemConflictError(ApiError):
+ def __init__(self, key: str, *args: object) -> None:
+ super().__init__(f"item with key '{key}' already exists", *args)
+
+
+class ItemNotFoundError(ApiError):
+ def __init__(self, key: str, *args: object) -> None:
+ super().__init__(f"key '{key}' not found", *args)
+
+
+class InvalidKeyError(ValueError):
+ def __init__(self, key: str, *args: object) -> None:
+ super().__init__(f"invalid key '{key}'", *args)
diff --git a/src/contiguity/otp.py b/src/contiguity/otp.py
new file mode 100644
index 0000000..7e00cfd
--- /dev/null
+++ b/src/contiguity/otp.py
@@ -0,0 +1,143 @@
+from __future__ import annotations
+
+from enum import Enum
+from http import HTTPStatus
+from typing import TYPE_CHECKING
+
+import phonenumbers
+from pydantic import BaseModel
+
+from ._common import Crumbs # noqa: TCH001 Pydantic needs this to be outside of the TYPE_CHECKING block.
+
+if TYPE_CHECKING:
+ from ._client import ApiClient
+
+
+class OTPLanguage(str, Enum):
+ ENGLISH = "en"
+ AFRIKAANS = "af"
+ ARABIC = "ar"
+ CATALAN = "ca"
+ CHINESE = "zh"
+ "Chinese (Mandarin)"
+ CANTONESE = "zh-hk"
+ "Chinese (Cantonese)"
+ CROATIAN = "hr"
+ CZECH = "cs"
+ DANISH = "da"
+ DUTCH = "nl"
+ FINNISH = "fi"
+ FRENCH = "fr"
+ GERMAN = "de"
+ GREEK = "el"
+ HEBREW = "he"
+ HINDI = "hi"
+ HUNGARIAN = "hu"
+ INDONESIAN = "id"
+ ITALIAN = "it"
+ JAPANESE = "ja"
+ KOREAN = "ko"
+ MALAY = "ms"
+ NORWEGIAN = "nb"
+ POLISH = "pl"
+ PORTUGUESE = "pt"
+ PORTUGUESE_BRAZIL = "pt-br"
+ ROMANIAN = "ro"
+ RUSSIAN = "ru"
+ SPANISH = "es"
+ SWEDISH = "sv"
+ TAGALOG = "tl"
+ THAI = "th"
+ TURKISH = "tr"
+ VIETNAMESE = "vi"
+
+
+class OTPSendResponse(BaseModel):
+ message: str
+ crumbs: Crumbs
+ otp_id: str
+
+
+class OTPResendResponse(BaseModel):
+ message: str
+ resent: bool
+
+
+class OTPVerifyResponse(BaseModel):
+ message: str
+ verified: bool
+
+
+class OTP:
+ def __init__(self, *, client: ApiClient, debug: bool = False) -> None:
+ self._client = client
+ self.debug = debug
+
+ def send(
+ self,
+ to: str,
+ /,
+ *,
+ name: str | None = None,
+ language: OTPLanguage = OTPLanguage.ENGLISH,
+ ) -> OTPSendResponse:
+ e164 = phonenumbers.format_number(phonenumbers.parse(to), phonenumbers.PhoneNumberFormat.E164)
+
+ response = self._client.post(
+ "/otp/new",
+ json={
+ "to": e164,
+ "language": language,
+ "name": name,
+ },
+ )
+ data = OTPSendResponse.model_validate_json(response.content)
+
+ if response.status_code != HTTPStatus.OK:
+ msg = f"Contiguity couldn't send your OTP. Received: {response.status_code} with reason: '{data.message}'"
+ raise ValueError(msg)
+ if self.debug:
+ print(f"Contiguity successfully sent your OTP to {to} with OTP ID {data.otp_id}")
+
+ return data
+
+ def resend(self, otp_id: str, /) -> OTPResendResponse:
+ response = self._client.post(
+ "/otp/resend",
+ json={
+ "otp_id": otp_id,
+ },
+ )
+ data = OTPResendResponse.model_validate_json(response.content)
+
+ if response.status_code != HTTPStatus.OK:
+ msg = (
+ "Contiguity couldn't resend your OTP."
+ f" Received: {response.status_code} with reason: '{data.message}'"
+ )
+ raise ValueError(msg)
+ if self.debug:
+ print(f"Contiguity resent your OTP ({id}) with status: {data.resent}")
+
+ return data
+
+ def verify(self, otp: int | str, /, *, otp_id: str) -> OTPVerifyResponse:
+ response = self._client.post(
+ "/otp/verify",
+ json={
+ "otp": str(otp),
+ "otp_id": otp_id,
+ },
+ )
+ data = OTPVerifyResponse.model_validate_json(response.content)
+
+ if response.status_code != HTTPStatus.OK:
+ msg = (
+ "Contiguity couldn't verify your OTP."
+ f" Received: {response.status_code} with reason: '{data.message}'"
+ )
+ raise ValueError(msg)
+ if self.debug:
+ print(f"Contiguity verified your OTP ({otp}) with status: {data.verified}")
+
+ return data
diff --git a/src/contiguity/py.typed b/src/contiguity/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/src/contiguity/quota.py b/src/contiguity/quota.py
new file mode 100644
index 0000000..53f0602
--- /dev/null
+++ b/src/contiguity/quota.py
@@ -0,0 +1,27 @@
+from http import HTTPStatus
+
+from typing_extensions import deprecated
+
+from ._client import ApiClient
+
+
+class Quota:
+ def __init__(self, *, client: ApiClient, debug: bool = False) -> None:
+ self._client = client
+ self.debug = debug
+
+ @deprecated("quota functionality will be removed in a future release")
+ def retrieve(self) -> dict:
+ response = self._client.get("/user/get/quota")
+ data = response.json()
+
+ if response.status_code != HTTPStatus.OK:
+ msg = (
+ "Contiguity had an issue finding your quota."
+ f" Received {response.status_code} with reason: '{data['message']}'"
+ )
+ raise ValueError(msg)
+ if self.debug:
+ print(f"Contiguity successfully found your quota. Data:\n{data}")
+
+ return data
diff --git a/src/contiguity/send.py b/src/contiguity/send.py
new file mode 100644
index 0000000..42cbf2f
--- /dev/null
+++ b/src/contiguity/send.py
@@ -0,0 +1,150 @@
+from __future__ import annotations
+
+from http import HTTPStatus
+from typing import TYPE_CHECKING, overload
+
+import phonenumbers
+from htmlmin import minify
+from pydantic import BaseModel
+
+from ._common import Crumbs # noqa: TCH001 Pydantic needs this to be outside of the TYPE_CHECKING block.
+
+if TYPE_CHECKING:
+ from ._client import ApiClient
+
+
+class TextResponse(BaseModel):
+ message: str
+ crumbs: Crumbs
+
+
+class EmailResponse(BaseModel):
+ message: str
+ crumbs: Crumbs
+ email_id: str
+
+
+class Send:
+ def __init__(self, *, client: ApiClient, debug: bool = False) -> None:
+ self._client = client
+ self.debug = debug
+
+ def text(self, to: str, message: str) -> TextResponse:
+ """
+ Send a text message.
+ Args:
+ to (str): The recipient's phone number.
+ message (str): The message to send.
+ Returns:
+ dict: The response object.
+ Raises:
+ ValueError: Raises an error if required fields are missing or sending the message fails.
+ """
+ try:
+ parsed_number = phonenumbers.parse(to, None)
+ if not phonenumbers.is_valid_number(parsed_number):
+ msg = "Contiguity requires phone numbers to follow the E.164 format. Formatting failed."
+ raise ValueError(msg)
+ except phonenumbers.NumberParseException as exc:
+ msg = "Contiguity requires phone numbers to follow the E.164 format. Parsing failed."
+ raise ValueError(msg) from exc
+
+ response = self._client.post(
+ "/send/text",
+ json={
+ "to": phonenumbers.format_number(parsed_number, phonenumbers.PhoneNumberFormat.E164),
+ "message": message,
+ },
+ )
+ data = TextResponse.model_validate_json(response.content)
+
+ if response.status_code != HTTPStatus.OK:
+ msg = (
+ "Contiguity couldn't send your message."
+ f" Received: {response.status_code} with reason: '{data.message}'"
+ )
+ raise ValueError(msg)
+ if self.debug:
+ print(f"Contiguity successfully sent your text to {to}. Crumbs:\n{data.crumbs}")
+
+ return data
+
+ @overload
+ def email(
+ self,
+ *,
+ to: str,
+ from_: str,
+ subject: str,
+ text: str,
+ reply_to: str = "",
+ cc: str = "",
+ ) -> EmailResponse: ...
+
+ @overload
+ def email(
+ self,
+ *,
+ to: str,
+ from_: str,
+ subject: str,
+ html: str,
+ reply_to: str = "",
+ cc: str = "",
+ ) -> EmailResponse: ...
+
+ def email( # noqa: PLR0913
+ self,
+ *,
+ to: str,
+ from_: str,
+ subject: str,
+ reply_to: str = "",
+ cc: str = "",
+ text: str | None = None,
+ html: str | None = None,
+ ) -> EmailResponse:
+ """
+ Send an email.
+ Args:
+ to (str): The recipient's email address.
+ from (str): The sender's name. The email address is selected automatically.
+ Configure at contiguity.co/dashboard
+ subject (str): The email subject.
+ text (str, optional): The plain text email body.
+ Provide one body, or HTML will be prioritized if both are present.
+ html (str, optional): The HTML email body. Provide one body.
+ reply_to (str, optional): The reply-to email address.
+ cc (str, optional): The CC email addresses.
+ Returns:
+ dict: The response object.
+ Raises:
+ ValueError: Raises an error if required fields are missing or sending the email fails.
+ """
+ email_payload = {
+ "to": to,
+ "from": from_,
+ "subject": subject,
+ "body": minify(html) if html else text,
+ "contentType": "html" if html else "text",
+ }
+
+ if reply_to:
+ email_payload["replyTo"] = reply_to
+
+ if cc:
+ email_payload["cc"] = cc
+
+ response = self._client.post("/send/email", json=email_payload)
+ data = EmailResponse.model_validate_json(response.content)
+
+ if response.status_code != HTTPStatus.OK:
+ msg = (
+ "Contiguity couldn't send your email."
+ f" Received: {response.status_code} with reason: '{data.message}'"
+ )
+ raise ValueError(msg)
+ if self.debug:
+ print(f"Contiguity successfully sent your email to {to}. Crumbs:\n{data.crumbs}")
+
+ return data
diff --git a/src/contiguity/template.py b/src/contiguity/template.py
new file mode 100644
index 0000000..32263aa
--- /dev/null
+++ b/src/contiguity/template.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from htmlmin import minify
+from typing_extensions import Never
+
+
+class Template:
+ def local(self, file_path: Path | str) -> str:
+ try:
+ file_path = Path(file_path)
+ return minify(file_path.read_text())
+ except OSError as exc:
+ msg = "reading files is not supported in the this environment"
+ raise ValueError(msg) from exc
+
+ async def online(self, file_path: str) -> Never:
+ # Coming soon
+ raise NotImplementedError
diff --git a/src/contiguity/verify.py b/src/contiguity/verify.py
new file mode 100644
index 0000000..413a783
--- /dev/null
+++ b/src/contiguity/verify.py
@@ -0,0 +1,16 @@
+import re
+
+import phonenumbers
+
+EMAIL_REGEX = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
+
+
+class Verify:
+ def number(self, number: str) -> bool:
+ try:
+ return phonenumbers.is_valid_number(phonenumbers.parse(number))
+ except phonenumbers.NumberParseException:
+ return False
+
+ def email(self, email: str) -> bool:
+ return EMAIL_REGEX.match(email) is not None
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..97f1811
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,6 @@
+import random
+import string
+
+
+def random_string(length: int = 10) -> str:
+ return "".join(random.choices(string.ascii_lowercase, k=length)) # noqa: S311
diff --git a/tests/base/__init__.py b/tests/base/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/base/test_async_base_dict_typed.py b/tests/base/test_async_base_dict_typed.py
new file mode 100644
index 0000000..fd65184
--- /dev/null
+++ b/tests/base/test_async_base_dict_typed.py
@@ -0,0 +1,171 @@
+# ruff: noqa: S101, S311, PLR2004
+import random
+from collections.abc import AsyncGenerator, Mapping
+from typing import Any
+
+import pytest
+from dotenv import load_dotenv
+from pydantic import JsonValue
+
+from contiguity import AsyncBase, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
+from tests import random_string
+
+load_dotenv()
+
+DictItemType = Mapping[str, JsonValue]
+
+
+def create_test_item(**kwargs: JsonValue) -> DictItemType:
+ kwargs.setdefault("key", "test_key")
+ kwargs.setdefault("field1", random.randint(1, 1000))
+ kwargs.setdefault("field2", random_string())
+ kwargs.setdefault("field3", 1)
+ kwargs.setdefault("field4", 0)
+ kwargs.setdefault("field5", ["foo", "bar"])
+ kwargs.setdefault("field6", [1, 2])
+ kwargs.setdefault("field7", {"foo": "bar"})
+ return kwargs
+
+
+@pytest.fixture
+async def base() -> AsyncGenerator[AsyncBase[DictItemType], Any]:
+ base = AsyncBase("test_base_dict_typed", item_type=DictItemType)
+ for item in (await base.query()).items:
+ await base.delete(str(item["key"]))
+ yield base
+ for item in (await base.query()).items:
+ await base.delete(str(item["key"]))
+
+
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ AsyncBase("", item_type=DictItemType)
+
+
+async def test_bad_key(base: AsyncBase[DictItemType]) -> None:
+ with pytest.raises(InvalidKeyError):
+ await base.get("")
+ with pytest.raises(InvalidKeyError):
+ await base.delete("")
+ with pytest.raises(InvalidKeyError):
+ await base.update({"foo": "bar"}, key="")
+
+
+async def test_get(base: AsyncBase[DictItemType]) -> None:
+ item = create_test_item()
+ await base.insert(item)
+ fetched_item = await base.get("test_key")
+ assert fetched_item == item
+
+
+async def test_get_nonexistent(base: AsyncBase[DictItemType]) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert await base.get("nonexistent_key") is None
+
+
+async def test_get_default(base: AsyncBase[DictItemType]) -> None:
+ for default_item in (None, "foo", 42, create_test_item()):
+ fetched_item = await base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
+async def test_delete(base: AsyncBase[DictItemType]) -> None:
+ item = create_test_item()
+ await base.insert(item)
+ await base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert await base.get("test_key") is None
+
+
+async def test_insert(base: AsyncBase[DictItemType]) -> None:
+ item = create_test_item()
+ inserted_item = await base.insert(item)
+ assert inserted_item == item
+
+
+async def test_insert_existing(base: AsyncBase[DictItemType]) -> None:
+ item = create_test_item()
+ await base.insert(item)
+ with pytest.raises(ItemConflictError):
+ await base.insert(item)
+
+
+async def test_put(base: AsyncBase[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(3)]
+ for _ in range(2):
+ response = await base.put(*items)
+ assert response == items
+
+
+async def test_put_empty(base: AsyncBase[DictItemType]) -> None:
+ items = []
+ response = await base.put(*items)
+ assert response == items
+
+
+async def test_put_too_many(base: AsyncBase[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ await base.put(*items)
+
+
+async def test_update(base: AsyncBase[DictItemType]) -> None:
+ item = {
+ "key": "test_key",
+ "field1": random_string(),
+ "field2": random_string(),
+ "field3": 1,
+ "field4": 0,
+ "field5": ["foo", "bar"],
+ "field6": [1, 2],
+ "field7": {"foo": "bar"},
+ }
+ await base.insert(item)
+ updated_item = await base.update(
+ {
+ "field1": "updated_value",
+ "field2": base.util.trim(),
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.prepend([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == {
+ "key": "test_key",
+ "field1": "updated_value",
+ "field3": item["field3"] + 2,
+ "field4": item["field4"] - 2,
+ "field5": [*item["field5"], "baz"],
+ "field6": [3, 4, *item["field6"]],
+ "field7": item["field7"],
+ }
+
+
+async def test_update_nonexistent(base: AsyncBase[DictItemType]) -> None:
+ with pytest.raises(ItemNotFoundError):
+ await base.update({"foo": "bar"}, key=random_string())
+
+
+async def test_update_empty(base: AsyncBase[DictItemType]) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ await base.update({}, key="test_key")
+
+
+async def test_query_empty(base: AsyncBase[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ await base.put(*items)
+ response = await base.query()
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+async def test_query(base: AsyncBase[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ await base.put(*items)
+ response = await base.query({"field1?gt": 1})
+ assert response == QueryResponse(
+ count=3,
+ last_key=None,
+ items=[item for item in items if isinstance(item["field1"], int) and item["field1"] > 1],
+ )
diff --git a/tests/base/test_async_base_dict_untyped.py b/tests/base/test_async_base_dict_untyped.py
new file mode 100644
index 0000000..1de7a6f
--- /dev/null
+++ b/tests/base/test_async_base_dict_untyped.py
@@ -0,0 +1,165 @@
+# ruff: noqa: S101, S311, PLR2004
+import random
+from collections.abc import AsyncGenerator
+from typing import Any
+
+import pytest
+from dotenv import load_dotenv
+from pydantic import JsonValue
+
+from contiguity import AsyncBase, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
+from tests import random_string
+
+load_dotenv()
+
+
+def create_test_item(**kwargs: JsonValue) -> dict:
+ kwargs.setdefault("key", "test_key")
+ kwargs.setdefault("field1", random.randint(1, 1000))
+ kwargs.setdefault("field2", random_string())
+ kwargs.setdefault("field3", 1)
+ kwargs.setdefault("field4", 0)
+ kwargs.setdefault("field5", ["foo", "bar"])
+ kwargs.setdefault("field6", [1, 2])
+ kwargs.setdefault("field7", {"foo": "bar"})
+ return kwargs
+
+
+@pytest.fixture
+async def base() -> AsyncGenerator[AsyncBase, Any]:
+ base = AsyncBase("test_base_dict_untyped")
+ for item in (await base.query()).items:
+ await base.delete(str(item["key"]))
+ yield base
+ for item in (await base.query()).items:
+ await base.delete(str(item["key"]))
+
+
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ AsyncBase("")
+
+
+async def test_bad_key(base: AsyncBase) -> None:
+ with pytest.raises(InvalidKeyError):
+ await base.get("")
+ with pytest.raises(InvalidKeyError):
+ await base.delete("")
+ with pytest.raises(InvalidKeyError):
+ await base.update({"foo": "bar"}, key="")
+
+
+async def test_get(base: AsyncBase) -> None:
+ item = create_test_item()
+ await base.insert(item)
+ fetched_item = await base.get("test_key")
+ assert fetched_item == item
+
+
+async def test_get_nonexistent(base: AsyncBase) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert await base.get("nonexistent_key") is None
+
+
+async def test_get_default(base: AsyncBase) -> None:
+ for default_item in (None, "foo", 42, create_test_item()):
+ fetched_item = await base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
+async def test_delete(base: AsyncBase) -> None:
+ item = create_test_item()
+ await base.insert(item)
+ await base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert await base.get("test_key") is None
+
+
+async def test_insert(base: AsyncBase) -> None:
+ item = create_test_item()
+ inserted_item = await base.insert(item)
+ assert inserted_item == item
+
+
+async def test_insert_existing(base: AsyncBase) -> None:
+ item = create_test_item()
+ await base.insert(item)
+ with pytest.raises(ItemConflictError):
+ await base.insert(item)
+
+
+async def test_put(base: AsyncBase) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(3)]
+ for _ in range(2):
+ response = await base.put(*items)
+ assert response == items
+
+
+async def test_put_empty(base: AsyncBase) -> None:
+ items = []
+ response = await base.put(*items)
+ assert response == items
+
+
+async def test_put_too_many(base: AsyncBase) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ await base.put(*items)
+
+
+async def test_update(base: AsyncBase) -> None:
+ item = {
+ "key": "test_key",
+ "field1": random_string(),
+ "field2": random_string(),
+ "field3": 1,
+ "field4": 0,
+ "field5": ["foo", "bar"],
+ "field6": [1, 2],
+ "field7": {"foo": "bar"},
+ }
+ await base.insert(item)
+ updated_item = await base.update(
+ {
+ "field1": "updated_value",
+ "field2": base.util.trim(),
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.prepend([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == {
+ "key": "test_key",
+ "field1": "updated_value",
+ "field3": item["field3"] + 2,
+ "field4": item["field4"] - 2,
+ "field5": [*item["field5"], "baz"],
+ "field6": [3, 4, *item["field6"]],
+ "field7": item["field7"],
+ }
+
+
+async def test_update_nonexistent(base: AsyncBase) -> None:
+ with pytest.raises(ItemNotFoundError):
+ await base.update({"foo": "bar"}, key=random_string())
+
+
+async def test_update_empty(base: AsyncBase) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ await base.update({}, key="test_key")
+
+
+async def test_query_empty(base: AsyncBase) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ await base.put(*items)
+ response = await base.query()
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+async def test_query(base: AsyncBase) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ await base.put(*items)
+ response = await base.query({"field1?gt": 1})
+ assert response == QueryResponse(count=3, last_key=None, items=[item for item in items if item["field1"] > 1])
diff --git a/tests/base/test_async_base_model.py b/tests/base/test_async_base_model.py
new file mode 100644
index 0000000..e0704de
--- /dev/null
+++ b/tests/base/test_async_base_model.py
@@ -0,0 +1,156 @@
+# ruff: noqa: S101, S311, PLR2004
+import random
+from collections.abc import AsyncGenerator
+from typing import Any
+
+import pytest
+from dotenv import load_dotenv
+from pydantic import BaseModel
+
+from contiguity import AsyncBase, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
+from tests import random_string
+
+load_dotenv()
+
+
+class TestItemModel(BaseModel):
+ key: str = "test_key"
+ field1: int = random.randint(1, 1000)
+ field2: str = random_string()
+ field3: int = 1
+ field4: int = 0
+ field5: list[str] = ["foo", "bar"]
+ field6: list[int] = [1, 2]
+ field7: dict[str, str] = {"foo": "bar"}
+
+
+@pytest.fixture
+async def base() -> AsyncGenerator[AsyncBase[TestItemModel], Any]:
+ base = AsyncBase("test_base_model", item_type=TestItemModel)
+ for item in (await base.query()).items:
+ await base.delete(item.key)
+ yield base
+ for item in (await base.query()).items:
+ await base.delete(item.key)
+
+
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ AsyncBase("", item_type=TestItemModel)
+
+
+async def test_bad_key(base: AsyncBase[TestItemModel]) -> None:
+ with pytest.raises(InvalidKeyError):
+ await base.get("")
+ with pytest.raises(InvalidKeyError):
+ await base.delete("")
+ with pytest.raises(InvalidKeyError):
+ await base.update({"foo": "bar"}, key="")
+
+
+async def test_get(base: AsyncBase[TestItemModel]) -> None:
+ item = TestItemModel()
+ await base.insert(item)
+ fetched_item = await base.get("test_key")
+ assert fetched_item == item
+
+
+async def test_get_nonexistent(base: AsyncBase[TestItemModel]) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert await base.get("nonexistent_key") is None
+
+
+async def test_get_default(base: AsyncBase[TestItemModel]) -> None:
+ for default_item in (None, "foo", 42, TestItemModel()):
+ fetched_item = await base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
+async def test_delete(base: AsyncBase[TestItemModel]) -> None:
+ item = TestItemModel()
+ await base.insert(item)
+ await base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert await base.get("test_key") is None
+
+
+async def test_insert(base: AsyncBase[TestItemModel]) -> None:
+ item = TestItemModel()
+ inserted_item = await base.insert(item)
+ assert inserted_item == item
+
+
+async def test_insert_existing(base: AsyncBase[TestItemModel]) -> None:
+ item = TestItemModel()
+ await base.insert(item)
+ with pytest.raises(ItemConflictError):
+ await base.insert(item)
+
+
+async def test_put(base: AsyncBase[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}") for i in range(3)]
+ for _ in range(2):
+ response = await base.put(*items)
+ assert response == items
+
+
+async def test_put_empty(base: AsyncBase[TestItemModel]) -> None:
+ items = []
+ response = await base.put(*items)
+ assert response == items
+
+
+async def test_put_too_many(base: AsyncBase[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ await base.put(*items)
+
+
+async def test_update(base: AsyncBase[TestItemModel]) -> None:
+ item = TestItemModel()
+ await base.insert(item)
+ updated_item = await base.update(
+ {
+ "field1": base.util.trim(),
+ "field2": "updated_value",
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.prepend([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == TestItemModel(
+ key="test_key",
+ field1=updated_item.field1,
+ field2="updated_value",
+ field3=item.field3 + 2,
+ field4=item.field4 - 2,
+ field5=[*item.field5, "baz"],
+ field6=[3, 4, *item.field6],
+ field7=item.field7,
+ )
+
+
+async def test_update_nonexistent(base: AsyncBase[TestItemModel]) -> None:
+ with pytest.raises(ItemNotFoundError):
+ await base.update({"foo": "bar"}, key=random_string())
+
+
+async def test_update_empty(base: AsyncBase[TestItemModel]) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ await base.update({}, key="test_key")
+
+
+async def test_query_empty(base: AsyncBase[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}", field1=i) for i in range(5)]
+ await base.put(*items)
+ response = await base.query()
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+async def test_query(base: AsyncBase[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}", field1=i) for i in range(5)]
+ await base.put(*items)
+ response = await base.query({"field1?gt": 1})
+ assert response == QueryResponse(count=3, last_key=None, items=[item for item in items if item.field1 > 1])
diff --git a/tests/base/test_async_base_typeddict.py b/tests/base/test_async_base_typeddict.py
new file mode 100644
index 0000000..627ccf1
--- /dev/null
+++ b/tests/base/test_async_base_typeddict.py
@@ -0,0 +1,183 @@
+# ruff: noqa: S101, S311, PLR2004
+from __future__ import annotations
+
+import random
+from typing import TYPE_CHECKING, Any
+
+import pytest
+from dotenv import load_dotenv
+from typing_extensions import TypedDict
+
+from contiguity import AsyncBase, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
+from tests import random_string
+
+if TYPE_CHECKING:
+ from collections.abc import AsyncGenerator
+
+load_dotenv()
+
+
+class TestItemDict(TypedDict):
+ key: str
+ field1: int
+ field2: str
+ field3: int
+ field4: int
+ field5: list[str]
+ field6: list[int]
+ field7: dict[str, str]
+
+
+def create_test_item( # noqa: PLR0913
+ key: str = "test_key",
+ field1: int = random.randint(1, 1000),
+ field2: str = random_string(),
+ field3: int = 1,
+ field4: int = 0,
+ field5: list[str] | None = None,
+ field6: list[int] | None = None,
+ field7: dict[str, str] | None = None,
+) -> TestItemDict:
+ return TestItemDict(
+ key=key,
+ field1=field1,
+ field2=field2,
+ field3=field3,
+ field4=field4,
+ field5=field5 or ["foo", "bar"],
+ field6=field6 or [1, 2],
+ field7=field7 or {"foo": "bar"},
+ )
+
+
+@pytest.fixture
+async def base() -> AsyncGenerator[AsyncBase[TestItemDict], Any]:
+ base = AsyncBase("test_base_typeddict", item_type=TestItemDict)
+ for item in (await base.query()).items:
+ await base.delete(item["key"])
+ yield base
+ for item in (await base.query()).items:
+ await base.delete(item["key"])
+
+
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ AsyncBase("", item_type=TestItemDict)
+
+
+async def test_bad_key(base: AsyncBase[TestItemDict]) -> None:
+ with pytest.raises(InvalidKeyError):
+ await base.get("")
+ with pytest.raises(InvalidKeyError):
+ await base.delete("")
+ with pytest.raises(InvalidKeyError):
+ await base.update({"foo": "bar"}, key="")
+
+
+async def test_get(base: AsyncBase[TestItemDict]) -> None:
+ item = create_test_item()
+ await base.insert(item)
+ fetched_item = await base.get("test_key")
+ assert fetched_item == item
+
+
+async def test_get_nonexistent(base: AsyncBase[TestItemDict]) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert await base.get("nonexistent_key") is None
+
+
+async def test_get_default(base: AsyncBase[TestItemDict]) -> None:
+ for default_item in (None, "foo", 42, create_test_item()):
+ fetched_item = await base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
+async def test_delete(base: AsyncBase[TestItemDict]) -> None:
+ item = create_test_item()
+ await base.insert(item)
+ await base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert await base.get("test_key") is None
+
+
+async def test_insert(base: AsyncBase[TestItemDict]) -> None:
+ item = create_test_item()
+ inserted_item = await base.insert(item)
+ assert inserted_item == item
+
+
+async def test_insert_existing(base: AsyncBase[TestItemDict]) -> None:
+ item = create_test_item()
+ await base.insert(item)
+ with pytest.raises(ItemConflictError):
+ await base.insert(item)
+
+
+async def test_put(base: AsyncBase[TestItemDict]) -> None:
+ items = [create_test_item(f"test_key_{i}") for i in range(3)]
+ for _ in range(2):
+ response = await base.put(*items)
+ assert response == items
+
+
+async def test_put_empty(base: AsyncBase[TestItemDict]) -> None:
+ items = []
+ response = await base.put(*items)
+ assert response == items
+
+
+async def test_put_too_many(base: AsyncBase[TestItemDict]) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ await base.put(*items)
+
+
+async def test_update(base: AsyncBase[TestItemDict]) -> None:
+ item = create_test_item()
+ await base.insert(item)
+ updated_item = await base.update(
+ {
+ # Trim will not pass type validation when using TypedDict
+ # because TypedDict does not support default values.
+ "field2": "updated_value",
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.prepend([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == TestItemDict(
+ key="test_key",
+ field1=item["field1"],
+ field2="updated_value",
+ field3=item["field3"] + 2,
+ field4=item["field4"] - 2,
+ field5=[*item["field5"], "baz"],
+ field6=[3, 4, *item["field6"]],
+ field7=item["field7"],
+ )
+
+
+async def test_update_nonexistent(base: AsyncBase[TestItemDict]) -> None:
+ with pytest.raises(ItemNotFoundError):
+ await base.update({"foo": "bar"}, key=random_string())
+
+
+async def test_update_empty(base: AsyncBase[TestItemDict]) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ await base.update({}, key="test_key")
+
+
+async def test_query_empty(base: AsyncBase[TestItemDict]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ await base.put(*items)
+ response = await base.query()
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+async def test_query(base: AsyncBase[TestItemDict]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ await base.put(*items)
+ response = await base.query({"field1?gt": 1})
+ assert response == QueryResponse(count=3, last_key=None, items=[item for item in items if item["field1"] > 1])
diff --git a/tests/base/test_base_dict_typed.py b/tests/base/test_base_dict_typed.py
new file mode 100644
index 0000000..1c3553a
--- /dev/null
+++ b/tests/base/test_base_dict_typed.py
@@ -0,0 +1,171 @@
+# ruff: noqa: S101, S311, PLR2004
+import random
+from collections.abc import Generator, Mapping
+from typing import Any
+
+import pytest
+from dotenv import load_dotenv
+from pydantic import JsonValue
+
+from contiguity import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
+from tests import random_string
+
+load_dotenv()
+
+DictItemType = Mapping[str, JsonValue]
+
+
+def create_test_item(**kwargs: JsonValue) -> DictItemType:
+ kwargs.setdefault("key", "test_key")
+ kwargs.setdefault("field1", random.randint(1, 1000))
+ kwargs.setdefault("field2", random_string())
+ kwargs.setdefault("field3", 1)
+ kwargs.setdefault("field4", 0)
+ kwargs.setdefault("field5", ["foo", "bar"])
+ kwargs.setdefault("field6", [1, 2])
+ kwargs.setdefault("field7", {"foo": "bar"})
+ return kwargs
+
+
+@pytest.fixture
+def base() -> Generator[Base[DictItemType], Any, None]:
+ base = Base("test_base_dict_typed", item_type=DictItemType)
+ for item in base.query().items:
+ base.delete(str(item["key"]))
+ yield base
+ for item in base.query().items:
+ base.delete(str(item["key"]))
+
+
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ Base("", item_type=DictItemType)
+
+
+def test_bad_key(base: Base[DictItemType]) -> None:
+ with pytest.raises(InvalidKeyError):
+ base.get("")
+ with pytest.raises(InvalidKeyError):
+ base.delete("")
+ with pytest.raises(InvalidKeyError):
+ base.update({"foo": "bar"}, key="")
+
+
+def test_get(base: Base[DictItemType]) -> None:
+ item = create_test_item()
+ base.insert(item)
+ fetched_item = base.get("test_key")
+ assert fetched_item == item
+
+
+def test_get_nonexistent(base: Base[DictItemType]) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert base.get("nonexistent_key") is None
+
+
+def test_get_default(base: Base[DictItemType]) -> None:
+ for default_item in (None, "foo", 42, create_test_item()):
+ fetched_item = base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
+def test_delete(base: Base[DictItemType]) -> None:
+ item = create_test_item()
+ base.insert(item)
+ base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert base.get("test_key") is None
+
+
+def test_insert(base: Base[DictItemType]) -> None:
+ item = create_test_item()
+ inserted_item = base.insert(item)
+ assert inserted_item == item
+
+
+def test_insert_existing(base: Base[DictItemType]) -> None:
+ item = create_test_item()
+ base.insert(item)
+ with pytest.raises(ItemConflictError):
+ base.insert(item)
+
+
+def test_put(base: Base[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(3)]
+ for _ in range(2):
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_empty(base: Base[DictItemType]) -> None:
+ items = []
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_too_many(base: Base[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ base.put(*items)
+
+
+def test_update(base: Base[DictItemType]) -> None:
+ item = {
+ "key": "test_key",
+ "field1": random_string(),
+ "field2": random_string(),
+ "field3": 1,
+ "field4": 0,
+ "field5": ["foo", "bar"],
+ "field6": [1, 2],
+ "field7": {"foo": "bar"},
+ }
+ base.insert(item)
+ updated_item = base.update(
+ {
+ "field1": "updated_value",
+ "field2": base.util.trim(),
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.prepend([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == {
+ "key": "test_key",
+ "field1": "updated_value",
+ "field3": item["field3"] + 2,
+ "field4": item["field4"] - 2,
+ "field5": [*item["field5"], "baz"],
+ "field6": [3, 4, *item["field6"]],
+ "field7": item["field7"],
+ }
+
+
+def test_update_nonexistent(base: Base[DictItemType]) -> None:
+ with pytest.raises(ItemNotFoundError):
+ base.update({"foo": "bar"}, key=random_string())
+
+
+def test_update_empty(base: Base[DictItemType]) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ base.update({}, key="test_key")
+
+
+def test_query_empty(base: Base[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query()
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+def test_query(base: Base[DictItemType]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query({"field1?gt": 1})
+ assert response == QueryResponse(
+ count=3,
+ last_key=None,
+ items=[item for item in items if isinstance(item["field1"], int) and item["field1"] > 1],
+ )
diff --git a/tests/base/test_base_dict_untyped.py b/tests/base/test_base_dict_untyped.py
new file mode 100644
index 0000000..37bce0c
--- /dev/null
+++ b/tests/base/test_base_dict_untyped.py
@@ -0,0 +1,165 @@
+# ruff: noqa: S101, S311, PLR2004
+import random
+from collections.abc import Generator
+from typing import Any
+
+import pytest
+from dotenv import load_dotenv
+from pydantic import JsonValue
+
+from contiguity import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
+from tests import random_string
+
+load_dotenv()
+
+
+def create_test_item(**kwargs: JsonValue) -> dict:
+ kwargs.setdefault("key", "test_key")
+ kwargs.setdefault("field1", random.randint(1, 1000))
+ kwargs.setdefault("field2", random_string())
+ kwargs.setdefault("field3", 1)
+ kwargs.setdefault("field4", 0)
+ kwargs.setdefault("field5", ["foo", "bar"])
+ kwargs.setdefault("field6", [1, 2])
+ kwargs.setdefault("field7", {"foo": "bar"})
+ return kwargs
+
+
+@pytest.fixture
+def base() -> Generator[Base, Any, None]:
+ base = Base("test_base_dict_untyped")
+ for item in base.query().items:
+ base.delete(str(item["key"]))
+ yield base
+ for item in base.query().items:
+ base.delete(str(item["key"]))
+
+
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ Base("")
+
+
+def test_bad_key(base: Base) -> None:
+ with pytest.raises(InvalidKeyError):
+ base.get("")
+ with pytest.raises(InvalidKeyError):
+ base.delete("")
+ with pytest.raises(InvalidKeyError):
+ base.update({"foo": "bar"}, key="")
+
+
+def test_get(base: Base) -> None:
+ item = create_test_item()
+ base.insert(item)
+ fetched_item = base.get("test_key")
+ assert fetched_item == item
+
+
+def test_get_nonexistent(base: Base) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert base.get("nonexistent_key") is None
+
+
+def test_get_default(base: Base) -> None:
+ for default_item in (None, "foo", 42, create_test_item()):
+ fetched_item = base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
+def test_delete(base: Base) -> None:
+ item = create_test_item()
+ base.insert(item)
+ base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert base.get("test_key") is None
+
+
+def test_insert(base: Base) -> None:
+ item = create_test_item()
+ inserted_item = base.insert(item)
+ assert inserted_item == item
+
+
+def test_insert_existing(base: Base) -> None:
+ item = create_test_item()
+ base.insert(item)
+ with pytest.raises(ItemConflictError):
+ base.insert(item)
+
+
+def test_put(base: Base) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(3)]
+ for _ in range(2):
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_empty(base: Base) -> None:
+ items = []
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_too_many(base: Base) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ base.put(*items)
+
+
+def test_update(base: Base) -> None:
+ item = {
+ "key": "test_key",
+ "field1": random_string(),
+ "field2": random_string(),
+ "field3": 1,
+ "field4": 0,
+ "field5": ["foo", "bar"],
+ "field6": [1, 2],
+ "field7": {"foo": "bar"},
+ }
+ base.insert(item)
+ updated_item = base.update(
+ {
+ "field1": "updated_value",
+ "field2": base.util.trim(),
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.prepend([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == {
+ "key": "test_key",
+ "field1": "updated_value",
+ "field3": item["field3"] + 2,
+ "field4": item["field4"] - 2,
+ "field5": [*item["field5"], "baz"],
+ "field6": [3, 4, *item["field6"]],
+ "field7": item["field7"],
+ }
+
+
+def test_update_nonexistent(base: Base) -> None:
+ with pytest.raises(ItemNotFoundError):
+ base.update({"foo": "bar"}, key=random_string())
+
+
+def test_update_empty(base: Base) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ base.update({}, key="test_key")
+
+
+def test_query_empty(base: Base) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query()
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+def test_query(base: Base) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query({"field1?gt": 1})
+ assert response == QueryResponse(count=3, last_key=None, items=[item for item in items if item["field1"] > 1])
diff --git a/tests/base/test_base_model.py b/tests/base/test_base_model.py
new file mode 100644
index 0000000..d299318
--- /dev/null
+++ b/tests/base/test_base_model.py
@@ -0,0 +1,156 @@
+# ruff: noqa: S101, S311, PLR2004
+import random
+from collections.abc import Generator
+from typing import Any
+
+import pytest
+from dotenv import load_dotenv
+from pydantic import BaseModel
+
+from contiguity import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
+from tests import random_string
+
+load_dotenv()
+
+
+class TestItemModel(BaseModel):
+ key: str = "test_key"
+ field1: int = random.randint(1, 1000)
+ field2: str = random_string()
+ field3: int = 1
+ field4: int = 0
+ field5: list[str] = ["foo", "bar"]
+ field6: list[int] = [1, 2]
+ field7: dict[str, str] = {"foo": "bar"}
+
+
+@pytest.fixture
+def base() -> Generator[Base[TestItemModel], Any, None]:
+ base = Base("test_base_model", item_type=TestItemModel)
+ for item in base.query().items:
+ base.delete(item.key)
+ yield base
+ for item in base.query().items:
+ base.delete(item.key)
+
+
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ Base("", item_type=TestItemModel)
+
+
+def test_bad_key(base: Base[TestItemModel]) -> None:
+ with pytest.raises(InvalidKeyError):
+ base.get("")
+ with pytest.raises(InvalidKeyError):
+ base.delete("")
+ with pytest.raises(InvalidKeyError):
+ base.update({"foo": "bar"}, key="")
+
+
+def test_get(base: Base[TestItemModel]) -> None:
+ item = TestItemModel()
+ base.insert(item)
+ fetched_item = base.get("test_key")
+ assert fetched_item == item
+
+
+def test_get_nonexistent(base: Base[TestItemModel]) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert base.get("nonexistent_key") is None
+
+
+def test_get_default(base: Base[TestItemModel]) -> None:
+ for default_item in (None, "foo", 42, TestItemModel()):
+ fetched_item = base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
+def test_delete(base: Base[TestItemModel]) -> None:
+ item = TestItemModel()
+ base.insert(item)
+ base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert base.get("test_key") is None
+
+
+def test_insert(base: Base[TestItemModel]) -> None:
+ item = TestItemModel()
+ inserted_item = base.insert(item)
+ assert inserted_item == item
+
+
+def test_insert_existing(base: Base[TestItemModel]) -> None:
+ item = TestItemModel()
+ base.insert(item)
+ with pytest.raises(ItemConflictError):
+ base.insert(item)
+
+
+def test_put(base: Base[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}") for i in range(3)]
+ for _ in range(2):
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_empty(base: Base[TestItemModel]) -> None:
+ items = []
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_too_many(base: Base[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ base.put(*items)
+
+
+def test_update(base: Base[TestItemModel]) -> None:
+ item = TestItemModel()
+ base.insert(item)
+ updated_item = base.update(
+ {
+ "field1": base.util.trim(),
+ "field2": "updated_value",
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.prepend([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == TestItemModel(
+ key="test_key",
+ field1=updated_item.field1,
+ field2="updated_value",
+ field3=item.field3 + 2,
+ field4=item.field4 - 2,
+ field5=[*item.field5, "baz"],
+ field6=[3, 4, *item.field6],
+ field7=item.field7,
+ )
+
+
+def test_update_nonexistent(base: Base[TestItemModel]) -> None:
+ with pytest.raises(ItemNotFoundError):
+ base.update({"foo": "bar"}, key=random_string())
+
+
+def test_update_empty(base: Base[TestItemModel]) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ base.update({}, key="test_key")
+
+
+def test_query_empty(base: Base[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query()
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+def test_query(base: Base[TestItemModel]) -> None:
+ items = [TestItemModel(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query({"field1?gt": 1})
+ assert response == QueryResponse(count=3, last_key=None, items=[item for item in items if item.field1 > 1])
diff --git a/tests/base/test_base_typeddict.py b/tests/base/test_base_typeddict.py
new file mode 100644
index 0000000..39e4003
--- /dev/null
+++ b/tests/base/test_base_typeddict.py
@@ -0,0 +1,183 @@
+# ruff: noqa: S101, S311, PLR2004
+from __future__ import annotations
+
+import random
+from typing import TYPE_CHECKING, Any
+
+import pytest
+from dotenv import load_dotenv
+from typing_extensions import TypedDict
+
+from contiguity import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse
+from tests import random_string
+
+if TYPE_CHECKING:
+ from collections.abc import Generator
+
+load_dotenv()
+
+
+class TestItemDict(TypedDict):
+ key: str
+ field1: int
+ field2: str
+ field3: int
+ field4: int
+ field5: list[str]
+ field6: list[int]
+ field7: dict[str, str]
+
+
+def create_test_item( # noqa: PLR0913
+ key: str = "test_key",
+ field1: int = random.randint(1, 1000),
+ field2: str = random_string(),
+ field3: int = 1,
+ field4: int = 0,
+ field5: list[str] | None = None,
+ field6: list[int] | None = None,
+ field7: dict[str, str] | None = None,
+) -> TestItemDict:
+ return TestItemDict(
+ key=key,
+ field1=field1,
+ field2=field2,
+ field3=field3,
+ field4=field4,
+ field5=field5 or ["foo", "bar"],
+ field6=field6 or [1, 2],
+ field7=field7 or {"foo": "bar"},
+ )
+
+
+@pytest.fixture
+def base() -> Generator[Base[TestItemDict], Any, None]:
+ base = Base("test_base_typeddict", item_type=TestItemDict)
+ for item in base.query().items:
+ base.delete(item["key"])
+ yield base
+ for item in base.query().items:
+ base.delete(item["key"])
+
+
+def test_bad_base_name() -> None:
+ with pytest.raises(ValueError, match="invalid Base name ''"):
+ Base("", item_type=TestItemDict)
+
+
+def test_bad_key(base: Base[TestItemDict]) -> None:
+ with pytest.raises(InvalidKeyError):
+ base.get("")
+ with pytest.raises(InvalidKeyError):
+ base.delete("")
+ with pytest.raises(InvalidKeyError):
+ base.update({"foo": "bar"}, key="")
+
+
+def test_get(base: Base[TestItemDict]) -> None:
+ item = create_test_item()
+ base.insert(item)
+ fetched_item = base.get("test_key")
+ assert fetched_item == item
+
+
+def test_get_nonexistent(base: Base[TestItemDict]) -> None:
+ with pytest.warns(DeprecationWarning):
+ assert base.get("nonexistent_key") is None
+
+
+def test_get_default(base: Base[TestItemDict]) -> None:
+ for default_item in (None, "foo", 42, create_test_item()):
+ fetched_item = base.get("nonexistent_key", default=default_item)
+ assert fetched_item == default_item
+
+
+def test_delete(base: Base[TestItemDict]) -> None:
+ item = create_test_item()
+ base.insert(item)
+ base.delete("test_key")
+ with pytest.warns(DeprecationWarning):
+ assert base.get("test_key") is None
+
+
+def test_insert(base: Base[TestItemDict]) -> None:
+ item = create_test_item()
+ inserted_item = base.insert(item)
+ assert inserted_item == item
+
+
+def test_insert_existing(base: Base[TestItemDict]) -> None:
+ item = create_test_item()
+ base.insert(item)
+ with pytest.raises(ItemConflictError):
+ base.insert(item)
+
+
+def test_put(base: Base[TestItemDict]) -> None:
+ items = [create_test_item(f"test_key_{i}") for i in range(3)]
+ for _ in range(2):
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_empty(base: Base[TestItemDict]) -> None:
+ items = []
+ response = base.put(*items)
+ assert response == items
+
+
+def test_put_too_many(base: Base[TestItemDict]) -> None:
+ items = [create_test_item(key=f"test_key_{i}") for i in range(base.PUT_LIMIT + 1)]
+ with pytest.raises(ValueError, match=f"cannot put more than {base.PUT_LIMIT} items at a time"):
+ base.put(*items)
+
+
+def test_update(base: Base[TestItemDict]) -> None:
+ item = create_test_item()
+ base.insert(item)
+ updated_item = base.update(
+ {
+ # Trim will not pass type validation when using TypedDict
+ # because TypedDict does not support default values.
+ "field2": "updated_value",
+ "field3": base.util.increment(2),
+ "field4": base.util.increment(-2),
+ "field5": base.util.append("baz"),
+ "field6": base.util.prepend([3, 4]),
+ },
+ key="test_key",
+ )
+ assert updated_item == TestItemDict(
+ key="test_key",
+ field1=item["field1"],
+ field2="updated_value",
+ field3=item["field3"] + 2,
+ field4=item["field4"] - 2,
+ field5=[*item["field5"], "baz"],
+ field6=[3, 4, *item["field6"]],
+ field7=item["field7"],
+ )
+
+
+def test_update_nonexistent(base: Base[TestItemDict]) -> None:
+ with pytest.raises(ItemNotFoundError):
+ base.update({"foo": "bar"}, key=random_string())
+
+
+def test_update_empty(base: Base[TestItemDict]) -> None:
+ with pytest.raises(ValueError, match="no updates provided"):
+ base.update({}, key="test_key")
+
+
+def test_query_empty(base: Base[TestItemDict]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query()
+ assert response == QueryResponse(count=5, last_key=None, items=items)
+
+
+def test_query(base: Base[TestItemDict]) -> None:
+ items = [create_test_item(key=f"test_key_{i}", field1=i) for i in range(5)]
+ base.put(*items)
+ response = base.query({"field1?gt": 1})
+ assert response == QueryResponse(count=3, last_key=None, items=[item for item in items if item["field1"] > 1])
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..0d553a6
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,575 @@
+version = 1
+requires-python = ">=3.9"
+resolution-markers = [
+ "python_full_version < '3.13'",
+ "python_full_version >= '3.13'",
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
+]
+
+[[package]]
+name = "anyio"
+version = "4.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "exceptiongroup", marker = "python_full_version < '3.11'" },
+ { name = "idna" },
+ { name = "sniffio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/49/f3f17ec11c4a91fe79275c426658e509b07547f874b14c1a526d86a83fc8/anyio-4.6.0.tar.gz", hash = "sha256:137b4559cbb034c477165047febb6ff83f390fc3b20bf181c1fc0a728cb8beeb", size = 170983 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/ef/7a4f225581a0d7886ea28359179cb861d7fbcdefad29663fc1167b86f69f/anyio-4.6.0-py3-none-any.whl", hash = "sha256:c7d2e9d63e31599eeb636c8c5c03a7e108d73b345f064f1c19fdc87b79036a9a", size = 89631 },
+]
+
+[[package]]
+name = "certifi"
+version = "2024.8.30"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 },
+]
+
+[[package]]
+name = "cfgv"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+]
+
+[[package]]
+name = "contiguity"
+version = "2.0.0"
+source = { editable = "." }
+dependencies = [
+ { name = "htmlmin" },
+ { name = "httpx" },
+ { name = "phonenumbers" },
+ { name = "pydantic" },
+ { name = "typing-extensions" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pre-commit" },
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "pytest-cov" },
+ { name = "python-dotenv" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "htmlmin", specifier = ">=0.1.12" },
+ { name = "httpx", specifier = ">=0.27.2" },
+ { name = "phonenumbers", specifier = ">=8.13.47,<9.0.0" },
+ { name = "pydantic", specifier = ">=2.9.0,<3.0.0" },
+ { name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "pre-commit", specifier = "~=3.8.0" },
+ { name = "pytest", specifier = "~=8.3.3" },
+ { name = "pytest-asyncio", specifier = "~=0.24.0" },
+ { name = "pytest-cov", specifier = "~=5.0.0" },
+ { name = "python-dotenv", specifier = "~=1.0.1" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.6.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/60/e781e8302e7b28f21ce06e30af077f856aa2cb4cf2253287dae9a593d509/coverage-7.6.2.tar.gz", hash = "sha256:a5f81e68aa62bc0cfca04f7b19eaa8f9c826b53fc82ab9e2121976dc74f131f3", size = 797872 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/14/fb75c01b8427fb567c90ce920c90ed2bd314ad6960d54e8b377928607fd1/coverage-7.6.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c9df1950fb92d49970cce38100d7e7293c84ed3606eaa16ea0b6bc27175bb667", size = 206561 },
+ { url = "https://files.pythonhosted.org/packages/93/b4/dcbf15f5583507415d0a78ce206e19d76699f1161e8b1ff6e1a21e9f9743/coverage-7.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:24500f4b0e03aab60ce575c85365beab64b44d4db837021e08339f61d1fbfe52", size = 206994 },
+ { url = "https://files.pythonhosted.org/packages/47/ee/57d607e14479fb760721ea1784608ade532665934bd75f260b250dc6c877/coverage-7.6.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a663b180b6669c400b4630a24cc776f23a992d38ce7ae72ede2a397ce6b0f170", size = 235429 },
+ { url = "https://files.pythonhosted.org/packages/76/e1/cd263fd750fdb115aab11a086e3584d99d46fca1f201b5493cc3972aea28/coverage-7.6.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfde025e2793a22efe8c21f807d276bd1d6a4bcc5ba6f19dbdfc4e7a12160909", size = 233329 },
+ { url = "https://files.pythonhosted.org/packages/30/3b/a1623d50fcd6ba532cef0c3c1059eec2a08a311676ffa84dbe4beb2b8a33/coverage-7.6.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:087932079c065d7b8ebadd3a0160656c55954144af6439886c8bcf78bbbcde7f", size = 234491 },
+ { url = "https://files.pythonhosted.org/packages/b1/a6/8f3b3fd1f9b9400f3df38a7159362622546e2d951cc4984cf4617d0fd4d7/coverage-7.6.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9c6b0c1cafd96213a0327cf680acb39f70e452caf8e9a25aeb05316db9c07f89", size = 233589 },
+ { url = "https://files.pythonhosted.org/packages/e3/40/37d64093f57b372435d87679956607ecab066d2aede76c6d215815a35fa3/coverage-7.6.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6e85830eed5b5263ffa0c62428e43cb844296f3b4461f09e4bdb0d44ec190bc2", size = 232050 },
+ { url = "https://files.pythonhosted.org/packages/80/63/cbb76298b4f42bffe0030f1bc129a26a26255857c6beaa20419259ac07cc/coverage-7.6.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62ab4231c01e156ece1b3a187c87173f31cbeee83a5e1f6dff17f288dca93345", size = 233180 },
+ { url = "https://files.pythonhosted.org/packages/7a/6a/eafa81503e905d473b799920927b06aa6ffba12db035fc98735b55bc1741/coverage-7.6.2-cp310-cp310-win32.whl", hash = "sha256:7b80fbb0da3aebde102a37ef0138aeedff45997e22f8962e5f16ae1742852676", size = 209281 },
+ { url = "https://files.pythonhosted.org/packages/19/d1/6b354c2cd52e0244944c097aaa71896869878df999f5f8e75fcd37eaf0f3/coverage-7.6.2-cp310-cp310-win_amd64.whl", hash = "sha256:d20c3d1f31f14d6962a4e2f549c21d31e670b90f777ef4171be540fb7fb70f02", size = 210092 },
+ { url = "https://files.pythonhosted.org/packages/a5/29/72da824da4182f518b054c21552b7ed2473a4e4c6ac616298209808a1a5c/coverage-7.6.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bb21bac7783c1bf6f4bbe68b1e0ff0d20e7e7732cfb7995bc8d96e23aa90fc7b", size = 206667 },
+ { url = "https://files.pythonhosted.org/packages/23/52/c15dcf3cf575256c7c0992e441cd41092a6c519d65abe1eb5567aab3d8e8/coverage-7.6.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a7b2e437fbd8fae5bc7716b9c7ff97aecc95f0b4d56e4ca08b3c8d8adcaadb84", size = 207111 },
+ { url = "https://files.pythonhosted.org/packages/92/61/0d46dc26cf9f711b7b6078a54680665a5c2d62ec15991adb51e79236c699/coverage-7.6.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:536f77f2bf5797983652d1d55f1a7272a29afcc89e3ae51caa99b2db4e89d658", size = 239050 },
+ { url = "https://files.pythonhosted.org/packages/3b/cb/9de71bade0343a0793f645f78a0e409248d85a2e5b4c4a9a1697c3b2e3d2/coverage-7.6.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f361296ca7054f0936b02525646b2731b32c8074ba6defab524b79b2b7eeac72", size = 236454 },
+ { url = "https://files.pythonhosted.org/packages/f2/81/b0dc02487447c4a56cf2eed5c57735097f77aeff582277a35f1f70713a8d/coverage-7.6.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7926d8d034e06b479797c199747dd774d5e86179f2ce44294423327a88d66ca7", size = 238320 },
+ { url = "https://files.pythonhosted.org/packages/60/90/76815a76234050a87d0d1438a34820c1b857dd17353855c02bddabbedea8/coverage-7.6.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0bbae11c138585c89fb4e991faefb174a80112e1a7557d507aaa07675c62e66b", size = 237250 },
+ { url = "https://files.pythonhosted.org/packages/f6/bd/760a599c08c882d97382855264586bba2604901029c3f6bec5710477ae81/coverage-7.6.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fcad7d5d2bbfeae1026b395036a8aa5abf67e8038ae7e6a25c7d0f88b10a8e6a", size = 235880 },
+ { url = "https://files.pythonhosted.org/packages/83/de/41c3b90a779e473ae1ca325542aa5fa5464b7d2061288e9c22ba5f1deaa3/coverage-7.6.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f01e53575f27097d75d42de33b1b289c74b16891ce576d767ad8c48d17aeb5e0", size = 236653 },
+ { url = "https://files.pythonhosted.org/packages/f4/90/61fe2721b9a9d9446e6c3ca33b6569e81d2a9a795ddfe786a66bf54035b7/coverage-7.6.2-cp311-cp311-win32.whl", hash = "sha256:7781f4f70c9b0b39e1b129b10c7d43a4e0c91f90c60435e6da8288efc2b73438", size = 209251 },
+ { url = "https://files.pythonhosted.org/packages/96/87/d586f2b12b98288fc874d366cd8d5601f5a374cb75853647a3e4d02e4eb0/coverage-7.6.2-cp311-cp311-win_amd64.whl", hash = "sha256:9bcd51eeca35a80e76dc5794a9dd7cb04b97f0e8af620d54711793bfc1fbba4b", size = 210083 },
+ { url = "https://files.pythonhosted.org/packages/3f/ac/1cca5ed5cf512a71cdd6e3afb75a5ef196f7ef9772be9192dadaaa5cfc1c/coverage-7.6.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ebc94fadbd4a3f4215993326a6a00e47d79889391f5659bf310f55fe5d9f581c", size = 206856 },
+ { url = "https://files.pythonhosted.org/packages/e4/58/030354d250f107a95e7aca24c7fd238709a3c7df3083cb206368798e637a/coverage-7.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9681516288e3dcf0aa7c26231178cc0be6cac9705cac06709f2353c5b406cfea", size = 207098 },
+ { url = "https://files.pythonhosted.org/packages/03/df/5f2cd6048d44a54bb5f58f8ece4efbc5b686ed49f8bd8dbf41eb2a6a687f/coverage-7.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d9c5d13927d77af4fbe453953810db766f75401e764727e73a6ee4f82527b3e", size = 240109 },
+ { url = "https://files.pythonhosted.org/packages/d3/18/7c53887643d921faa95529643b1b33e60ebba30ab835c8b5abd4e54d946b/coverage-7.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92f9ca04b3e719d69b02dc4a69debb795af84cb7afd09c5eb5d54b4a1ae2191", size = 237141 },
+ { url = "https://files.pythonhosted.org/packages/d2/79/339bdf597d128374e6150c089b37436ba694585d769cabf6d5abd73a1365/coverage-7.6.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ff2ef83d6d0b527b5c9dad73819b24a2f76fdddcfd6c4e7a4d7e73ecb0656b4", size = 239210 },
+ { url = "https://files.pythonhosted.org/packages/a9/62/7310c6de2bcb8a42f91094d41f0d4793ccda5a54621be3db76a156556cf2/coverage-7.6.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:47ccb6e99a3031ffbbd6e7cc041e70770b4fe405370c66a54dbf26a500ded80b", size = 238698 },
+ { url = "https://files.pythonhosted.org/packages/f2/cb/ccb23c084d7f581f770dc7ed547dc5b50763334ad6ce26087a9ad0b5b26d/coverage-7.6.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a867d26f06bcd047ef716175b2696b315cb7571ccb951006d61ca80bbc356e9e", size = 237000 },
+ { url = "https://files.pythonhosted.org/packages/e7/ab/58de9e2f94e4dc91b84d6e2705aa1e9d5447a2669fe113b4bbce6d2224a1/coverage-7.6.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cdfcf2e914e2ba653101157458afd0ad92a16731eeba9a611b5cbb3e7124e74b", size = 238666 },
+ { url = "https://files.pythonhosted.org/packages/6c/dc/8be87b9ed5dbd4892b603f41088b41982768e928734e5bdce67d2ddd460a/coverage-7.6.2-cp312-cp312-win32.whl", hash = "sha256:f9035695dadfb397bee9eeaf1dc7fbeda483bf7664a7397a629846800ce6e276", size = 209489 },
+ { url = "https://files.pythonhosted.org/packages/64/3a/3f44e55273a58bfb39b87ad76541bbb81d14de916b034fdb39971cc99ffe/coverage-7.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:5ed69befa9a9fc796fe015a7040c9398722d6b97df73a6b608e9e275fa0932b0", size = 210270 },
+ { url = "https://files.pythonhosted.org/packages/ae/99/c9676a75b57438a19c5174dfcf39798b42728ad56650497286379dc0c2c3/coverage-7.6.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eea60c79d36a8f39475b1af887663bc3ae4f31289cd216f514ce18d5938df40", size = 206888 },
+ { url = "https://files.pythonhosted.org/packages/e0/de/820ecb42e892049c5f384430e98b35b899da3451dd0cdb2f867baf26abfa/coverage-7.6.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa68a6cdbe1bc6793a9dbfc38302c11599bbe1837392ae9b1d238b9ef3dafcf1", size = 207142 },
+ { url = "https://files.pythonhosted.org/packages/dd/59/81fc7ad855d65eeb68fe9e7809cbb339946adb07be7ac32d3fc24dc17bd7/coverage-7.6.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ec528ae69f0a139690fad6deac8a7d33629fa61ccce693fdd07ddf7e9931fba", size = 239658 },
+ { url = "https://files.pythonhosted.org/packages/cd/a7/865de3eb9e78ffbf7afd92f86d2580b18edfb6f0481bd3c39b205e05a762/coverage-7.6.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed5ac02126f74d190fa2cc14a9eb2a5d9837d5863920fa472b02eb1595cdc925", size = 236802 },
+ { url = "https://files.pythonhosted.org/packages/36/94/3b8f3abf88b7c451f97fd14c98f536bcee364e74250d928d57cc97c38ddd/coverage-7.6.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21c0ea0d4db8a36b275cb6fb2437a3715697a4ba3cb7b918d3525cc75f726304", size = 238793 },
+ { url = "https://files.pythonhosted.org/packages/d5/4b/57f95e41a10525002f524f3dbd577a3a9871d67998f8a8eb192fe697dc7b/coverage-7.6.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35a51598f29b2a19e26d0908bd196f771a9b1c5d9a07bf20be0adf28f1ad4f77", size = 238455 },
+ { url = "https://files.pythonhosted.org/packages/99/c9/9fbe5b841628e1d9030c8044844afef4f4735586289eb9237eeb5b97f0d7/coverage-7.6.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c9192925acc33e146864b8cf037e2ed32a91fdf7644ae875f5d46cd2ef086a5f", size = 236538 },
+ { url = "https://files.pythonhosted.org/packages/43/0d/2200a0d447e30de94d48e4851c04d8dce37340815e7eda27457a7043c037/coverage-7.6.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf4eeecc9e10f5403ec06138978235af79c9a79af494eb6b1d60a50b49ed2869", size = 238383 },
+ { url = "https://files.pythonhosted.org/packages/ec/8a/106c66faafb4a87002b698769d6de3c4db0b6c29a7aeb72de13b893c333e/coverage-7.6.2-cp313-cp313-win32.whl", hash = "sha256:e4ee15b267d2dad3e8759ca441ad450c334f3733304c55210c2a44516e8d5530", size = 209551 },
+ { url = "https://files.pythonhosted.org/packages/c4/f5/1b39e2faaf5b9cc7eed568c444df5991ce7ff7138e2e735a6801be1bdadb/coverage-7.6.2-cp313-cp313-win_amd64.whl", hash = "sha256:c71965d1ced48bf97aab79fad56df82c566b4c498ffc09c2094605727c4b7e36", size = 210282 },
+ { url = "https://files.pythonhosted.org/packages/79/a3/8dd4e6c09f5286094cd6c7edb115b3fbf06ad8304d45431722a4e3bc2508/coverage-7.6.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7571e8bbecc6ac066256f9de40365ff833553e2e0c0c004f4482facb131820ef", size = 207629 },
+ { url = "https://files.pythonhosted.org/packages/8e/db/a9aa7009bbdc570a235e1ac781c0a83aa323cac6db8f8f13c2127b110978/coverage-7.6.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:078a87519057dacb5d77e333f740708ec2a8f768655f1db07f8dfd28d7a005f0", size = 207902 },
+ { url = "https://files.pythonhosted.org/packages/54/08/d0962be62d4335599ca2ff3a48bb68c9bfb80df74e28ca689ff5f392087b/coverage-7.6.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e5e92e3e84a8718d2de36cd8387459cba9a4508337b8c5f450ce42b87a9e760", size = 250617 },
+ { url = "https://files.pythonhosted.org/packages/a5/a2/158570aff1dd88b661a6c11281cbb190e8696e77798b4b2e47c74bfb2f39/coverage-7.6.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ebabdf1c76593a09ee18c1a06cd3022919861365219ea3aca0247ededf6facd6", size = 246334 },
+ { url = "https://files.pythonhosted.org/packages/aa/fe/b00428cca325b6585ca77422e4f64d7d86a225b14664b98682ea501efb57/coverage-7.6.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12179eb0575b8900912711688e45474f04ab3934aaa7b624dea7b3c511ecc90f", size = 248692 },
+ { url = "https://files.pythonhosted.org/packages/30/21/0a15fefc13039450bc45e7159f3add92489f004555eb7dab9c7ad4365dd0/coverage-7.6.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:39d3b964abfe1519b9d313ab28abf1d02faea26cd14b27f5283849bf59479ff5", size = 248188 },
+ { url = "https://files.pythonhosted.org/packages/de/b8/5c093526046a8450a7a3d62ad09517cf38e638f6b3ee9433dd6a73360501/coverage-7.6.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:84c4315577f7cd511d6250ffd0f695c825efe729f4205c0340f7004eda51191f", size = 246072 },
+ { url = "https://files.pythonhosted.org/packages/1e/8b/542b607d2cff56e5a90a6948f5a9040b693761d2be2d3c3bf88957b02361/coverage-7.6.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ff797320dcbff57caa6b2301c3913784a010e13b1f6cf4ab3f563f3c5e7919db", size = 247354 },
+ { url = "https://files.pythonhosted.org/packages/95/82/2e9111aa5e59f42b332d387f64e3205c2263518d1e660154d0c9fc54390e/coverage-7.6.2-cp313-cp313t-win32.whl", hash = "sha256:2b636a301e53964550e2f3094484fa5a96e699db318d65398cfba438c5c92171", size = 210194 },
+ { url = "https://files.pythonhosted.org/packages/9d/46/aabe4305cfc57cab4865f788ceceef746c422469720c32ed7a5b44e20f5e/coverage-7.6.2-cp313-cp313t-win_amd64.whl", hash = "sha256:d03a060ac1a08e10589c27d509bbdb35b65f2d7f3f8d81cf2fa199877c7bc58a", size = 211346 },
+ { url = "https://files.pythonhosted.org/packages/6a/a9/85d14426f2449252f302f12c1c2a957a0a7ae7f35317ca3eaa365e1d6453/coverage-7.6.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c37faddc8acd826cfc5e2392531aba734b229741d3daec7f4c777a8f0d4993e5", size = 206555 },
+ { url = "https://files.pythonhosted.org/packages/71/ff/bc4d5697a55edf1ff077c47df5637ff4518ba2760ada82c142aca79ea3fe/coverage-7.6.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab31fdd643f162c467cfe6a86e9cb5f1965b632e5e65c072d90854ff486d02cf", size = 206990 },
+ { url = "https://files.pythonhosted.org/packages/34/65/1301721d09f5b58da9decfd62eb42eaef07fdb854dae904c3482e59cc309/coverage-7.6.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97df87e1a20deb75ac7d920c812e9326096aa00a9a4b6d07679b4f1f14b06c90", size = 235022 },
+ { url = "https://files.pythonhosted.org/packages/9f/ec/7a2f361485226e6934a8f5d1f6eef7e8b7faf228fb6107476fa584700a32/coverage-7.6.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:343056c5e0737487a5291f5691f4dfeb25b3e3c8699b4d36b92bb0e586219d14", size = 232943 },
+ { url = "https://files.pythonhosted.org/packages/2d/60/b23e61a372bef93c9d13d87efa2ea3a870130be498e5b81740616b6e6200/coverage-7.6.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad4ef1c56b47b6b9024b939d503ab487231df1f722065a48f4fc61832130b90e", size = 234074 },
+ { url = "https://files.pythonhosted.org/packages/89/ec/4a56d9b310b2413987682ae3a858e30ea11d6f6d05366ecab4d73385fbef/coverage-7.6.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fca4a92c8a7a73dee6946471bce6d1443d94155694b893b79e19ca2a540d86e", size = 233226 },
+ { url = "https://files.pythonhosted.org/packages/8c/77/31ecc00c525dea216d59090b807e9d1268a07d289f9dbe0cfc6795e33b68/coverage-7.6.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69f251804e052fc46d29d0e7348cdc5fcbfc4861dc4a1ebedef7e78d241ad39e", size = 231706 },
+ { url = "https://files.pythonhosted.org/packages/7b/02/3f84bdd286a9db9b816cb5ca0adfa001575f8e496ba39da26f0ded2f0849/coverage-7.6.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e8ea055b3ea046c0f66217af65bc193bbbeca1c8661dc5fd42698db5795d2627", size = 232697 },
+ { url = "https://files.pythonhosted.org/packages/7c/34/158b73026cbc2d2b3a56fbc71d955c0eea52953e49de97f820b3060f62b9/coverage-7.6.2-cp39-cp39-win32.whl", hash = "sha256:6c2ba1e0c24d8fae8f2cf0aeb2fc0a2a7f69b6d20bd8d3749fd6b36ecef5edf0", size = 209278 },
+ { url = "https://files.pythonhosted.org/packages/d1/05/4326e4ea071176f0bddc30b5a3555b48fa96c45a8f6a09b6c2e4041dfcc0/coverage-7.6.2-cp39-cp39-win_amd64.whl", hash = "sha256:2186369a654a15628e9c1c9921409a6b3eda833e4b91f3ca2a7d9f77abb4987c", size = 210057 },
+ { url = "https://files.pythonhosted.org/packages/9d/5c/88f15b7614ba9ed1dbb1c0bd2c9073184b96c2bead0b93199487b44d04b3/coverage-7.6.2-pp39.pp310-none-any.whl", hash = "sha256:667952739daafe9616db19fbedbdb87917eee253ac4f31d70c7587f7ab531b4e", size = 198799 },
+]
+
+[package.optional-dependencies]
+toml = [
+ { name = "tomli", marker = "python_full_version <= '3.11'" },
+]
+
+[[package]]
+name = "distlib"
+version = "0.3.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
+]
+
+[[package]]
+name = "filelock"
+version = "3.16.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 },
+]
+
+[[package]]
+name = "h11"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
+]
+
+[[package]]
+name = "htmlmin"
+version = "0.1.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/e7/fcd59e12169de19f0131ff2812077f964c6b960e7c09804d30a7bf2ab461/htmlmin-0.1.12.tar.gz", hash = "sha256:50c1ef4630374a5d723900096a961cff426dff46b48f34d194a81bbe14eca178", size = 19940 }
+
+[[package]]
+name = "httpcore"
+version = "1.0.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b6/44/ed0fa6a17845fb033bd885c03e842f08c1b9406c86a2e60ac1ae1b9206a6/httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f", size = 85180 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 },
+]
+
+[[package]]
+name = "httpx"
+version = "0.27.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+ { name = "sniffio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 },
+]
+
+[[package]]
+name = "identify"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/29/bb/25024dbcc93516c492b75919e76f389bac754a3e4248682fba32b250c880/identify-2.6.1.tar.gz", hash = "sha256:91478c5fb7c3aac5ff7bf9b4344f803843dc586832d5f110d672b19aa1984c98", size = 99097 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7d/0c/4ef72754c050979fdcc06c744715ae70ea37e734816bb6514f79df77a42f/identify-2.6.1-py2.py3-none-any.whl", hash = "sha256:53863bcac7caf8d2ed85bd20312ea5dcfc22226800f6d6881f232d861db5a8f0", size = 98972 },
+]
+
+[[package]]
+name = "idna"
+version = "3.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 },
+]
+
+[[package]]
+name = "nodeenv"
+version = "1.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
+]
+
+[[package]]
+name = "packaging"
+version = "24.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/51/65/50db4dda066951078f0a96cf12f4b9ada6e4b811516bf0262c0f4f7064d4/packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002", size = 148788 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985 },
+]
+
+[[package]]
+name = "phonenumbers"
+version = "8.13.47"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/0c/8f315d5e6ddea2e45ae13ada6936df6240858929881daf20cb3133fdb729/phonenumbers-8.13.47.tar.gz", hash = "sha256:53c5e7c6d431cafe4efdd44956078404ae9bc8b0eacc47be3105d3ccc88aaffa", size = 2297081 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b6/0b/5cde445764ac72460748107e999b026b7245e3fcc5fd5551cc5aff45e469/phonenumbers-8.13.47-py2.py3-none-any.whl", hash = "sha256:5d3c0142ef7055ca5551884352e3b6b93bfe002a0bc95b8eaba39b0e2184541b", size = 2582530 },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.3.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
+]
+
+[[package]]
+name = "pre-commit"
+version = "3.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cfgv" },
+ { name = "identify" },
+ { name = "nodeenv" },
+ { name = "pyyaml" },
+ { name = "virtualenv" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/64/10/97ee2fa54dff1e9da9badbc5e35d0bbaef0776271ea5907eccf64140f72f/pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af", size = 177815 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/92/caae8c86e94681b42c246f0bca35c059a2f0529e5b92619f6aba4cf7e7b6/pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f", size = 204643 },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.9.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/b7/d9e3f12af310e1120c21603644a1cd86f59060e040ec5c3a80b8f05fae30/pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", size = 769917 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12", size = 434928 },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.23.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e2/aa/6b6a9b9f8537b872f552ddd46dd3da230367754b6f707b8e1e963f515ea3/pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", size = 402156 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/8b/d3ae387f66277bd8104096d6ec0a145f4baa2966ebb2cad746c0920c9526/pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b", size = 1867835 },
+ { url = "https://files.pythonhosted.org/packages/46/76/f68272e4c3a7df8777798282c5e47d508274917f29992d84e1898f8908c7/pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166", size = 1776689 },
+ { url = "https://files.pythonhosted.org/packages/cc/69/5f945b4416f42ea3f3bc9d2aaec66c76084a6ff4ff27555bf9415ab43189/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63e46b3169866bd62849936de036f901a9356e36376079b05efa83caeaa02ceb", size = 1800748 },
+ { url = "https://files.pythonhosted.org/packages/50/ab/891a7b0054bcc297fb02d44d05c50e68154e31788f2d9d41d0b72c89fdf7/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed1a53de42fbe34853ba90513cea21673481cd81ed1be739f7f2efb931b24916", size = 1806469 },
+ { url = "https://files.pythonhosted.org/packages/31/7c/6e3fa122075d78f277a8431c4c608f061881b76c2b7faca01d317ee39b5d/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cfdd16ab5e59fc31b5e906d1a3f666571abc367598e3e02c83403acabc092e07", size = 2002246 },
+ { url = "https://files.pythonhosted.org/packages/ad/6f/22d5692b7ab63fc4acbc74de6ff61d185804a83160adba5e6cc6068e1128/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255a8ef062cbf6674450e668482456abac99a5583bbafb73f9ad469540a3a232", size = 2659404 },
+ { url = "https://files.pythonhosted.org/packages/11/ac/1e647dc1121c028b691028fa61a4e7477e6aeb5132628fde41dd34c1671f/pydantic_core-2.23.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a7cd62e831afe623fbb7aabbb4fe583212115b3ef38a9f6b71869ba644624a2", size = 2053940 },
+ { url = "https://files.pythonhosted.org/packages/91/75/984740c17f12c3ce18b5a2fcc4bdceb785cce7df1511a4ce89bca17c7e2d/pydantic_core-2.23.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f09e2ff1f17c2b51f2bc76d1cc33da96298f0a036a137f5440ab3ec5360b624f", size = 1921437 },
+ { url = "https://files.pythonhosted.org/packages/a0/74/13c5f606b64d93f0721e7768cd3e8b2102164866c207b8cd6f90bb15d24f/pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e38e63e6f3d1cec5a27e0afe90a085af8b6806ee208b33030e65b6516353f1a3", size = 1966129 },
+ { url = "https://files.pythonhosted.org/packages/18/03/9c4aa5919457c7b57a016c1ab513b1a926ed9b2bb7915bf8e506bf65c34b/pydantic_core-2.23.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0dbd8dbed2085ed23b5c04afa29d8fd2771674223135dc9bc937f3c09284d071", size = 2110908 },
+ { url = "https://files.pythonhosted.org/packages/92/2c/053d33f029c5dc65e5cf44ff03ceeefb7cce908f8f3cca9265e7f9b540c8/pydantic_core-2.23.4-cp310-none-win32.whl", hash = "sha256:6531b7ca5f951d663c339002e91aaebda765ec7d61b7d1e3991051906ddde119", size = 1735278 },
+ { url = "https://files.pythonhosted.org/packages/de/81/7dfe464eca78d76d31dd661b04b5f2036ec72ea8848dd87ab7375e185c23/pydantic_core-2.23.4-cp310-none-win_amd64.whl", hash = "sha256:7c9129eb40958b3d4500fa2467e6a83356b3b61bfff1b414c7361d9220f9ae8f", size = 1917453 },
+ { url = "https://files.pythonhosted.org/packages/5d/30/890a583cd3f2be27ecf32b479d5d615710bb926d92da03e3f7838ff3e58b/pydantic_core-2.23.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:77733e3892bb0a7fa797826361ce8a9184d25c8dffaec60b7ffe928153680ba8", size = 1865160 },
+ { url = "https://files.pythonhosted.org/packages/1d/9a/b634442e1253bc6889c87afe8bb59447f106ee042140bd57680b3b113ec7/pydantic_core-2.23.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b84d168f6c48fabd1f2027a3d1bdfe62f92cade1fb273a5d68e621da0e44e6d", size = 1776777 },
+ { url = "https://files.pythonhosted.org/packages/75/9a/7816295124a6b08c24c96f9ce73085032d8bcbaf7e5a781cd41aa910c891/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df49e7a0861a8c36d089c1ed57d308623d60416dab2647a4a17fe050ba85de0e", size = 1799244 },
+ { url = "https://files.pythonhosted.org/packages/a9/8f/89c1405176903e567c5f99ec53387449e62f1121894aa9fc2c4fdc51a59b/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff02b6d461a6de369f07ec15e465a88895f3223eb75073ffea56b84d9331f607", size = 1805307 },
+ { url = "https://files.pythonhosted.org/packages/d5/a5/1a194447d0da1ef492e3470680c66048fef56fc1f1a25cafbea4bc1d1c48/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:996a38a83508c54c78a5f41456b0103c30508fed9abcad0a59b876d7398f25fd", size = 2000663 },
+ { url = "https://files.pythonhosted.org/packages/13/a5/1df8541651de4455e7d587cf556201b4f7997191e110bca3b589218745a5/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d97683ddee4723ae8c95d1eddac7c192e8c552da0c73a925a89fa8649bf13eea", size = 2655941 },
+ { url = "https://files.pythonhosted.org/packages/44/31/a3899b5ce02c4316865e390107f145089876dff7e1dfc770a231d836aed8/pydantic_core-2.23.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:216f9b2d7713eb98cb83c80b9c794de1f6b7e3145eef40400c62e86cee5f4e1e", size = 2052105 },
+ { url = "https://files.pythonhosted.org/packages/1b/aa/98e190f8745d5ec831f6d5449344c48c0627ac5fed4e5340a44b74878f8e/pydantic_core-2.23.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f783e0ec4803c787bcea93e13e9932edab72068f68ecffdf86a99fd5918878b", size = 1919967 },
+ { url = "https://files.pythonhosted.org/packages/ae/35/b6e00b6abb2acfee3e8f85558c02a0822e9a8b2f2d812ea8b9079b118ba0/pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d0776dea117cf5272382634bd2a5c1b6eb16767c223c6a5317cd3e2a757c61a0", size = 1964291 },
+ { url = "https://files.pythonhosted.org/packages/13/46/7bee6d32b69191cd649bbbd2361af79c472d72cb29bb2024f0b6e350ba06/pydantic_core-2.23.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d5f7a395a8cf1621939692dba2a6b6a830efa6b3cee787d82c7de1ad2930de64", size = 2109666 },
+ { url = "https://files.pythonhosted.org/packages/39/ef/7b34f1b122a81b68ed0a7d0e564da9ccdc9a2924c8d6c6b5b11fa3a56970/pydantic_core-2.23.4-cp311-none-win32.whl", hash = "sha256:74b9127ffea03643e998e0c5ad9bd3811d3dac8c676e47db17b0ee7c3c3bf35f", size = 1732940 },
+ { url = "https://files.pythonhosted.org/packages/2f/76/37b7e76c645843ff46c1d73e046207311ef298d3f7b2f7d8f6ac60113071/pydantic_core-2.23.4-cp311-none-win_amd64.whl", hash = "sha256:98d134c954828488b153d88ba1f34e14259284f256180ce659e8d83e9c05eaa3", size = 1916804 },
+ { url = "https://files.pythonhosted.org/packages/74/7b/8e315f80666194b354966ec84b7d567da77ad927ed6323db4006cf915f3f/pydantic_core-2.23.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f3e0da4ebaef65158d4dfd7d3678aad692f7666877df0002b8a522cdf088f231", size = 1856459 },
+ { url = "https://files.pythonhosted.org/packages/14/de/866bdce10ed808323d437612aca1ec9971b981e1c52e5e42ad9b8e17a6f6/pydantic_core-2.23.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f69a8e0b033b747bb3e36a44e7732f0c99f7edd5cea723d45bc0d6e95377ffee", size = 1770007 },
+ { url = "https://files.pythonhosted.org/packages/dc/69/8edd5c3cd48bb833a3f7ef9b81d7666ccddd3c9a635225214e044b6e8281/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723314c1d51722ab28bfcd5240d858512ffd3116449c557a1336cbe3919beb87", size = 1790245 },
+ { url = "https://files.pythonhosted.org/packages/80/33/9c24334e3af796ce80d2274940aae38dd4e5676298b4398eff103a79e02d/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb2802e667b7051a1bebbfe93684841cc9351004e2badbd6411bf357ab8d5ac8", size = 1801260 },
+ { url = "https://files.pythonhosted.org/packages/a5/6f/e9567fd90104b79b101ca9d120219644d3314962caa7948dd8b965e9f83e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d18ca8148bebe1b0a382a27a8ee60350091a6ddaf475fa05ef50dc35b5df6327", size = 1996872 },
+ { url = "https://files.pythonhosted.org/packages/2d/ad/b5f0fe9e6cfee915dd144edbd10b6e9c9c9c9d7a56b69256d124b8ac682e/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33e3d65a85a2a4a0dc3b092b938a4062b1a05f3a9abde65ea93b233bca0e03f2", size = 2661617 },
+ { url = "https://files.pythonhosted.org/packages/06/c8/7d4b708f8d05a5cbfda3243aad468052c6e99de7d0937c9146c24d9f12e9/pydantic_core-2.23.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:128585782e5bfa515c590ccee4b727fb76925dd04a98864182b22e89a4e6ed36", size = 2071831 },
+ { url = "https://files.pythonhosted.org/packages/89/4d/3079d00c47f22c9a9a8220db088b309ad6e600a73d7a69473e3a8e5e3ea3/pydantic_core-2.23.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:68665f4c17edcceecc112dfed5dbe6f92261fb9d6054b47d01bf6371a6196126", size = 1917453 },
+ { url = "https://files.pythonhosted.org/packages/e9/88/9df5b7ce880a4703fcc2d76c8c2d8eb9f861f79d0c56f4b8f5f2607ccec8/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20152074317d9bed6b7a95ade3b7d6054845d70584216160860425f4fbd5ee9e", size = 1968793 },
+ { url = "https://files.pythonhosted.org/packages/e3/b9/41f7efe80f6ce2ed3ee3c2dcfe10ab7adc1172f778cc9659509a79518c43/pydantic_core-2.23.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9261d3ce84fa1d38ed649c3638feefeae23d32ba9182963e465d58d62203bd24", size = 2116872 },
+ { url = "https://files.pythonhosted.org/packages/63/08/b59b7a92e03dd25554b0436554bf23e7c29abae7cce4b1c459cd92746811/pydantic_core-2.23.4-cp312-none-win32.whl", hash = "sha256:4ba762ed58e8d68657fc1281e9bb72e1c3e79cc5d464be146e260c541ec12d84", size = 1738535 },
+ { url = "https://files.pythonhosted.org/packages/88/8d/479293e4d39ab409747926eec4329de5b7129beaedc3786eca070605d07f/pydantic_core-2.23.4-cp312-none-win_amd64.whl", hash = "sha256:97df63000f4fea395b2824da80e169731088656d1818a11b95f3b173747b6cd9", size = 1917992 },
+ { url = "https://files.pythonhosted.org/packages/ad/ef/16ee2df472bf0e419b6bc68c05bf0145c49247a1095e85cee1463c6a44a1/pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", size = 1856143 },
+ { url = "https://files.pythonhosted.org/packages/da/fa/bc3dbb83605669a34a93308e297ab22be82dfb9dcf88c6cf4b4f264e0a42/pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", size = 1770063 },
+ { url = "https://files.pythonhosted.org/packages/4e/48/e813f3bbd257a712303ebdf55c8dc46f9589ec74b384c9f652597df3288d/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", size = 1790013 },
+ { url = "https://files.pythonhosted.org/packages/b4/e0/56eda3a37929a1d297fcab1966db8c339023bcca0b64c5a84896db3fcc5c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", size = 1801077 },
+ { url = "https://files.pythonhosted.org/packages/04/be/5e49376769bfbf82486da6c5c1683b891809365c20d7c7e52792ce4c71f3/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", size = 1996782 },
+ { url = "https://files.pythonhosted.org/packages/bc/24/e3ee6c04f1d58cc15f37bcc62f32c7478ff55142b7b3e6d42ea374ea427c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", size = 2661375 },
+ { url = "https://files.pythonhosted.org/packages/c1/f8/11a9006de4e89d016b8de74ebb1db727dc100608bb1e6bbe9d56a3cbbcce/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", size = 2071635 },
+ { url = "https://files.pythonhosted.org/packages/7c/45/bdce5779b59f468bdf262a5bc9eecbae87f271c51aef628d8c073b4b4b4c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", size = 1916994 },
+ { url = "https://files.pythonhosted.org/packages/d8/fa/c648308fe711ee1f88192cad6026ab4f925396d1293e8356de7e55be89b5/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", size = 1968877 },
+ { url = "https://files.pythonhosted.org/packages/16/16/b805c74b35607d24d37103007f899abc4880923b04929547ae68d478b7f4/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", size = 2116814 },
+ { url = "https://files.pythonhosted.org/packages/d1/58/5305e723d9fcdf1c5a655e6a4cc2a07128bf644ff4b1d98daf7a9dbf57da/pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", size = 1738360 },
+ { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411 },
+ { url = "https://files.pythonhosted.org/packages/7a/04/2580b2deaae37b3e30fc30c54298be938b973990b23612d6b61c7bdd01c7/pydantic_core-2.23.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a4fa4fc04dff799089689f4fd502ce7d59de529fc2f40a2c8836886c03e0175a", size = 1868200 },
+ { url = "https://files.pythonhosted.org/packages/39/6e/e311bd0751505350f0cdcee3077841eb1f9253c5a1ddbad048cd9fbf7c6e/pydantic_core-2.23.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7df63886be5e270da67e0966cf4afbae86069501d35c8c1b3b6c168f42cb36", size = 1749316 },
+ { url = "https://files.pythonhosted.org/packages/d0/b4/95b5eb47c6dc8692508c3ca04a1f8d6f0884c9dacb34cf3357595cbe73be/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcedcd19a557e182628afa1d553c3895a9f825b936415d0dbd3cd0bbcfd29b4b", size = 1800880 },
+ { url = "https://files.pythonhosted.org/packages/da/79/41c4f817acd7f42d94cd1e16526c062a7b089f66faed4bd30852314d9a66/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f54b118ce5de9ac21c363d9b3caa6c800341e8c47a508787e5868c6b79c9323", size = 1807077 },
+ { url = "https://files.pythonhosted.org/packages/fb/53/d13d1eb0a97d5c06cf7a225935d471e9c241afd389a333f40c703f214973/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86d2f57d3e1379a9525c5ab067b27dbb8a0642fb5d454e17a9ac434f9ce523e3", size = 2002859 },
+ { url = "https://files.pythonhosted.org/packages/53/7d/6b8a1eff453774b46cac8c849e99455b27167971a003212f668e94bc4c9c/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:de6d1d1b9e5101508cb37ab0d972357cac5235f5c6533d1071964c47139257df", size = 2661437 },
+ { url = "https://files.pythonhosted.org/packages/6c/ea/8820f57f0b46e6148ee42d8216b15e8fe3b360944284bbc705bf34fac888/pydantic_core-2.23.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1278e0d324f6908e872730c9102b0112477a7f7cf88b308e4fc36ce1bdb6d58c", size = 2054404 },
+ { url = "https://files.pythonhosted.org/packages/0f/36/d4ae869e473c3c7868e1cd1e2a1b9e13bce5cd1a7d287f6ac755a0b1575e/pydantic_core-2.23.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a6b5099eeec78827553827f4c6b8615978bb4b6a88e5d9b93eddf8bb6790f55", size = 1921680 },
+ { url = "https://files.pythonhosted.org/packages/0d/f8/eed5c65b80c4ac4494117e2101973b45fc655774ef647d17dde40a70f7d2/pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e55541f756f9b3ee346b840103f32779c695a19826a4c442b7954550a0972040", size = 1966093 },
+ { url = "https://files.pythonhosted.org/packages/e8/c8/1d42ce51d65e571ab53d466cae83434325a126811df7ce4861d9d97bee4b/pydantic_core-2.23.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a5c7ba8ffb6d6f8f2ab08743be203654bb1aaa8c9dcb09f82ddd34eadb695605", size = 2111437 },
+ { url = "https://files.pythonhosted.org/packages/aa/c9/7fea9d13383c2ec6865919e09cffe44ab77e911eb281b53a4deaafd4c8e8/pydantic_core-2.23.4-cp39-none-win32.whl", hash = "sha256:37b0fe330e4a58d3c58b24d91d1eb102aeec675a3db4c292ec3928ecd892a9a6", size = 1735049 },
+ { url = "https://files.pythonhosted.org/packages/98/95/dd7045c4caa2b73d0bf3b989d66b23cfbb7a0ef14ce99db15677a000a953/pydantic_core-2.23.4-cp39-none-win_amd64.whl", hash = "sha256:1498bec4c05c9c787bde9125cfdcc63a41004ff167f495063191b863399b1a29", size = 1920180 },
+ { url = "https://files.pythonhosted.org/packages/13/a9/5d582eb3204464284611f636b55c0a7410d748ff338756323cb1ce721b96/pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f455ee30a9d61d3e1a15abd5068827773d6e4dc513e795f380cdd59932c782d5", size = 1857135 },
+ { url = "https://files.pythonhosted.org/packages/2c/57/faf36290933fe16717f97829eabfb1868182ac495f99cf0eda9f59687c9d/pydantic_core-2.23.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1e90d2e3bd2c3863d48525d297cd143fe541be8bbf6f579504b9712cb6b643ec", size = 1740583 },
+ { url = "https://files.pythonhosted.org/packages/91/7c/d99e3513dc191c4fec363aef1bf4c8af9125d8fa53af7cb97e8babef4e40/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e203fdf807ac7e12ab59ca2bfcabb38c7cf0b33c41efeb00f8e5da1d86af480", size = 1793637 },
+ { url = "https://files.pythonhosted.org/packages/29/18/812222b6d18c2d13eebbb0f7cdc170a408d9ced65794fdb86147c77e1982/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08277a400de01bc72436a0ccd02bdf596631411f592ad985dcee21445bd0068", size = 1941963 },
+ { url = "https://files.pythonhosted.org/packages/0f/36/c1f3642ac3f05e6bb4aec3ffc399fa3f84895d259cf5f0ce3054b7735c29/pydantic_core-2.23.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f220b0eea5965dec25480b6333c788fb72ce5f9129e8759ef876a1d805d00801", size = 1915332 },
+ { url = "https://files.pythonhosted.org/packages/f7/ca/9c0854829311fb446020ebb540ee22509731abad886d2859c855dd29b904/pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d06b0c8da4f16d1d1e352134427cb194a0a6e19ad5db9161bf32b2113409e728", size = 1957926 },
+ { url = "https://files.pythonhosted.org/packages/c0/1c/7836b67c42d0cd4441fcd9fafbf6a027ad4b79b6559f80cf11f89fd83648/pydantic_core-2.23.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:ba1a0996f6c2773bd83e63f18914c1de3c9dd26d55f4ac302a7efe93fb8e7433", size = 2100342 },
+ { url = "https://files.pythonhosted.org/packages/a9/f9/b6bcaf874f410564a78908739c80861a171788ef4d4f76f5009656672dfe/pydantic_core-2.23.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:9a5bce9d23aac8f0cf0836ecfc033896aa8443b501c58d0602dbfd5bd5b37753", size = 1920344 },
+ { url = "https://files.pythonhosted.org/packages/32/fd/ac9cdfaaa7cf2d32590b807d900612b39acb25e5527c3c7e482f0553025b/pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:78ddaaa81421a29574a682b3179d4cf9e6d405a09b99d93ddcf7e5239c742e21", size = 1857850 },
+ { url = "https://files.pythonhosted.org/packages/08/fe/038f4b2bcae325ea643c8ad353191187a4c92a9c3b913b139289a6f2ef04/pydantic_core-2.23.4-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:883a91b5dd7d26492ff2f04f40fbb652de40fcc0afe07e8129e8ae779c2110eb", size = 1740265 },
+ { url = "https://files.pythonhosted.org/packages/51/14/b215c9c3cbd1edaaea23014d4b3304260823f712d3fdee52549b19b25d62/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88ad334a15b32a791ea935af224b9de1bf99bcd62fabf745d5f3442199d86d59", size = 1793912 },
+ { url = "https://files.pythonhosted.org/packages/62/de/2c3ad79b63ba564878cbce325be725929ba50089cd5156f89ea5155cb9b3/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:233710f069d251feb12a56da21e14cca67994eab08362207785cf8c598e74577", size = 1942870 },
+ { url = "https://files.pythonhosted.org/packages/cb/55/c222af19e4644c741b3f3fe4fd8bbb6b4cdca87d8a49258b61cf7826b19e/pydantic_core-2.23.4-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:19442362866a753485ba5e4be408964644dd6a09123d9416c54cd49171f50744", size = 1915610 },
+ { url = "https://files.pythonhosted.org/packages/c4/7a/9a8760692a6f76bb54bcd43f245ff3d8b603db695899bbc624099c00af80/pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:624e278a7d29b6445e4e813af92af37820fafb6dcc55c012c834f9e26f9aaaef", size = 1958403 },
+ { url = "https://files.pythonhosted.org/packages/4c/91/9b03166feb914bb5698e2f6499e07c2617e2eebf69f9374d0358d7eb2009/pydantic_core-2.23.4-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f5ef8f42bec47f21d07668a043f077d507e5bf4e668d5c6dfe6aaba89de1a5b8", size = 2101154 },
+ { url = "https://files.pythonhosted.org/packages/1d/d9/1d7ecb98318da4cb96986daaf0e20d66f1651d0aeb9e2d4435b916ce031d/pydantic_core-2.23.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:aea443fffa9fbe3af1a9ba721a87f926fe548d32cab71d188a6ede77d0ff244e", size = 1920855 },
+]
+
+[[package]]
+name = "pytest"
+version = "8.3.3"
+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 = "tomli", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "0.24.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "5.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage", extra = ["toml"] },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 },
+]
+
+[[package]]
+name = "pyyaml"
+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 }
+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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 },
+ { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 },
+ { 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 },
+ { 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 },
+ { 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 },
+ { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167 },
+ { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952 },
+ { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301 },
+ { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638 },
+ { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850 },
+ { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980 },
+ { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 },
+ { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 },
+ { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 },
+ { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 },
+ { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 },
+ { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 },
+ { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 },
+ { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 },
+ { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 },
+ { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 },
+ { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 },
+ { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 },
+ { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 },
+ { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 },
+ { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 },
+ { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 },
+ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
+ { url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 },
+ { url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 },
+ { url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 },
+ { url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 },
+ { url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 },
+ { url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 },
+ { url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 },
+ { url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 },
+ { url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
+]
+
+[[package]]
+name = "tomli"
+version = "2.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/35/b9/de2a5c0144d7d75a57ff355c0c24054f965b2dc3036456ae03a51ea6264b/tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed", size = 16096 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237 },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.12.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
+]
+
+[[package]]
+name = "virtualenv"
+version = "20.26.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "distlib" },
+ { name = "filelock" },
+ { name = "platformdirs" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3f/40/abc5a766da6b0b2457f819feab8e9203cbeae29327bd241359f866a3da9d/virtualenv-20.26.6.tar.gz", hash = "sha256:280aede09a2a5c317e409a00102e7077c6432c5a38f0ef938e643805a7ad2c48", size = 9372482 }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/90/57b8ac0c8a231545adc7698c64c5a36fa7cd8e376c691b9bde877269f2eb/virtualenv-20.26.6-py3-none-any.whl", hash = "sha256:7345cc5b25405607a624d8418154577459c3e0277f5466dd79c49d5e492995f2", size = 5999862 },
+]