diff --git a/Makefile b/Makefile index d5c6651..1689252 100644 --- a/Makefile +++ b/Makefile @@ -20,10 +20,20 @@ patch_templates: rm -rf $$VENV tests: - uv run pytest -rs -m e2e + @set -e; \ + uv run pytest -rs -v -s -m e2e || exit 0; \ + $(MAKE) clean-tests + clean-tests: - @source .venv/bin/activate && \ + @. .venv/bin/activate && \ appwrite-lab stop test-lab -.PHONY: patch_templates tests clean-tests build_appwrite_cli build_appwrite_playwright \ No newline at end of file +build_twilio_shim: + cd twilio-shim && docker buildx build -t docker.io/syntaxsdev/twilio-shim:latest -f Dockerfile . --load + +push_twilio_shim: + cd twilio-shim && docker buildx build -t docker.io/syntaxsdev/twilio-shim:latest -f Dockerfile . --load --push + + +.PHONY: patch_templates tests clean-tests build_appwrite_cli build_appwrite_playwright build_twilio_shim push_twilio_shim \ No newline at end of file diff --git a/appwrite_lab/_orchestrator.py b/appwrite_lab/_orchestrator.py index 30eacaa..6ca1bdb 100644 --- a/appwrite_lab/_orchestrator.py +++ b/appwrite_lab/_orchestrator.py @@ -132,10 +132,11 @@ def get_running_pods_by_project(self, name: str): } return pods - def _deploy_service( + def _deploy_compose_service( self, project: str, - template_path: Path, + template_paths: list[Path] | Path, + env_file: Path | None = None, env_vars: dict[str, str] = {}, extra_args: list[str] = [], ): @@ -144,22 +145,29 @@ def _deploy_service( Args: project: The name of the project to deploy the service to. - template_path: The path to the template to use for the service. + template_paths: The path to the template to use for the service. env_vars: The environment variables to set. + env_file: The path to the environment file to use for the service. extra_args: Extra arguments to pass to the compose command. """ - new_env = {**os.environ, **env_vars} + if isinstance(template_paths, Path): + template_paths = [template_paths] + + file_overlays = [] + for path in template_paths: + file_overlays.extend(["-f", str(path)]) + cmd = [ *self.compose, - "-f", - template_path, + *file_overlays, + *(["--env-file", str(env_file)] if env_file else []), "-p", project, *extra_args, "up", "-d", ] - return self._run_cmd_safely(cmd, envs=new_env) + return self._run_cmd_safely(cmd, envs=env_vars) def deploy_appwrite_lab( self, @@ -188,24 +196,38 @@ def deploy_appwrite_lab( error=True, message=f"Lab '{name}' already deployed.", data=None ) converted_version = version.replace(".", "_") + + # Gather paths template_path = ( Path(__file__).parent / "templates" / f"docker_compose_{converted_version}.yml" ) + + twilio_shim_path = ( + Path(__file__).parent + / "templates" + / "extras" + / "twilio-shim" + / "docker_compose.yml" + ) + + env_file = Path(__file__).parent / "templates" / "environment" / "dotenv" if not template_path.exists(): return Response( error=True, message=f"Template {version} not found.", data=None ) - # Override default env vars + # Override default env vars for port env_vars = get_env_vars(self.default_env_vars) if port != 80: env_vars["_APP_PORT"] = str(port) # What actually deploys the initial appwrite service - cmd_res = self._deploy_service( - project=name, template_path=template_path, env_vars=env_vars + cmd_res = self._deploy_compose_service( + project=name, + template_paths=[template_path, twilio_shim_path], + env_file=env_file, ) # Deploy mail server (mailpit) @@ -216,9 +238,7 @@ def deploy_appwrite_lab( / "mailpit" / "docker_compose.yml" ) - self._deploy_service( - project=name, template_path=mailpit_template_path, env_vars={} - ) + self._deploy_compose_service(project=name, template_paths=mailpit_template_path) # if CLI, will throw error in actual Response object if type(cmd_res) is Response and cmd_res.error: return cmd_res @@ -246,6 +266,8 @@ def deploy_appwrite_lab( version=version, url=url, **_kwargs, + sms_shim_url="https://localhost:4443", + mailpit_url="http://localhost:8025", ) lab.generate_missing_config() diff --git a/appwrite_lab/_state.py b/appwrite_lab/_state.py index ebd00f9..7ff20bc 100644 --- a/appwrite_lab/_state.py +++ b/appwrite_lab/_state.py @@ -1,6 +1,5 @@ import json import os -from pathlib import Path from .utils import get_state_path diff --git a/appwrite_lab/labs.py b/appwrite_lab/labs.py index 418ac54..fc60ca1 100644 --- a/appwrite_lab/labs.py +++ b/appwrite_lab/labs.py @@ -152,7 +152,7 @@ def create_api_key( return Response( message=f"API key created for {project_name}", data=api_key.data, - _print_data=True, + _print_data=False, ) def stop(self, name: str): diff --git a/appwrite_lab/models.py b/appwrite_lab/models.py index 7c6431a..6a35f6a 100644 --- a/appwrite_lab/models.py +++ b/appwrite_lab/models.py @@ -51,6 +51,8 @@ class Lab(_BaseClass): default_factory=lambda: {"default": Project(None, None, None)} ) _file: str = field(default="") + sms_shim_url: str = field(default="") + mailpit_url: str = field(default="") def generate_missing_config(self): """Generate missing data config with random values.""" diff --git a/appwrite_lab/templates/environment/dotenv b/appwrite_lab/templates/environment/dotenv index f7e79a7..313f147 100644 --- a/appwrite_lab/templates/environment/dotenv +++ b/appwrite_lab/templates/environment/dotenv @@ -55,8 +55,8 @@ _APP_SMTP_PORT=1025 _APP_SMTP_SECURE= _APP_SMTP_USERNAME= _APP_SMTP_PASSWORD= -_APP_SMS_PROVIDER= -_APP_SMS_FROM= +_APP_SMS_PROVIDER=sms://twilio:shim@twilio +_APP_SMS_FROM=+15555555555 _APP_STORAGE_LIMIT=30000000 _APP_STORAGE_PREVIEW_LIMIT=20000000 _APP_STORAGE_ANTIVIRUS=disabled diff --git a/appwrite_lab/templates/extras/twilio-shim/docker_compose.yml b/appwrite_lab/templates/extras/twilio-shim/docker_compose.yml new file mode 100644 index 0000000..1ea6387 --- /dev/null +++ b/appwrite_lab/templates/extras/twilio-shim/docker_compose.yml @@ -0,0 +1,61 @@ +x-sms-env: &sms_env + _APP_SMS_PROVIDER: ${_APP_SMS_PROVIDER:-sms://twilio:shim@twilio} + _APP_SMS_FROM: ${_APP_SMS_FROM:-+15555555555} + +networks: + appwrite: + name: appwrite + +volumes: + fake_twilio_certs: {} + php_extra_conf: {} + +services: + # one-shot: write php ini into the named volume + php-ini-init: + image: busybox:1.36 + volumes: + - php_extra_conf:/out + command: > + sh -lc 'printf "%s\n%s\n" + "curl.cainfo=/usr/local/share/ca-certificates/dev-rootCA.pem" + "openssl.cafile=/usr/local/share/ca-certificates/dev-rootCA.pem" + > /out/zz-dev-ca.ini' + + twilio-shim: + image: syntaxsdev/twilio-shim:latest + volumes: + - fake_twilio_certs:/certs + ports: ["4443:443"] # dev-only host access + networks: + appwrite: + aliases: [api.twilio.com] + restart: unless-stopped + healthcheck: + test: ["CMD","curl","-k","https://localhost:443/"] + interval: 5s + timeout: 3s + retries: 5 + + appwrite: + depends_on: + php-ini-init: { condition: service_completed_successfully } + environment: + <<: *sms_env + PHP_INI_SCAN_DIR: /usr/local/etc/php/conf.d:/opt/php-extra-conf.d + volumes: + - fake_twilio_certs:/usr/local/share/ca-certificates:ro + - php_extra_conf:/opt/php-extra-conf.d:ro + networks: [appwrite] + + appwrite-worker-messaging: + depends_on: + php-ini-init: { condition: service_completed_successfully } + twilio-shim: { condition: service_healthy } + environment: + <<: *sms_env + PHP_INI_SCAN_DIR: /usr/local/etc/php/conf.d:/opt/php-extra-conf.d + volumes: + - fake_twilio_certs:/usr/local/share/ca-certificates:ro + - php_extra_conf:/opt/php-extra-conf.d:ro + networks: [appwrite] diff --git a/appwrite_lab/tools/sms.py b/appwrite_lab/tools/sms.py new file mode 100644 index 0000000..6f21844 --- /dev/null +++ b/appwrite_lab/tools/sms.py @@ -0,0 +1,17 @@ +from appwrite_lab.models import Lab +from httpx import AsyncClient + + +class SMS: + def __init__(self, lab: Lab): + self.url = lab.sms_shim_url + + async def get_messages(self): + async with AsyncClient(verify=False) as client: + response = await client.get(f"{self.url}/inbox") + return response.json().get("messages") + + async def clear_messages(self): + async with AsyncClient(verify=False) as client: + response = await client.post(f"{self.url}/clear") + return response.json().get("ok") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 3f0e143..4e7c4e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,18 @@ build-backend = "setuptools.build_meta" [project] name = "appwrite-lab" -version = "0.1.2" +version = "0.1.3" description = "Zero-click Appwrite test environments." readme = "README.md" requires-python = ">=3.11" -dependencies = ["playwright", "typer>=0.16.0", "python-dotenv>=1.1.0", "pytest>=8.4.1"] +dependencies = [ + "playwright", + "typer>=0.16.0", + "python-dotenv>=1.1.0", + "pytest>=8.4.1", + "httpx>=0.28.1", + "pytest-asyncio>=1.1.0", +] license = "MIT" [tool.setuptools] @@ -23,6 +30,5 @@ appwrite-lab = "appwrite_lab.cli.entry:app" awlab = "appwrite_lab.cli.entry:app" [tool.pytest.ini_options] -markers = [ - "e2e: mark test as e2e", -] +markers = ["e2e: mark test as e2e"] +asyncio_mode = "auto" \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 8ec0814..8197d8a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,9 +2,20 @@ from pathlib import Path from appwrite_lab.models import Lab from appwrite_lab.test_suite import lab_svc, appwrite_file, lab_config, lab +from appwrite_lab.tools.sms import SMS @pytest.fixture(scope="session") def appwrite_file_path(): return Path(__file__).parent / "data" / "appwrite.json" - \ No newline at end of file + + +@pytest.fixture(scope="session") +def sms(lab: Lab): + return SMS(lab) + + +@pytest.fixture(autouse=True) +async def clear_sms(sms: SMS): + yield + await sms.clear_messages() diff --git a/tests/test_labs.py b/tests/test_labs.py index adec80c..776b044 100644 --- a/tests/test_labs.py +++ b/tests/test_labs.py @@ -2,19 +2,21 @@ from appwrite_lab.labs import Labs from appwrite_lab.automations.models import Expiration import pytest - +import uuid @pytest.mark.e2e def test_labs_new(lab: Lab): assert lab.name == "test-lab" assert lab.version == "1.7.4" - assert lab.url.endswith("8080") + assert lab.url.endswith("80") assert lab.projects.get("default") is not None @pytest.mark.e2e def test_labs_create_api_key(lab: Lab, lab_svc: Labs): default = lab.projects.get("default") + if default.api_key: + pytest.skip("API key already exists") res = lab_svc.create_api_key( project_name=default.project_name, key_name="default-api-key", @@ -37,8 +39,9 @@ def test_labs_synced_project(lab: Lab, lab_svc: Labs): @pytest.mark.e2e def test_labs_create_project(lab: Lab, lab_svc: Labs): - project_name = "test-project" - project_id = "test-project-id" + nonce = str(uuid.uuid4())[:8] + project_name = f"test-project-{nonce}" + project_id = f"test-project-id-{nonce}" res = lab_svc.create_project( project_name=project_name, project_id=project_id, diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..33623c8 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,27 @@ +import pytest +from appwrite_lab.tools.sms import SMS +from appwrite_lab.models import Lab +from httpx import AsyncClient +import asyncio + +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_sms_get_messages(sms: SMS, lab: Lab): + await sms.clear_messages() + messages = await sms.get_messages() + assert len(messages) == 0 + + async with AsyncClient(verify=False) as client: + response = await client.post( + f"{lab.url}/v1/account/sessions/phone", + json={"userId": "unique()", "phone": "+15555550123"}, + headers={"X-Appwrite-Project": lab.projects.get("default").project_id}, + ) + assert response.status_code == 201 + await asyncio.sleep(1) + messages = await sms.get_messages() + assert len(messages) == 1 + assert messages[0].get("frm") == "+15555555555" + assert len(messages[0].get("body")) == 6 + + diff --git a/twilio-shim/Dockerfile b/twilio-shim/Dockerfile new file mode 100644 index 0000000..6500325 --- /dev/null +++ b/twilio-shim/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +WORKDIR /app + +# deps +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +# mkcert (static binary) +RUN curl -L -o /usr/local/bin/mkcert \ + https://github.com/FiloSottile/mkcert/releases/download/v1.4.4/mkcert-v1.4.4-linux-amd64 \ + && chmod +x /usr/local/bin/mkcert + +# app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py ./main.py + +# entrypoint that generates certs then runs HTTPS +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 443 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/twilio-shim/entrypoint.sh b/twilio-shim/entrypoint.sh new file mode 100644 index 0000000..ec7b2ee --- /dev/null +++ b/twilio-shim/entrypoint.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +CERT_DIR=${CERT_DIR:-/certs} +CAROOT="${CERT_DIR}/ca" +mkdir -p "${CAROOT}" + +# Generate CA + leaf if missing +if [ ! -f "${CERT_DIR}/api.twilio.com.crt" ] || [ ! -f "${CERT_DIR}/api.twilio.com.key" ]; then + echo ">> Generating dev CA and leaf cert..." + export CAROOT + mkcert -install >/dev/null 2>&1 || true + mkcert -key-file "${CERT_DIR}/api.twilio.com.key" \ + -cert-file "${CERT_DIR}/api.twilio.com.crt" \ + "api.twilio.com" + cp "${CAROOT}/rootCA.pem" "${CERT_DIR}/dev-rootCA.pem" +fi + +echo ">> Starting Twilio shim (HTTPS)…" +exec uvicorn main:app \ + --host 0.0.0.0 --port 443 \ + --ssl-keyfile "${CERT_DIR}/api.twilio.com.key" \ + --ssl-certfile "${CERT_DIR}/api.twilio.com.crt" diff --git a/twilio-shim/main.py b/twilio-shim/main.py new file mode 100644 index 0000000..34bf0e2 --- /dev/null +++ b/twilio-shim/main.py @@ -0,0 +1,87 @@ +from fastapi import FastAPI, Body, Form, Request +from fastapi.responses import HTMLResponse, JSONResponse +from typing import List, Dict +from datetime import datetime + +app = FastAPI() +MESSAGES: List[Dict] = [] + + +@app.post("/inbox") +async def inbox(payload: Dict = Body(...)): + payload["ts"] = datetime.utcnow().isoformat() + "Z" + MESSAGES.append(payload) + if len(MESSAGES) > 100: + del MESSAGES[:-100] + return {"ok": True} + + +@app.get("/inbox") +def list_inbox(): + return {"messages": MESSAGES} + + +@app.post("/clear") +def clear_inbox(): + MESSAGES.clear() + return {"ok": True} + + +# Twilio-compatible endpoint +@app.post("/2010-04-01/Accounts/{sid}/Messages.json") +async def send_sms(sid: str, request: Request): + form = await request.form() + data = dict(form) # includes any extra fields + to = data.get("To") + body = data.get("Body") + from_ = data.get("From") # optional + mssid = data.get("MessagingServiceSid") # optional + + # Log everything we got to see what Appwrite is actually posting + print(f"[FAKE-TWILIO] sid={sid} payload={data}") + MESSAGES.append( + { + "to": to, + "frm": from_, + "body": body, + "ts": datetime.utcnow().isoformat() + "Z", + } + ) + + # Minimal Twilio-style validation: need To + Body + (From or MessagingServiceSid) + if not to or not body or not (from_ or mssid): + return JSONResponse( + status_code=400, + content={ + "code": 21601, + "message": "Missing required parameter: 'To'/'Body' or sender", + }, + ) + + # Return Twilio-like success + return JSONResponse( + status_code=201, + content={ + "sid": "SM_fake123", + "status": "queued", + "to": to, + "from": from_, + "messaging_service_sid": mssid, + "body": body, + }, + ) + + +@app.get("/", response_class=HTMLResponse) +def home(): + rows = "".join( + f"{m.get('to', '')}{m.get('frm', '')}{m.get('body', '')}{m.get('ts', '')}" + for m in reversed(MESSAGES) + ) + return f""" + SMS Capture + + +

Captured SMS (dev)

+ {rows}
ToFromBodyTime (UTC)
+ """ diff --git a/twilio-shim/requirements.txt b/twilio-shim/requirements.txt new file mode 100644 index 0000000..f30baa0 --- /dev/null +++ b/twilio-shim/requirements.txt @@ -0,0 +1,14 @@ +annotated-types==0.7.0 +anyio==4.10.0 +click==8.2.1 +fastapi==0.116.1 +h11==0.16.0 +idna==3.10 +pydantic==2.11.7 +pydantic-core==2.33.2 +python-multipart==0.0.20 +sniffio==1.3.1 +starlette==0.47.2 +typing-extensions==4.14.1 +typing-inspection==0.4.1 +uvicorn==0.35.0 diff --git a/uv.lock b/uv.lock index 370c394..10dcd10 100644 --- a/uv.lock +++ b/uv.lock @@ -2,25 +2,52 @@ version = 1 revision = 2 requires-python = ">=3.11" +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + [[package]] name = "appwrite-lab" -version = "0.1.0" +version = "0.1.2" source = { editable = "." } dependencies = [ + { name = "httpx" }, { name = "playwright" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "python-dotenv" }, { name = "typer" }, ] [package.metadata] requires-dist = [ + { name = "httpx", specifier = ">=0.28.1" }, { name = "playwright" }, { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", specifier = ">=1.1.0" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "typer", specifier = ">=0.16.0" }, ] +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + [[package]] name = "click" version = "8.2.1" @@ -84,6 +111,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236, upload-time = "2025-06-05T16:15:20.111Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[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, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -188,6 +261,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + [[package]] name = "python-dotenv" version = "1.1.1" @@ -219,6 +304,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[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, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "typer" version = "0.16.0"