From a4f047e0782c7b9f8334e41665db7ead28fdbe76 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Sat, 12 Oct 2024 15:22:32 -0700 Subject: [PATCH 01/12] initial work to merge sdks --- .gitignore | 6 +- .pre-commit-config.yaml | 22 ++ LICENSE.txt | 21 ++ contiguity/__init__.py | 6 - contiguity/main.py | 355 ------------------- examples/__init__.py | 0 examples/base/__init__.py | 0 examples/base/basic_usage.py | 40 +++ examples/base/pydantic_usage.py | 69 ++++ pyproject.toml | 73 ++++ setup.py | 21 -- src/contiguity/__init__.py | 64 ++++ src/contiguity/_auth.py | 23 ++ src/contiguity/_client.py | 52 +++ src/contiguity/analytics.py | 24 ++ src/contiguity/base/__init__.py | 10 + src/contiguity/base/async_base.py | 428 +++++++++++++++++++++++ src/contiguity/base/base.py | 437 +++++++++++++++++++++++ src/contiguity/otp.py | 75 ++++ src/contiguity/py.typed | 0 src/contiguity/quota.py | 23 ++ src/contiguity/send.py | 129 +++++++ src/contiguity/template.py | 16 + src/contiguity/verify.py | 16 + tests/__init__.py | 6 + tests/test_base_dict_typed.py | 171 +++++++++ tests/test_base_dict_untyped.py | 165 +++++++++ tests/test_base_model.py | 156 +++++++++ tests/test_base_typeddict.py | 183 ++++++++++ uv.lock | 561 ++++++++++++++++++++++++++++++ 30 files changed, 2767 insertions(+), 385 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE.txt delete mode 100644 contiguity/__init__.py delete mode 100644 contiguity/main.py create mode 100644 examples/__init__.py create mode 100644 examples/base/__init__.py create mode 100644 examples/base/basic_usage.py create mode 100644 examples/base/pydantic_usage.py create mode 100644 pyproject.toml delete mode 100644 setup.py create mode 100644 src/contiguity/__init__.py create mode 100644 src/contiguity/_auth.py create mode 100644 src/contiguity/_client.py create mode 100644 src/contiguity/analytics.py create mode 100644 src/contiguity/base/__init__.py create mode 100644 src/contiguity/base/async_base.py create mode 100644 src/contiguity/base/base.py create mode 100644 src/contiguity/otp.py create mode 100644 src/contiguity/py.typed create mode 100644 src/contiguity/quota.py create mode 100644 src/contiguity/send.py create mode 100644 src/contiguity/template.py create mode 100644 src/contiguity/verify.py create mode 100644 tests/__init__.py create mode 100644 tests/test_base_dict_typed.py create mode 100644 tests/test_base_dict_untyped.py create mode 100644 tests/test_base_model.py create mode 100644 tests/test_base_typeddict.py create mode 100644 uv.lock 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/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..60d14a6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[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", +] + +[tool.ruff] +src = ["src"] +line-length = 119 +target-version = "py39" + +[tool.ruff.lint] +select = ["ALL"] +ignore = ["A", "D"] + +[tool.pyright] +venvPath = "." +venv = ".venv" +reportUnnecessaryTypeIgnoreComment = true 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..2e4af1a --- /dev/null +++ b/src/contiguity/__init__.py @@ -0,0 +1,64 @@ +from ._client import ApiClient +from .analytics import EmailAnalytics +from .base import 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, + debug: bool = False, + base_url: str = "https://api.contiguity.co", + orwell_base_url: str = "https://orwell.contiguity.co", + ) -> None: + if not token: + raise ValueError("Contiguity requires a token/API key to be provided via contiguity.login('token')") + self.token = token + self.debug = debug + self.base_url = base_url + self.orwell_base_url = orwell_base_url + 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__ = ( + "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/analytics.py b/src/contiguity/analytics.py new file mode 100644 index 0000000..f06a40d --- /dev/null +++ b/src/contiguity/analytics.py @@ -0,0 +1,24 @@ +import json + +from ._client import ApiClient + + +class EmailAnalytics: + def __init__(self, *, client: ApiClient, debug: bool = False) -> None: + self._client = client + self.debug = debug + + def retrieve(self, id: str): + if not id: + raise ValueError("Contiguity Analytics requires an email ID.") + + status = self._client.get(f"/email/status/{id}") + + 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 diff --git a/src/contiguity/base/__init__.py b/src/contiguity/base/__init__.py new file mode 100644 index 0000000..fd12e2b --- /dev/null +++ b/src/contiguity/base/__init__.py @@ -0,0 +1,10 @@ +from .base import Base, BaseItem, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse + +__all__ = ( + "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..209577d --- /dev/null +++ b/src/contiguity/base/async_base.py @@ -0,0 +1,428 @@ +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, Any, Generic, Literal, TypeVar, Union, overload +from urllib.parse import quote +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 + +if TYPE_CHECKING: + from httpx import Response as HttpxResponse + from typing_extensions import Self + +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 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) + + +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="") + + +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/base.py b/src/contiguity/base/base.py new file mode 100644 index 0000000..5967016 --- /dev/null +++ b/src/contiguity/base/base.py @@ -0,0 +1,437 @@ +# 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, Any, Generic, Literal, TypeVar, Union, overload +from urllib.parse import quote +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 + +if TYPE_CHECKING: + from httpx import Response as HttpxResponse + from typing_extensions import Self + +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 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) + + +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="") + + +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/otp.py b/src/contiguity/otp.py new file mode 100644 index 0000000..4bdf248 --- /dev/null +++ b/src/contiguity/otp.py @@ -0,0 +1,75 @@ +import phonenumbers + +from contiguity._client import ApiClient + + +class OTP: + def __init__(self, *, client: ApiClient, debug: bool = False) -> None: + self._client = client + self.debug = debug + + def send(self, to, /, *, language, name=""): + e164 = phonenumbers.format_number(phonenumbers.parse(to), phonenumbers.PhoneNumberFormat.E164) + + otp_handler = self._client.post( + "/otp/new", + json={ + "to": e164, + "language": language, + "name": name, + }, + ) + + 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 {to} with OTP ID {otp_handler_response['otp_id']}") + + return otp_handler_response["otp_id"] + + def verify(self, otp, id): + otp_handler = self._client.post( + "/otp/verify", + json={ + "otp": otp, + "otp_id": id, + }, + ) + + 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 ({otp}) with boolean verified status: {otp_handler_response['verified']}" + ) + + return otp_handler_response["verified"] + + def resend(self, id, /): + otp_handler = self._client.post( + "/otp/resend", + json={ + "otp_id": id, + }, + ) + + 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 ({id}) with boolean resent status: {otp_handler_response['verified']}" + ) + + return otp_handler_response["verified"] 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..e7dcf4a --- /dev/null +++ b/src/contiguity/quota.py @@ -0,0 +1,23 @@ +import json + +from contiguity._client import ApiClient + + +class Quota: + def __init__(self, *, client: ApiClient, debug: bool = False) -> None: + self._client = client + self.debug = debug + + def retrieve(self): + quota = self._client.get("/user/get/quota") + + 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 diff --git a/src/contiguity/send.py b/src/contiguity/send.py new file mode 100644 index 0000000..7a14adb --- /dev/null +++ b/src/contiguity/send.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import json +from typing import overload + +import phonenumbers +from htmlmin import minify + +from contiguity._client import ApiClient + + +class Send: + def __init__(self, *, client: ApiClient, debug: bool = False) -> None: + self._client = client + self.debug = debug + + def text(self, to: str, message: str): + """ + 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): + 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 = self._client.post( + "/send/text", + json={ + "to": phonenumbers.format_number(parsed_number, phonenumbers.PhoneNumberFormat.E164), + "message": message, + }, + ) + 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 {to}. Crumbs:\n\n{json.dumps(text_handler_response)}") + + return text_handler_response + + @overload + def email( + self, + *, + to: str, + from_: str, + subject: str, + text: str, + reply_to: str = "", + cc: str = "", + ): ... + + @overload + def email( + self, + *, + to: str, + from_: str, + subject: str, + html: str, + reply_to: str = "", + cc: str = "", + ): ... + + def email( + self, + *, + to: str, + from_: str, + subject: str, + reply_to: str = "", + cc: str = "", + text: str | None = None, + html: str | None = None, + ): + """ + 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. + """ + 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 + + email_handler = self._client.post("/send/email", json=email_payload) + + 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 {to}. Crumbs:\n\n{json.dumps(email_handler_response)}") + + return email_handler_response diff --git a/src/contiguity/template.py b/src/contiguity/template.py new file mode 100644 index 0000000..cd0971d --- /dev/null +++ b/src/contiguity/template.py @@ -0,0 +1,16 @@ +from htmlmin import minify + + +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 + raise NotImplementedError diff --git a/src/contiguity/verify.py b/src/contiguity/verify.py new file mode 100644 index 0000000..692f258 --- /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 Exception: + return False + + def email(self, email: str) -> bool: + return bool(EMAIL_REGEX.match(email)) 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/test_base_dict_typed.py b/tests/test_base_dict_typed.py new file mode 100644 index 0000000..1102c37 --- /dev/null +++ b/tests/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_base 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/test_base_dict_untyped.py b/tests/test_base_dict_untyped.py new file mode 100644 index 0000000..9b144cc --- /dev/null +++ b/tests/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_base 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/test_base_model.py b/tests/test_base_model.py new file mode 100644 index 0000000..21e936a --- /dev/null +++ b/tests/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_base 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/test_base_typeddict.py b/tests/test_base_typeddict.py new file mode 100644 index 0000000..3b7681f --- /dev/null +++ b/tests/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_base 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..2ab83be --- /dev/null +++ b/uv.lock @@ -0,0 +1,561 @@ +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 = "1.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-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-cov", specifier = "~=5.0.0" }, + { name = "python-dotenv", specifier = "~=1.0.1" }, +] + +[[package]] +name = "coverage" +version = "7.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690 }, + { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127 }, + { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654 }, + { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598 }, + { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732 }, + { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816 }, + { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325 }, + { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418 }, + { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343 }, + { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136 }, + { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796 }, + { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244 }, + { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 }, + { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 }, + { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 }, + { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 }, + { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 }, + { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 }, + { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348 }, + { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 }, + { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 }, + { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 }, + { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 }, + { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 }, + { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 }, + { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 }, + { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 }, + { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 }, + { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 }, + { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 }, + { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 }, + { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 }, + { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 }, + { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 }, + { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 }, + { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 }, + { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 }, + { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 }, + { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 }, + { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 }, + { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 }, + { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 }, + { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 }, + { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 }, + { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 }, + { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 }, + { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 }, + { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 }, + { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 }, + { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688 }, + { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120 }, + { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249 }, + { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237 }, + { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311 }, + { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453 }, + { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958 }, + { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938 }, + { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352 }, + { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153 }, + { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distlib" +version = "0.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/91/e2df406fb4efacdf46871c25cde65d3c6ee5e173b7e5a4547a47bae91920/distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64", size = 609931 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850 }, +] + +[[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.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/b0/5e8b8674f8d203335a62fdfcfa0d11ebe09e23613c3391033cbba35f7926/httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61", size = 83234 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/d4/e5d7e4f2174f8a4d63c8897d79eb8fe2503f7ecc03282fee1fa2719c2704/httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5", size = 77926 }, +] + +[[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-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.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, +] + +[[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 }, +] From 017ff3c967e9690ef456fe89e5810d58a755ae13 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Sun, 13 Oct 2024 03:02:27 -0700 Subject: [PATCH 02/12] Update packages --- uv.lock | 146 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/uv.lock b/uv.lock index 2ab83be..80b4d2e 100644 --- a/uv.lock +++ b/uv.lock @@ -58,7 +58,7 @@ wheels = [ [[package]] name = "contiguity" -version = "1.0.0" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "htmlmin" }, @@ -95,71 +95,71 @@ dev = [ [[package]] name = "coverage" -version = "7.6.1" +version = "7.6.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } +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/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690 }, - { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127 }, - { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654 }, - { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598 }, - { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732 }, - { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816 }, - { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325 }, - { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418 }, - { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343 }, - { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136 }, - { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796 }, - { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244 }, - { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 }, - { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 }, - { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 }, - { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 }, - { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 }, - { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 }, - { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348 }, - { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230 }, - { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 }, - { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 }, - { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 }, - { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 }, - { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 }, - { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 }, - { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 }, - { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 }, - { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 }, - { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 }, - { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 }, - { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 }, - { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 }, - { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 }, - { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 }, - { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 }, - { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 }, - { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 }, - { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 }, - { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 }, - { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 }, - { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 }, - { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 }, - { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 }, - { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 }, - { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 }, - { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 }, - { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 }, - { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 }, - { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 }, - { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688 }, - { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120 }, - { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249 }, - { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237 }, - { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311 }, - { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453 }, - { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958 }, - { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938 }, - { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352 }, - { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153 }, - { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926 }, + { 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] @@ -169,11 +169,11 @@ toml = [ [[package]] name = "distlib" -version = "0.3.8" +version = "0.3.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/91/e2df406fb4efacdf46871c25cde65d3c6ee5e173b7e5a4547a47bae91920/distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64", size = 609931 } +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/8e/41/9307e4f5f9976bc8b7fea0b66367734e8faf3ec84bc0d412d8cfabbb66cd/distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784", size = 468850 }, + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, ] [[package]] @@ -211,15 +211,15 @@ sdist = { url = "https://files.pythonhosted.org/packages/b3/e7/fcd59e12169de19f0 [[package]] name = "httpcore" -version = "1.0.5" +version = "1.0.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/b0/5e8b8674f8d203335a62fdfcfa0d11ebe09e23613c3391033cbba35f7926/httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61", size = 83234 } +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/78/d4/e5d7e4f2174f8a4d63c8897d79eb8fe2503f7ecc03282fee1fa2719c2704/httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5", size = 77926 }, + { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 }, ] [[package]] @@ -530,11 +530,11 @@ wheels = [ [[package]] name = "tomli" -version = "2.0.1" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/3f/d7af728f075fb08564c5949a9c95e44352e23dee646869fa104a3b2060a3/tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f", size = 15164 } +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/97/75/10a9ebee3fd790d20926a90a2547f0bf78f371b2f13aa822c759680ca7b9/tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", size = 12757 }, + { url = "https://files.pythonhosted.org/packages/cf/db/ce8eda256fa131af12e0a76d481711abe4681b6923c27efb9a255c9e4594/tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38", size = 13237 }, ] [[package]] From aba87245f0d33f1c92795a95660fb85c69d28edf Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Sun, 13 Oct 2024 03:02:38 -0700 Subject: [PATCH 03/12] Ignore print lint rule --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 60d14a6..28f8abf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ target-version = "py39" [tool.ruff.lint] select = ["ALL"] -ignore = ["A", "D"] +ignore = ["A", "D", "T201"] [tool.pyright] venvPath = "." From 86163d353fb7b3d6894c3edaa2db2d17ccfab55f Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Sun, 13 Oct 2024 03:07:26 -0700 Subject: [PATCH 04/12] Organize tests --- tests/base/__init__.py | 0 tests/{ => base}/test_base_dict_typed.py | 2 +- tests/{ => base}/test_base_dict_untyped.py | 2 +- tests/{ => base}/test_base_model.py | 2 +- tests/{ => base}/test_base_typeddict.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 tests/base/__init__.py rename tests/{ => base}/test_base_dict_typed.py (98%) rename tests/{ => base}/test_base_dict_untyped.py (97%) rename tests/{ => base}/test_base_model.py (97%) rename tests/{ => base}/test_base_typeddict.py (98%) diff --git a/tests/base/__init__.py b/tests/base/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_base_dict_typed.py b/tests/base/test_base_dict_typed.py similarity index 98% rename from tests/test_base_dict_typed.py rename to tests/base/test_base_dict_typed.py index 1102c37..1c3553a 100644 --- a/tests/test_base_dict_typed.py +++ b/tests/base/test_base_dict_typed.py @@ -7,7 +7,7 @@ from dotenv import load_dotenv from pydantic import JsonValue -from contiguity_base import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse +from contiguity import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse from tests import random_string load_dotenv() diff --git a/tests/test_base_dict_untyped.py b/tests/base/test_base_dict_untyped.py similarity index 97% rename from tests/test_base_dict_untyped.py rename to tests/base/test_base_dict_untyped.py index 9b144cc..37bce0c 100644 --- a/tests/test_base_dict_untyped.py +++ b/tests/base/test_base_dict_untyped.py @@ -7,7 +7,7 @@ from dotenv import load_dotenv from pydantic import JsonValue -from contiguity_base import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse +from contiguity import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse from tests import random_string load_dotenv() diff --git a/tests/test_base_model.py b/tests/base/test_base_model.py similarity index 97% rename from tests/test_base_model.py rename to tests/base/test_base_model.py index 21e936a..d299318 100644 --- a/tests/test_base_model.py +++ b/tests/base/test_base_model.py @@ -7,7 +7,7 @@ from dotenv import load_dotenv from pydantic import BaseModel -from contiguity_base import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse +from contiguity import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse from tests import random_string load_dotenv() diff --git a/tests/test_base_typeddict.py b/tests/base/test_base_typeddict.py similarity index 98% rename from tests/test_base_typeddict.py rename to tests/base/test_base_typeddict.py index 3b7681f..39e4003 100644 --- a/tests/test_base_typeddict.py +++ b/tests/base/test_base_typeddict.py @@ -8,7 +8,7 @@ from dotenv import load_dotenv from typing_extensions import TypedDict -from contiguity_base import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse +from contiguity import Base, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse from tests import random_string if TYPE_CHECKING: From a8b9dcef7f96d991c9981c0bdc8358e5f3628e1d Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Sun, 13 Oct 2024 03:08:12 -0700 Subject: [PATCH 05/12] Fix some linting issues --- src/contiguity/__init__.py | 7 ++-- src/contiguity/analytics.py | 18 +++++----- src/contiguity/otp.py | 61 +++++++++++++++++----------------- src/contiguity/quota.py | 19 ++++++----- src/contiguity/send.py | 65 ++++++++++++++++++++----------------- src/contiguity/template.py | 20 +++++++----- src/contiguity/verify.py | 4 +-- 7 files changed, 104 insertions(+), 90 deletions(-) diff --git a/src/contiguity/__init__.py b/src/contiguity/__init__.py index 2e4af1a..82820ca 100644 --- a/src/contiguity/__init__.py +++ b/src/contiguity/__init__.py @@ -20,16 +20,17 @@ def __init__( self, *, token: str, - debug: bool = False, base_url: str = "https://api.contiguity.co", orwell_base_url: str = "https://orwell.contiguity.co", + debug: bool = False, ) -> None: if not token: - raise ValueError("Contiguity requires a token/API key to be provided via contiguity.login('token')") + msg = "Contiguity requires a token/API key to be provided via contiguity.login('token')" + raise ValueError(msg) self.token = token - self.debug = debug 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()) diff --git a/src/contiguity/analytics.py b/src/contiguity/analytics.py index f06a40d..4ef1152 100644 --- a/src/contiguity/analytics.py +++ b/src/contiguity/analytics.py @@ -1,4 +1,4 @@ -import json +from http import HTTPStatus from ._client import ApiClient @@ -10,15 +10,15 @@ def __init__(self, *, client: ApiClient, debug: bool = False) -> None: def retrieve(self, id: str): if not id: - raise ValueError("Contiguity Analytics requires an email ID.") + msg = "Contiguity Analytics requires an email ID." + raise ValueError(msg) - status = self._client.get(f"/email/status/{id}") + response = self._client.get(f"/email/status/{id}") - json_data = status.json() - - if status.status_code != 200: - raise ValueError(f"Contiguity Analytics couldn't find an email with ID {id}") + 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\n{json.dumps(json_data)}") + print(f"Contiguity successfully found your email. Data:\n{response.text}") - return json_data + return response.json() diff --git a/src/contiguity/otp.py b/src/contiguity/otp.py index 4bdf248..2470b11 100644 --- a/src/contiguity/otp.py +++ b/src/contiguity/otp.py @@ -1,6 +1,8 @@ +from http import HTTPStatus + import phonenumbers -from contiguity._client import ApiClient +from ._client import ApiClient class OTP: @@ -8,10 +10,10 @@ def __init__(self, *, client: ApiClient, debug: bool = False) -> None: self._client = client self.debug = debug - def send(self, to, /, *, language, name=""): + def send(self, to: str, /, *, language, name: str = ""): e164 = phonenumbers.format_number(phonenumbers.parse(to), phonenumbers.PhoneNumberFormat.E164) - otp_handler = self._client.post( + response = self._client.post( "/otp/new", json={ "to": e164, @@ -19,57 +21,56 @@ def send(self, to, /, *, language, name=""): "name": name, }, ) + json_data = response.json() - 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 response.status_code != HTTPStatus.OK: + msg = ( + "Contiguity couldn't send your OTP." + f" Received: {response.status_code} with reason: '{json_data['message']}'" ) + raise ValueError(msg) if self.debug: - print(f"Contiguity successfully sent your OTP to {to} with OTP ID {otp_handler_response['otp_id']}") + print(f"Contiguity successfully sent your OTP to {to} with OTP ID {json_data['otp_id']}") - return otp_handler_response["otp_id"] + return json_data["otp_id"] def verify(self, otp, id): - otp_handler = self._client.post( + response = self._client.post( "/otp/verify", json={ "otp": otp, "otp_id": id, }, ) + json_data = response.json() - 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 response.status_code != HTTPStatus.OK: + msg = ( + "Contiguity couldn't verify your OTP." + f" Received: {response.status_code} with reason: '{json_data['message']}'" ) + raise ValueError(msg) if self.debug: - print( - f"Contiguity 'verified' your OTP ({otp}) with boolean verified status: {otp_handler_response['verified']}" - ) + print(f"Contiguity verified your OTP ({otp}) with status: {json_data['verified']}") - return otp_handler_response["verified"] + return json_data["verified"] def resend(self, id, /): - otp_handler = self._client.post( + response = self._client.post( "/otp/resend", json={ "otp_id": id, }, ) + json_data = response.json() - 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 response.status_code != HTTPStatus.OK: + msg = ( + "Contiguity couldn't resend your OTP." + f" Received: {response.status_code} with reason: '{json_data['message']}'" ) + raise ValueError(msg) if self.debug: - print( - f"Contiguity resent your OTP ({id}) with boolean resent status: {otp_handler_response['verified']}" - ) + print(f"Contiguity resent your OTP ({id}) with status: {json_data['verified']}") - return otp_handler_response["verified"] + return json_data["verified"] diff --git a/src/contiguity/quota.py b/src/contiguity/quota.py index e7dcf4a..5502fc6 100644 --- a/src/contiguity/quota.py +++ b/src/contiguity/quota.py @@ -1,6 +1,6 @@ -import json +from http import HTTPStatus -from contiguity._client import ApiClient +from ._client import ApiClient class Quota: @@ -9,15 +9,16 @@ def __init__(self, *, client: ApiClient, debug: bool = False) -> None: self.debug = debug def retrieve(self): - quota = self._client.get("/user/get/quota") + response = self._client.get("/user/get/quota") + json_data = response.json() - 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 response.status_code != HTTPStatus.OK: + msg = ( + "Contiguity had an issue finding your quota." + f" Received {response.status_code} with reason: '{json_data['message']}'" ) + raise ValueError(msg) if self.debug: - print(f"Contiguity successfully found your quota. Data:\n\n{json.dumps(json_data)}") + print(f"Contiguity successfully found your quota. Data:\n{response.text}") return json_data diff --git a/src/contiguity/send.py b/src/contiguity/send.py index 7a14adb..2a76fc8 100644 --- a/src/contiguity/send.py +++ b/src/contiguity/send.py @@ -1,12 +1,13 @@ from __future__ import annotations -import json -from typing import overload +from http import HTTPStatus +from typing import TYPE_CHECKING, overload import phonenumbers from htmlmin import minify -from contiguity._client import ApiClient +if TYPE_CHECKING: + from ._client import ApiClient class Send: @@ -28,27 +29,31 @@ def text(self, to: str, message: str): try: parsed_number = phonenumbers.parse(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.") + 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 - text_handler = self._client.post( + response = self._client.post( "/send/text", json={ "to": phonenumbers.format_number(parsed_number, phonenumbers.PhoneNumberFormat.E164), "message": message, }, ) - text_handler_response = text_handler.json() + json_data = response.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 response.status_code != HTTPStatus.OK: + msg = ( + "Contiguity couldn't send your message." + f" Received: {response.status_code} with reason: '{json_data['message']}'" ) + raise ValueError(msg) if self.debug: - print(f"Contiguity successfully sent your text to {to}. Crumbs:\n\n{json.dumps(text_handler_response)}") + print(f"Contiguity successfully sent your text to {to}. Crumbs:\n{response.text}") - return text_handler_response + return json_data @overload def email( @@ -88,14 +93,15 @@ def email( """ 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. + 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: @@ -115,15 +121,16 @@ def email( if cc: email_payload["cc"] = cc - email_handler = self._client.post("/send/email", json=email_payload) + response = self._client.post("/send/email", json=email_payload) + json_data = response.json() - 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 response.status_code != HTTPStatus.OK: + msg = ( + "Contiguity couldn't send your email." + f" Received: {response.status_code} with reason: '{json_data['message']}'" ) + raise ValueError(msg) if self.debug: - print(f"Contiguity successfully sent your email to {to}. Crumbs:\n\n{json.dumps(email_handler_response)}") + print(f"Contiguity successfully sent your email to {to}. Crumbs:\n{response.text}") - return email_handler_response + return json_data diff --git a/src/contiguity/template.py b/src/contiguity/template.py index cd0971d..3e868c1 100644 --- a/src/contiguity/template.py +++ b/src/contiguity/template.py @@ -1,16 +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): + def local(self, file_path: Path | str) -> str: 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.") + file_path = Path(file_path) + return minify(file_path.read_text()) + except OSError as exc: + msg = "Getting contents from files is not supported in the current environment." + raise ValueError(msg) from exc - async def online(self, file_name): + async def online(self, file_path: str) -> Never: # Coming soon raise NotImplementedError diff --git a/src/contiguity/verify.py b/src/contiguity/verify.py index 692f258..413a783 100644 --- a/src/contiguity/verify.py +++ b/src/contiguity/verify.py @@ -9,8 +9,8 @@ class Verify: def number(self, number: str) -> bool: try: return phonenumbers.is_valid_number(phonenumbers.parse(number)) - except Exception: + except phonenumbers.NumberParseException: return False def email(self, email: str) -> bool: - return bool(EMAIL_REGEX.match(email)) + return EMAIL_REGEX.match(email) is not None From f4ccba14be02f6631d1c443dc0a60b64482f4899 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Sun, 13 Oct 2024 03:10:05 -0700 Subject: [PATCH 06/12] Format README --- README.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) 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) From b4c4aa80b68e31cb477cbc6d3d82c68be89fce65 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Sun, 13 Oct 2024 03:55:51 -0700 Subject: [PATCH 07/12] Add async Base --- pyproject.toml | 4 + src/contiguity/__init__.py | 3 +- src/contiguity/base/__init__.py | 6 +- src/contiguity/base/async_base.py | 202 +++++---------------- src/contiguity/base/base.py | 158 +++------------- src/contiguity/base/common.py | 121 ++++++++++++ src/contiguity/base/exceptions.py | 16 ++ tests/base/test_async_base_dict_typed.py | 171 +++++++++++++++++ tests/base/test_async_base_dict_untyped.py | 165 +++++++++++++++++ tests/base/test_async_base_model.py | 156 ++++++++++++++++ tests/base/test_async_base_typeddict.py | 183 +++++++++++++++++++ uv.lock | 14 ++ 12 files changed, 907 insertions(+), 292 deletions(-) create mode 100644 src/contiguity/base/common.py create mode 100644 src/contiguity/base/exceptions.py create mode 100644 tests/base/test_async_base_dict_typed.py create mode 100644 tests/base/test_async_base_dict_untyped.py create mode 100644 tests/base/test_async_base_model.py create mode 100644 tests/base/test_async_base_typeddict.py diff --git a/pyproject.toml b/pyproject.toml index 28f8abf..80e29b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dev-dependencies = [ "pytest~=8.3.3", "pytest-cov~=5.0.0", "python-dotenv~=1.0.1", + "pytest-asyncio~=0.24.0", ] [tool.ruff] @@ -71,3 +72,6 @@ ignore = ["A", "D", "T201"] venvPath = "." venv = ".venv" reportUnnecessaryTypeIgnoreComment = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/src/contiguity/__init__.py b/src/contiguity/__init__.py index 82820ca..b6658d7 100644 --- a/src/contiguity/__init__.py +++ b/src/contiguity/__init__.py @@ -1,6 +1,6 @@ from ._client import ApiClient from .analytics import EmailAnalytics -from .base import Base, BaseItem, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse +from .base import AsyncBase, Base, BaseItem, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse from .otp import OTP from .quota import Quota from .send import Send @@ -47,6 +47,7 @@ def login(token: str, /, *, debug: bool = False) -> Contiguity: __all__ = ( + "AsyncBase", "Contiguity", "Send", "Verify", diff --git a/src/contiguity/base/__init__.py b/src/contiguity/base/__init__.py index fd12e2b..8aada10 100644 --- a/src/contiguity/base/__init__.py +++ b/src/contiguity/base/__init__.py @@ -1,6 +1,10 @@ -from .base import Base, BaseItem, InvalidKeyError, ItemConflictError, ItemNotFoundError, QueryResponse +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", diff --git a/src/contiguity/base/async_base.py b/src/contiguity/base/async_base.py index 209577d..3a0c3c1 100644 --- a/src/contiguity/base/async_base.py +++ b/src/contiguity/base/async_base.py @@ -5,8 +5,7 @@ from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone from http import HTTPStatus -from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, Union, overload -from urllib.parse import quote +from typing import TYPE_CHECKING, Generic, Literal, overload from warnings import warn from httpx import HTTPStatusError @@ -15,138 +14,29 @@ from typing_extensions import deprecated from contiguity._auth import get_data_key, get_project_id -from contiguity._client import ApiClient, ApiError +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 -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 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) - - -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="") - - -class Base(Generic[ItemT]): +class AsyncBase(Generic[ItemT]): EXPIRES_ATTRIBUTE = "__expires" PUT_LIMIT = 30 @@ -203,8 +93,8 @@ def __init__( # noqa: PLR0913 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( + 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, @@ -270,25 +160,25 @@ def _insert_expires_attr( return item_dict @overload - def get(self: Self, key: str, /) -> ItemT | None: ... + async def get(self: Self, key: str, /) -> ItemT | None: ... @overload - def get(self: Self, key: str, /, *, default: ItemT) -> ItemT: ... + async def get(self: Self, key: str, /, *, default: ItemT) -> ItemT: ... @overload - def get(self: Self, key: str, /, *, default: DefaultItemT) -> ItemT | DefaultItemT: ... + async def get(self: Self, key: str, /, *, default: DefaultItemT) -> ItemT | DefaultItemT: ... - def get( + async def get( self: Self, key: str, /, *, - default: ItemT | DefaultItemT | _Unset = _UNSET, + default: ItemT | DefaultItemT | Unset = UNSET, ) -> ItemT | DefaultItemT | None: - key = _check_key(key) - response = self._client.get(f"/items/{key}") + key = check_key(key) + response = await self._client.get(f"/items/{key}") if response.status_code == HTTPStatus.NOT_FOUND: - if not isinstance(default, _Unset): + if not isinstance(default, Unset): return default msg = ( "ItemNotFoundError will be raised in the future." @@ -299,16 +189,16 @@ def get( return self._response_as_item_type(response, sequence=False) - def delete(self: Self, key: str, /) -> None: + async def delete(self: Self, key: str, /) -> None: """Delete an item from the Base.""" - key = _check_key(key) - response = self._client.delete(f"/items/{key}") + 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 - def insert( + async def insert( self: Self, item: ItemT, /, @@ -317,7 +207,7 @@ def insert( 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}) + response = await self._client.post("/items", json={"item": item_dict}) if response.status_code == HTTPStatus.CONFLICT: raise ItemConflictError(str(item_dict.get("key"))) @@ -327,7 +217,7 @@ def insert( raise ApiError(msg) return returned_item[0] - def put( + async def put( self: Self, *items: ItemT, expire_in: int | None = None, @@ -344,11 +234,11 @@ def put( 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}) + 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`.") - def put_many( + async def put_many( self: Self, items: Sequence[ItemT], /, @@ -356,11 +246,11 @@ def put_many( expire_in: int | None = None, expire_at: TimestampType | None = None, ) -> Sequence[ItemT]: - return self.put(*items, expire_in=expire_in, expire_at=expire_at) + return await self.put(*items, expire_in=expire_in, expire_at=expire_at) - def update( + async def update( self: Self, - updates: Mapping[str, DataType | _UpdateOperation], + updates: Mapping[str, DataType | UpdateOperation], /, *, key: str, @@ -371,25 +261,25 @@ def update( `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) + key = check_key(key) if not updates: msg = "no updates provided" raise ValueError(msg) - payload = _UpdatePayload.from_updates_mapping(updates) + 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()}) + 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) - def query( + async def query( self: Self, *queries: QueryType, limit: int = 1000, @@ -407,7 +297,7 @@ def query( if queries: payload["query"] = queries - response = self._client.post("/query", json=payload) + response = await self._client.post("/query", json=payload) try: response.raise_for_status() except HTTPStatusError as exc: @@ -419,10 +309,10 @@ def query( return query_response @deprecated("This method has been renamed to `query` and will be removed in a future release.") - def fetch( + async def fetch( self: Self, *queries: QueryType, limit: int = 1000, last: str | None = None, ) -> QueryResponse[ItemT]: - return self.query(*queries, limit=limit, last=last) + return await self.query(*queries, limit=limit, last=last) diff --git a/src/contiguity/base/base.py b/src/contiguity/base/base.py index 5967016..819899d 100644 --- a/src/contiguity/base/base.py +++ b/src/contiguity/base/base.py @@ -14,8 +14,7 @@ from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone from http import HTTPStatus -from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, Union, overload -from urllib.parse import quote +from typing import TYPE_CHECKING, Generic, Literal, overload from warnings import warn from httpx import HTTPStatusError @@ -26,134 +25,25 @@ 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 -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 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) - - -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="") - class Base(Generic[ItemT]): EXPIRES_ATTRIBUTE = "__expires" @@ -212,7 +102,7 @@ def __init__( # noqa: PLR0913 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.util = Updates() self._client = ApiClient( base_url=f"https://{self.host}/{api_version}/{self.project_id}/{self.name}", api_key=self.data_key, @@ -292,12 +182,12 @@ def get( key: str, /, *, - default: ItemT | DefaultItemT | _Unset = _UNSET, + default: ItemT | DefaultItemT | Unset = UNSET, ) -> ItemT | DefaultItemT | None: - key = _check_key(key) + key = check_key(key) response = self._client.get(f"/items/{key}") if response.status_code == HTTPStatus.NOT_FOUND: - if not isinstance(default, _Unset): + if not isinstance(default, Unset): return default msg = ( "ItemNotFoundError will be raised in the future." @@ -310,7 +200,7 @@ def get( def delete(self: Self, key: str, /) -> None: """Delete an item from the Base.""" - key = _check_key(key) + key = check_key(key) response = self._client.delete(f"/items/{key}") try: response.raise_for_status() @@ -369,7 +259,7 @@ def put_many( def update( self: Self, - updates: Mapping[str, DataType | _UpdateOperation], + updates: Mapping[str, DataType | UpdateOperation], /, *, key: str, @@ -380,12 +270,12 @@ def update( `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) + key = check_key(key) if not updates: msg = "no updates provided" raise ValueError(msg) - payload = _UpdatePayload.from_updates_mapping(updates) + payload = UpdatePayload.from_updates_mapping(updates) payload.set = self._insert_expires_attr( payload.set, expire_in=expire_in, 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/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/uv.lock b/uv.lock index 80b4d2e..0d553a6 100644 --- a/uv.lock +++ b/uv.lock @@ -72,6 +72,7 @@ dependencies = [ dev = [ { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "python-dotenv" }, ] @@ -89,6 +90,7 @@ requires-dist = [ 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" }, ] @@ -444,6 +446,18 @@ 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" From 9e02b6f57aa50cb4912e6e58219e73bfb6afe370 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:16:55 -0700 Subject: [PATCH 08/12] Add pydantic models to API methods, deprecate quota and analytics --- src/contiguity/_common.py | 8 +++ src/contiguity/analytics.py | 10 ++- src/contiguity/otp.py | 119 ++++++++++++++++++++++++++++-------- src/contiguity/quota.py | 13 ++-- src/contiguity/send.py | 40 ++++++++---- src/contiguity/template.py | 2 +- 6 files changed, 144 insertions(+), 48 deletions(-) create mode 100644 src/contiguity/_common.py 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 index 4ef1152..1408d2c 100644 --- a/src/contiguity/analytics.py +++ b/src/contiguity/analytics.py @@ -1,5 +1,7 @@ from http import HTTPStatus +from typing_extensions import deprecated + from ._client import ApiClient @@ -8,17 +10,19 @@ def __init__(self, *, client: ApiClient, debug: bool = False) -> None: self._client = client self.debug = debug - def retrieve(self, id: str): + @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{response.text}") + print(f"Contiguity successfully found your email. Data:\n{data}") - return response.json() + return data diff --git a/src/contiguity/otp.py b/src/contiguity/otp.py index 2470b11..7e00cfd 100644 --- a/src/contiguity/otp.py +++ b/src/contiguity/otp.py @@ -1,8 +1,71 @@ +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 + -from ._client import ApiClient +class OTPVerifyResponse(BaseModel): + message: str + verified: bool class OTP: @@ -10,7 +73,14 @@ def __init__(self, *, client: ApiClient, debug: bool = False) -> None: self._client = client self.debug = debug - def send(self, to: str, /, *, language, name: str = ""): + 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( @@ -21,56 +91,53 @@ def send(self, to: str, /, *, language, name: str = ""): "name": name, }, ) - json_data = response.json() + data = OTPSendResponse.model_validate_json(response.content) if response.status_code != HTTPStatus.OK: - msg = ( - "Contiguity couldn't send your OTP." - f" Received: {response.status_code} with reason: '{json_data['message']}'" - ) + 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 {json_data['otp_id']}") + print(f"Contiguity successfully sent your OTP to {to} with OTP ID {data.otp_id}") - return json_data["otp_id"] + return data - def verify(self, otp, id): + def resend(self, otp_id: str, /) -> OTPResendResponse: response = self._client.post( - "/otp/verify", + "/otp/resend", json={ - "otp": otp, - "otp_id": id, + "otp_id": otp_id, }, ) - json_data = response.json() + data = OTPResendResponse.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: '{json_data['message']}'" + "Contiguity couldn't resend 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: {json_data['verified']}") + print(f"Contiguity resent your OTP ({id}) with status: {data.resent}") - return json_data["verified"] + return data - def resend(self, id, /): + def verify(self, otp: int | str, /, *, otp_id: str) -> OTPVerifyResponse: response = self._client.post( - "/otp/resend", + "/otp/verify", json={ - "otp_id": id, + "otp": str(otp), + "otp_id": otp_id, }, ) - json_data = response.json() + data = OTPVerifyResponse.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: '{json_data['message']}'" + "Contiguity couldn't verify 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: {json_data['verified']}") + print(f"Contiguity verified your OTP ({otp}) with status: {data.verified}") - return json_data["verified"] + return data diff --git a/src/contiguity/quota.py b/src/contiguity/quota.py index 5502fc6..53f0602 100644 --- a/src/contiguity/quota.py +++ b/src/contiguity/quota.py @@ -1,5 +1,7 @@ from http import HTTPStatus +from typing_extensions import deprecated + from ._client import ApiClient @@ -8,17 +10,18 @@ def __init__(self, *, client: ApiClient, debug: bool = False) -> None: self._client = client self.debug = debug - def retrieve(self): + @deprecated("quota functionality will be removed in a future release") + def retrieve(self) -> dict: response = self._client.get("/user/get/quota") - json_data = response.json() + data = response.json() if response.status_code != HTTPStatus.OK: msg = ( "Contiguity had an issue finding your quota." - f" Received {response.status_code} with reason: '{json_data['message']}'" + f" Received {response.status_code} with reason: '{data['message']}'" ) raise ValueError(msg) if self.debug: - print(f"Contiguity successfully found your quota. Data:\n{response.text}") + print(f"Contiguity successfully found your quota. Data:\n{data}") - return json_data + return data diff --git a/src/contiguity/send.py b/src/contiguity/send.py index 2a76fc8..42cbf2f 100644 --- a/src/contiguity/send.py +++ b/src/contiguity/send.py @@ -5,17 +5,31 @@ 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): + def text(self, to: str, message: str) -> TextResponse: """ Send a text message. Args: @@ -42,18 +56,18 @@ def text(self, to: str, message: str): "message": message, }, ) - json_data = response.json() + 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: '{json_data['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{response.text}") + print(f"Contiguity successfully sent your text to {to}. Crumbs:\n{data.crumbs}") - return json_data + return data @overload def email( @@ -65,7 +79,7 @@ def email( text: str, reply_to: str = "", cc: str = "", - ): ... + ) -> EmailResponse: ... @overload def email( @@ -77,9 +91,9 @@ def email( html: str, reply_to: str = "", cc: str = "", - ): ... + ) -> EmailResponse: ... - def email( + def email( # noqa: PLR0913 self, *, to: str, @@ -89,7 +103,7 @@ def email( cc: str = "", text: str | None = None, html: str | None = None, - ): + ) -> EmailResponse: """ Send an email. Args: @@ -122,15 +136,15 @@ def email( email_payload["cc"] = cc response = self._client.post("/send/email", json=email_payload) - json_data = response.json() + 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: '{json_data['message']}'" + 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{response.text}") + print(f"Contiguity successfully sent your email to {to}. Crumbs:\n{data.crumbs}") - return json_data + return data diff --git a/src/contiguity/template.py b/src/contiguity/template.py index 3e868c1..32263aa 100644 --- a/src/contiguity/template.py +++ b/src/contiguity/template.py @@ -12,7 +12,7 @@ def local(self, file_path: Path | str) -> str: file_path = Path(file_path) return minify(file_path.read_text()) except OSError as exc: - msg = "Getting contents from files is not supported in the current environment." + msg = "reading files is not supported in the this environment" raise ValueError(msg) from exc async def online(self, file_path: str) -> Never: From 0e961b1cff764eea57f8d891c47ce9fe46f7d9ad Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:46:52 -0700 Subject: [PATCH 09/12] Add GitHub Actions CI --- .github/workflows/build.yaml | 32 +++++++++++++++++++ .github/workflows/ci.yaml | 31 ++++++++++++++++++ .github/workflows/lint.yaml | 32 +++++++++++++++++++ .github/workflows/release.yaml | 58 ++++++++++++++++++++++++++++++++++ .github/workflows/test.yaml | 32 +++++++++++++++++++ 5 files changed, 185 insertions(+) create mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/lint.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..4061d76 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,32 @@ +name: Build + +on: + # push: + workflow_call: + workflow_dispatch: + +jobs: + build: + name: Build source distribution + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Build source distribution + 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..ae03f3d --- /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..f99e77a --- /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..066fb5c --- /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 From c2bffd01f4ee391fccdac126d38d908f558dda68 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:49:42 -0700 Subject: [PATCH 10/12] Fix trailing whitespace --- .github/workflows/release.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index f99e77a..8dae8cd 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -28,7 +28,7 @@ jobs: with: name: wheels path: ./dist - + - name: Create release uses: softprops/action-gh-release@v2 with: From 89a43598a20ba20a8fbed8be9de83d61a2b41783 Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:58:55 -0700 Subject: [PATCH 11/12] Remove unnecessary workflow triggers --- .github/workflows/lint.yaml | 4 ++-- .github/workflows/test.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index ae03f3d..4f19eb6 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -1,8 +1,8 @@ name: Lint on: - push: - pull_request: + # push: + # pull_request: workflow_call: workflow_dispatch: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 066fb5c..6c244b8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,8 +1,8 @@ name: Test on: - push: - pull_request: + # push: + # pull_request: workflow_call: workflow_dispatch: From b14956652fa14b0da783fc55cc1c2550c2857c3d Mon Sep 17 00:00:00 2001 From: Lemonyte <49930425+lemonyte@users.noreply.github.com> Date: Tue, 15 Oct 2024 19:01:13 -0700 Subject: [PATCH 12/12] Adjust build workflow --- .github/workflows/build.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4061d76..2f1a15d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -7,7 +7,6 @@ on: jobs: build: - name: Build source distribution runs-on: ubuntu-latest steps: - name: Checkout repository @@ -16,7 +15,7 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v3 - - name: Build source distribution + - name: Build distributions run: uv build - name: Upload sdist artifact