diff --git a/.env-dist b/.env-dist new file mode 100644 index 0000000..a7fc96c --- /dev/null +++ b/.env-dist @@ -0,0 +1,18 @@ +# GENERAL +PROJECT_NAME=ideologicalatlas +RESEND_API_KEY=CHANGE-ME +API_KEY=CHANGE-ME +PORT=5051 +LOG_LEVEL=INFO +ENVIRONMENT=local + +# EMAIL +FROM_EMAIL=noreply@notifications.ideologicalatlas.com +FROM_EMAIL_NAME=Ideological Atlas +BASE_SITE_URL=localhost:4045 + +# TESTING +TEST_EMAIL=CHANGE-ME +TEST_LANGUAGE=es +TEST_TEMPLATE=register +TEST_CONTEXT={"user_uuid": "test-uuid-123", "name": "Test User"} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..7fa344f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,25 @@ +--- +name: πŸ› Bug Report +about: Create a report to help us improve +title: "[FIX] " +labels: bug +assignees: '' +--- + +## Bug Description +A clear and concise description of the issue. + +## Steps to Reproduce +1. Go to '...' +2. Click on '...' +3. See error + +## Expected Behavior +What should have happened instead. + +## Environment +- OS: [e.g. Ubuntu 24] +- Browser/Version: [e.g. Chrome 90] + +## Screenshots or Logs +(If applicable, paste logs or images here) diff --git a/.github/ISSUE_TEMPLATE/chore.md b/.github/ISSUE_TEMPLATE/chore.md new file mode 100644 index 0000000..d5e3cdd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/chore.md @@ -0,0 +1,16 @@ +--- +name: 🧹 Chore +about: Technical tasks, cleanup, refactoring, or dependencies +title: "[CHORE] " +labels: chore +assignees: '' +--- + +## Task +Technical description of the task (lib update, refactoring, CI cleanup, etc.). + +## Reason +Why is this technical change necessary? (Tech debt, security, performance). + +## Implementation Notes +Specific details for the developer. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..2caccaf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: πŸš€ Feature Request +about: Suggest a new feature or improvement +title: "[FEAT] " +labels: enhancement +assignees: '' +--- + +## Description +A concise description of the requested feature. + +## Motivation +What problem does it solve or what value does it bring? + +## Acceptance Criteria +- [ ] Criterion 1 +- [ ] Criterion 2 +- [ ] Criterion 3 + +## Mockups or Screenshots +(If applicable, add images here) diff --git a/.github/issue-branch.yml b/.github/issue-branch.yml new file mode 100644 index 0000000..6fd8ad1 --- /dev/null +++ b/.github/issue-branch.yml @@ -0,0 +1,10 @@ +branchName: '${issue.number}-${issue.title}' +branches: + - label: bug + prefix: fix/ + - label: enhancement + prefix: feature/ + - label: chore + prefix: chore/ + - label: '*' + prefix: other/ diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..50ceece --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +## Type of Change +- [ ] πŸš€ New feature (non-breaking change which adds functionality) +- [ ] πŸ› Bug fix (non-breaking change which fixes an issue) +- [ ] 🧹 Chore (technical task, refactoring, maintenance) + +## Description +Briefly describe the changes made in this Pull Request. Explain the solution and the reasoning. + +## Related Issue +Closes # (Link the issue here, e.g., #123) + +## How Has This Been Tested? +Please describe the tests that you ran to verify your changes. +- [ ] Unit Tests +- [ ] Manual Testing + +## Checklist +- [ ] My code follows the project's style guidelines +- [ ] I have performed a self-review of my code +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes + +## Screenshots (if applicable) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1678d1a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI Pipeline + +on: + push: + +jobs: + quality-assurance: + name: Quality & Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + version: "latest" + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install Dependencies + run: | + cd src + uv sync --extra dev + + - name: Cache Pre-commit hooks + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: | + pre-commit-${{ runner.os }}- + + - name: Run Pre-commit (Linting & Static Analysis) + run: | + cd src + uv run pre-commit run --all-files --config ../.pre-commit-config.yaml + + - name: Run Tests with Coverage + env: + RESEND_API_KEY: "test_resend_key" + API_KEY: "test_api_key" + FROM_EMAIL: "test@example.com" + FROM_EMAIL_NAME: "Test User" + BASE_SITE_URL: "http://localhost:8000" + run: | + cd src + uv run coverage run -m unittest discover -s tests -t . -p "test_*.py" + uv run coverage xml + + - name: Upload results to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: Ideological-Atlas/notifications + files: ./src/coverage.xml + fail_ci_if_error: true + verbose: true diff --git a/.github/workflows/create-branch.yml b/.github/workflows/create-branch.yml new file mode 100644 index 0000000..bff747d --- /dev/null +++ b/.github/workflows/create-branch.yml @@ -0,0 +1,13 @@ +name: Create Issue Branch +on: + issues: + types: [assigned] + +jobs: + create_branch: + runs-on: ubuntu-latest + steps: + - name: Create Issue Branch + uses: robvanderleek/create-issue-branch-action@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index b7faf40..525e7d6 100644 --- a/.gitignore +++ b/.gitignore @@ -173,7 +173,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. @@ -182,11 +182,11 @@ cython_debug/ .abstra/ # Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, +# and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder -# .vscode/ + .vscode/ # Ruff stuff: .ruff_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..ed9d641 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,72 @@ +repos: + - repo: https://github.com/pycqa/isort + rev: 7.0.0 + hooks: + - id: isort + args: ["--profile", "black"] + exclude: '(^|/)(__init__\.py)$' + + - repo: https://github.com/psf/black + rev: 25.12.0 + hooks: + - id: black + + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: 'v0.14.10' + hooks: + - id: ruff + args: [ --exit-non-zero-on-fix ] + exclude: '(^|.*/)__init__\.py$' + + - repo: https://github.com/asottile/pyupgrade + rev: v3.21.2 + hooks: + - id: pyupgrade + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.11.0.1 + hooks: + - id: shellcheck + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-merge-conflict + - id: check-json + - id: name-tests-test + args: + - --pytest-test-first + exclude: '.*helpers.*' + - id: pretty-format-json + args: + - --autofix + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.19.1 + hooks: + - id: mypy + args: + - --check-untyped-defs + - --ignore-missing-imports + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-use-type-annotations + - id: python-no-eval + - id: python-no-log-warn + - id: text-unicode-replacement-char + - repo: https://github.com/PyCQA/bandit + rev: 1.9.2 + hooks: + - id: bandit + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + exclude: ^src/app/locales + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.0 + hooks: + - id: gitleaks diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..30b2fcd --- /dev/null +++ b/Makefile @@ -0,0 +1,83 @@ +ifneq ("$(wildcard .env)","") + include .env + export +endif + +# --- Configuration --- +COMPOSE = docker compose +NOTIFICATIONS_SVC = $(PROJECT_NAME)_notifications +EXEC = docker exec -it $(NOTIFICATIONS_SVC) +TAIL_LOGS = 50 + +.DEFAULT_GOAL := up-logs + +# --- System --- +.PHONY: help prepare-env clean-images remove-containers + +help: ## Show this help message + @awk 'BEGIN {FS = ":.*## "} /^[a-zA-Z_-]+:.*## / {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +prepare-env: ## Create .env from template + @test -f .env || cp .env-dist .env + +clean-images: ## Remove all project images + @if [ -n "$(shell docker images -qa)" ]; then docker rmi $(shell docker images -qa) --force; fi + +remove-containers: ## Remove all project containers + @if [ -n "$(shell docker ps -qa)" ]; then docker rm $(shell docker ps -qa); fi + +# --- Docker Orchestration --- +up-logs: up logs + +up: prepare-env ## Start containers in background + @$(COMPOSE) up --force-recreate -d --remove-orphans + +down: ## Stop and remove containers + @$(COMPOSE) down + +restart: ## Restart containers + @$(COMPOSE) restart + +build: prepare-env ## Build images + @$(COMPOSE) build + +down-up: down up-logs ## Recreate services + +complete-build: build down-up ## Full rebuild cycle + +# --- Development & Logs --- +.PHONY: logs all-logs bash shell lint format + +logs: ## Show notifications service logs + @docker logs --tail $(TAIL_LOGS) -f $(NOTIFICATIONS_SVC) + +bash: ## Access container bash + @$(EXEC) bash + +shell: ## Access IPython shell + @$(EXEC) ipython + + +# --- Testing --- +.PHONY: trigger-test test install-dev-dependencies + +install-dev-dependencies: ## Install dev dependencies + @$(EXEC) uv sync --extra dev + +clean-coverage: ## Clean previous coverage results + @$(EXEC) rm -f .coverage coverage.xml + +test: clean-coverage install-dev-dependencies ## Run tests with standard unittest + @$(EXEC) uv run coverage run -m unittest discover -s tests -t . -p "test_*.py" + @$(EXEC) uv run coverage xml + @$(EXEC) uv run coverage report + +trigger-test: ## Send a test email via Curl using .env variables + @echo "Sending test email to $(TEST_EMAIL)..." + @echo "Template: $(TEST_TEMPLATE)" + @echo "Language: $(TEST_LANGUAGE)" + @curl -X POST http://localhost:$(PORT)/notifications/send \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $(API_KEY)" \ + -d '{"to_email": "$(TEST_EMAIL)", "template_name": "$(TEST_TEMPLATE)", "language": "$(TEST_LANGUAGE)", "context": $(TEST_CONTEXT)}' + @echo "\nDone." diff --git a/README.md b/README.md index 0dc95ae..2d75e8c 100644 --- a/README.md +++ b/README.md @@ -1 +1,221 @@ -# notifications \ No newline at end of file +# Ideological Atlas - Notifications Microservice + +![CI](https://github.com/Ideological-Atlas/notifications/actions/workflows/ci.yml/badge.svg) +[![codecov](https://codecov.io/gh/Ideological-Atlas/notifications/graph/badge.svg?token=W9D4BVTK2Y)](https://codecov.io/gh/Ideological-Atlas/notifications) +![Python](https://img.shields.io/badge/python-3.14-blue.svg) +![FastAPI](https://img.shields.io/badge/FastAPI-0.128.0-009688.svg) +![License](https://img.shields.io/badge/license-GPLv3-green) + +[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit) +[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) +[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) +[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/) + +[![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit) +[![gitleaks](https://img.shields.io/badge/protected%20by-gitleaks-blueviolet)](https://github.com/gitleaks/gitleaks) +[![shellcheck](https://img.shields.io/badge/shellcheck-enforced-4EAA25)](https://github.com/koalaman/shellcheck) +[![codespell](https://img.shields.io/badge/spell%20check-codespell-blue)](https://github.com/codespell-project/codespell) + +A high-performance, asynchronous microservice designed to handle transactional emails for the **Ideological Atlas** platform. Built with **FastAPI** and **Python 3.14**, it leverages **Resend** for email delivery, **Jinja2** for dynamic HTML templating, and full internationalization (i18n) support. + +## πŸš€ Features + +* **REST API:** Clean and documented endpoints to trigger email dispatch. +* **Email Provider:** Integrated with [Resend](https://resend.com/) for reliable delivery. +* **Templating Engine:** Uses Jinja2 with inheritance support (`base.html`) for consistent email branding. +* **Internationalization:** Built-in support for multiple languages via JSON locale files. +* **Security:** API Key authentication using HTTP Headers. +* **Modern Stack:** Python 3.14, FastAPI, `uv` package manager, and Pydantic v2. +* **DevOps Ready:** Dockerized, comprehensive `Makefile`, and CI/CD pipelines with GitHub Actions. +* **Code Quality:** Strict linting (Ruff, Black, Isort, Mypy) and high test coverage. + +## πŸ›  Tech Stack + +* **Language:** Python 3.14 +* **Framework:** FastAPI +* **Server:** Uvicorn +* **Dependency Management:** uv +* **Containerization:** Docker & Docker Compose +* **Testing:** Unittest & Coverage +* **CI/CD:** GitHub Actions + +## πŸ“‚ Project Structure + +```text +. +β”œβ”€β”€ .github/workflows # CI/CD pipelines +β”œβ”€β”€ docker # Docker configuration files +β”œβ”€β”€ src +β”‚ β”œβ”€β”€ app +β”‚ β”‚ β”œβ”€β”€ core # Configuration, Security, Logging +β”‚ β”‚ β”œβ”€β”€ locales # JSON translation files +β”‚ β”‚ β”œβ”€β”€ routers # API Endpoints +β”‚ β”‚ β”œβ”€β”€ schemas # Pydantic models +β”‚ β”‚ └── services # Business logic (Email Engine) +β”‚ β”œβ”€β”€ templates # Jinja2 HTML templates +β”‚ β”œβ”€β”€ tests # Unit tests +β”‚ └── main.py # Application entry point +β”œβ”€β”€ compose.yml # Docker Compose services +β”œβ”€β”€ Makefile # Task automation +└── pyproject.toml # Project dependencies and tool config + +``` + +## ⚑ Getting Started + +### Prerequisites + +* **Docker** and **Docker Compose** (Recommended for development) +* **Python 3.14+** and **uv** (If running locally without Docker) + +### Environment Configuration + +1. Copy the example environment file: +```bash +cp .env-dist .env + +``` + + +2. Configure the variables in `.env`: + +| Variable | Description | Default | +| --- | --- | --- | +| `PROJECT_NAME` | Name of the project | `ideologicalatlas` | +| `RESEND_API_KEY` | **Required**. Your Resend API Key | `CHANGE-ME` | +| `API_KEY` | **Required**. Secret key to protect the API | `CHANGE-ME` | +| `PORT` | Service port | `5051` | +| `LOG_LEVEL` | Logging verbosity | `INFO` | +| `FROM_EMAIL` | Sender email address | `noreply@...` | +| `BASE_SITE_URL` | URL for link generation | `localhost` | + +## 🐳 Docker Usage (Recommended) + +This project includes a robust `Makefile` to simplify Docker operations. + +1. **Start the service:** +```bash +make up + +``` + + +This creates the `.env` file (if missing), builds the image, and starts the container in the background. +2. **View Logs:** +```bash +make logs + +``` + + +3. **Stop the service:** +```bash +make down + +``` + + +4. **Rebuild completely:** +```bash +make complete-build + +``` + + + +## πŸ’» Local Development + +If you prefer running without Docker: + +1. **Install `uv`:** +```bash +pip install uv + +``` + + +2. **Install dependencies:** +```bash +cd src +uv sync --extra dev + +``` + + +3. **Run the application:** +```bash +uv run uvicorn main:app --host 0.0.0.0 --port 5051 --reload + +``` + + + +## πŸ“‘ API Usage + +### Send Email Endpoint + +* **URL:** `/notifications/send` +* **Method:** `POST` +* **Auth:** Header `x-api-key: ` + +#### Request Body + +```json +{ + "to_email": "user@example.com", + "template_name": "register", + "language": "es", + "context": { + "user_uuid": "123e4567-e89b-12d3-a456-426614174000", + "name": "Jane Doe" + } +} + +``` + +#### Example cURL + +You can use the built-in make command to trigger a test email: + +```bash +make trigger-test + +``` + +Or manually: + +```bash +curl -X POST http://localhost:5051/notifications/send \ + -H "Content-Type: application/json" \ + -H "x-api-key: YOUR_SECRET_KEY" \ + -d '{"to_email": "test@test.com", "template_name": "register", "language": "en", "context": {"user_uuid": "123"}}' + +``` + +## πŸ§ͺ Testing & Quality + +We maintain high code quality standards enforced by **Pre-commit** hooks and **Unittest**. + +### Running Tests + +To run the test suite with coverage inside the Docker container: + +```bash +make test + +``` + +### Static Analysis + +Linting and formatting are handled by `ruff`, `black`, and `isort`. You can run the pre-commit checks manually: + +```bash +# Ensure dev dependencies are installed +uv run pre-commit run --all-files --config .pre-commit-config.yaml + +``` + +## πŸ“ License + +This project is licensed under the **GNU General Public License v3.0**. See the [LICENSE](https://www.google.com/search?q=LICENSE) file for details. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..2839385 --- /dev/null +++ b/compose.yml @@ -0,0 +1,18 @@ +services: + notifications: + container_name: ${PROJECT_NAME}_notifications + hostname: notifications + restart: always + build: + context: . + dockerfile: docker/Dockerfile + env_file: .env + ports: + - "${PORT}:8000" + volumes: + - ./src:/src/ + logging: + driver: "json-file" + options: + max-size: 50m + max-file: "2" diff --git a/docker/.bashrc b/docker/.bashrc new file mode 100644 index 0000000..0fec28d --- /dev/null +++ b/docker/.bashrc @@ -0,0 +1,32 @@ +#!/bin/bash +NOCOLOR='\001\033[0m\002' + +RED='\001\033[00;31m\002' +GREEN='\001\033[00;32m\002' +BLUE='\001\033[00;34m\002' +PURPLE='\001\033[00;35m\002' + +ENVIRONMENT=${ENVIRONMENT:-unknown} + +case ${ENVIRONMENT} in + "prod"|"pro"|"production") + ENV_COLOR="$RED" + ;; + "dev"|"development") + ENV_COLOR="$GREEN" + ;; + "local") + ENV_COLOR="$BLUE" + ;; + *) + ENV_COLOR="$PURPLE" + ;; +esac + +ENV_PROMPT="${ENV_COLOR}(${PROJECT_NAME}_${ENVIRONMENT})${NOCOLOR}" + +get_env_prompt() { + echo "$ENV_PROMPT" +} + +PS1="$(get_env_prompt) $PS1" diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..dffca4d --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.14-slim + +# Prepare work directory +RUN mkdir -p /src +WORKDIR /src + +# Install uv system-wide +RUN pip install --no-cache-dir uv +ENV UV_PROJECT_ENVIRONMENT=/usr/local + +# Copy only dependency files first to leverage Docker cache +COPY src/pyproject.toml src/uv.lock /src/ + +# Install dependencies +RUN uv sync --frozen --no-install-project + +# Create logs dir +RUN mkdir -p /src/logs + +# Copy source code +COPY src/ /src/ + +# Install the project itself +RUN uv sync --frozen + +# Add terminal colors +COPY docker/.bashrc /root/.bashrc + +# Run app +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker/ipython_config.py b/docker/ipython_config.py new file mode 100644 index 0000000..50b9c07 --- /dev/null +++ b/docker/ipython_config.py @@ -0,0 +1,6 @@ +# type: ignore +c = get_config() # noqa +c.InteractiveShellApp.exec_lines = [ + "%autoreload 2", +] +c.InteractiveShellApp.extensions = ["autoreload"] diff --git a/src/.python-version b/src/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/src/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/core/__init__.py b/src/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/core/config.py b/src/app/core/config.py new file mode 100644 index 0000000..75fb344 --- /dev/null +++ b/src/app/core/config.py @@ -0,0 +1,18 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + PROJECT_NAME: str = "Email Service" + RESEND_API_KEY: str + API_KEY: str + FROM_EMAIL: str + FROM_EMAIL_NAME: str + BASE_SITE_URL: str + LOG_LEVEL: str = "INFO" + + TEMPLATE_FOLDER: str = "templates" + + model_config = SettingsConfigDict(env_file=".env") + + +settings = Settings() diff --git a/src/app/core/logging_config.py b/src/app/core/logging_config.py new file mode 100644 index 0000000..e3d7f33 --- /dev/null +++ b/src/app/core/logging_config.py @@ -0,0 +1,38 @@ +import logging +import sys +from pathlib import Path + +from app.core.config import settings + + +def setup_logging(): + log_format = "[%(asctime)s] %(levelname)s (%(name)s) %(message)s" + formatter = logging.Formatter(log_format) + + log_dir = Path("logs") + log_dir.mkdir(parents=True, exist_ok=True) + + clean_project_name = settings.PROJECT_NAME.replace(" ", "_").lower() + log_file = log_dir / f"{clean_project_name}.log" + + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setFormatter(formatter) + + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setFormatter(formatter) + + logging.basicConfig( + level=settings.LOG_LEVEL, handlers=[stream_handler, file_handler] + ) + + loggers_to_fix = ["uvicorn", "uvicorn.error", "uvicorn.access"] + + for logger_name in loggers_to_fix: + logger = logging.getLogger(logger_name) + logger.handlers = [] + logger.propagate = False + + logger.addHandler(stream_handler) + logger.addHandler(file_handler) + + logger.setLevel(settings.LOG_LEVEL) diff --git a/src/app/core/security.py b/src/app/core/security.py new file mode 100644 index 0000000..18a7452 --- /dev/null +++ b/src/app/core/security.py @@ -0,0 +1,25 @@ +import logging + +from fastapi import Depends, HTTPException, status +from fastapi.security import APIKeyHeader +from fastapi.security.utils import get_authorization_scheme_param + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +AUTH_HEADER = APIKeyHeader(name="Authorization", auto_error=False) + + +async def api_key_auth(auth_value: str | None = Depends(AUTH_HEADER)) -> str: + scheme, param = get_authorization_scheme_param(auth_value) + + if scheme.lower() != "bearer" or param != settings.API_KEY: + logger.warning("Unauthorized access attempt: Invalid Key or Scheme.") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid Credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return param diff --git a/src/app/locales/en.json b/src/app/locales/en.json new file mode 100644 index 0000000..02cf5f5 --- /dev/null +++ b/src/app/locales/en.json @@ -0,0 +1,61 @@ +{ + "base": { + "contact_email": "support@ideologicalatlas.com", + "contact_text": "Contact", + "doubts": "Questions?", + "footer_reason": "You are receiving this email because you signed up for Ideological Atlas. If this wasn't you, please contact", + "help_text": "for help.", + "rights": "Ideological Atlas. All rights reserved.", + "subject_prefix": "Ideological Atlas", + "thanks": "Thanks for using our platform." + }, + "register": { + "button": "Verify Account", + "fallback_text": "If the button doesn't work, copy and paste the link below into your browser:", + "intro": "Thanks for joining us. We are excited to have you on board.", + "salutation": "Cheers,", + "subject": "Verify your Ideological Atlas account", + "team": "The Ideological Atlas Team", + "title": "Welcome to Ideological Atlas!", + "verify_text": "To get started, please verify your email address by clicking the button below:" + }, + "registration_reminder_30_days": { + "button": "Verify and Save Account", + "fallback_text": "Direct link:", + "intro": "It's been a month since you signed up. To keep our database clean, unverified accounts are periodically removed.", + "salutation": "Regards,", + "subject": "Last chance to verify your account", + "team": "The Ideological Atlas Team", + "title": "Action Required", + "verify_text": "If you still wish to access Ideological Atlas, please verify your account today:" + }, + "registration_reminder_3_days": { + "button": "Complete Verification", + "fallback_text": "Or use this link:", + "intro": "We noticed you signed up a few days ago but haven't verified your account yet.", + "salutation": "See you inside,", + "subject": "Reminder: Verify your account", + "team": "The Ideological Atlas Team", + "title": "Did you forget something?", + "verify_text": "Verifying your email is necessary to access all features. It only takes a second:" + }, + "registration_reminder_7_days": { + "button": "Activate Account Now", + "fallback_text": "Alternative link:", + "intro": "It's been a week since you signed up. Your ideological profile is ready to be explored, but we need you to confirm your email.", + "salutation": "Best regards,", + "subject": "Your account is waiting for you", + "team": "The Ideological Atlas Team", + "title": "We miss you!", + "verify_text": "Click below to activate your account now:" + }, + "user_deleted_due_no_verification": { + "button": "Register Again", + "intro": "As mentioned in our previous reminders, your account has been removed from our database due to lack of verification after 31 days.", + "re_register_text": "If you wish to use Ideological Atlas in the future, please register again and verify your email.", + "salutation": "Regards,", + "subject": "Account Deletion Notice", + "team": "The Ideological Atlas Team", + "title": "Your account has been deleted" + } +} diff --git a/src/app/locales/es.json b/src/app/locales/es.json new file mode 100644 index 0000000..b496b78 --- /dev/null +++ b/src/app/locales/es.json @@ -0,0 +1,61 @@ +{ + "base": { + "contact_email": "support@ideologicalatlas.com", + "contact_text": "Contacta a", + "doubts": "\u00bfTienes dudas?", + "footer_reason": "Est\u00e1s recibiendo este correo porque te registraste en Ideological Atlas. Si no fuiste t\u00fa, contacta a", + "help_text": "para obtener ayuda.", + "rights": "Ideological Atlas. Todos los derechos reservados.", + "subject_prefix": "Ideological Atlas", + "thanks": "Gracias por usar nuestra plataforma." + }, + "register": { + "button": "Verificar Cuenta", + "fallback_text": "Si el bot\u00f3n no funciona, copia y pega el siguiente enlace en tu navegador:", + "intro": "Gracias por unirte a nosotros. Estamos emocionados de tenerte a bordo.", + "salutation": "Saludos,", + "subject": "Verifica tu cuenta en Ideological Atlas", + "team": "El equipo de Ideological Atlas", + "title": "\u00a1Bienvenido a Ideological Atlas!", + "verify_text": "Para comenzar, por favor verifica tu direcci\u00f3n de correo electr\u00f3nico haciendo clic en el bot\u00f3n de abajo:" + }, + "registration_reminder_30_days": { + "button": "Verificar y Salvar Cuenta", + "fallback_text": "Enlace directo:", + "intro": "Ha pasado un mes desde tu registro. Para mantener nuestra base de datos limpia, las cuentas no verificadas se eliminan peri\u00f3dicamente.", + "salutation": "Saludos,", + "subject": "\u00daltima oportunidad para verificar tu cuenta", + "team": "El equipo de Ideological Atlas", + "title": "Acci\u00f3n requerida", + "verify_text": "Si a\u00fan deseas acceder a Ideological Atlas, por favor verifica tu cuenta hoy mismo:" + }, + "registration_reminder_3_days": { + "button": "Completar Verificaci\u00f3n", + "fallback_text": "O utiliza este enlace:", + "intro": "Notamos que te registraste hace unos d\u00edas pero a\u00fan no has verificado tu cuenta.", + "salutation": "Nos vemos dentro,", + "subject": "Recordatorio: Verifica tu cuenta", + "team": "El equipo de Ideological Atlas", + "title": "\u00bfOlvidaste algo?", + "verify_text": "Es necesario verificar tu correo para acceder a todas las funcionalidades. Solo te tomar\u00e1 un segundo:" + }, + "registration_reminder_7_days": { + "button": "Activar Cuenta Ahora", + "fallback_text": "Enlace alternativo:", + "intro": "Ha pasado una semana desde tu registro. Tu perfil ideol\u00f3gico est\u00e1 listo para ser explorado, pero necesitamos que confirmes tu correo.", + "salutation": "Atentamente,", + "subject": "Tu cuenta te est\u00e1 esperando", + "team": "El equipo de Ideological Atlas", + "title": "\u00a1Te echamos de menos!", + "verify_text": "Haz clic abajo para activar tu cuenta ahora:" + }, + "user_deleted_due_no_verification": { + "button": "Registrarse de nuevo", + "intro": "Como mencionamos en nuestros recordatorios anteriores, tu cuenta ha sido eliminada de nuestra base de datos debido a la falta de verificaci\u00f3n tras 31 d\u00edas.", + "re_register_text": "Si deseas utilizar Ideological Atlas en el futuro, por favor reg\u00edstrate nuevamente y verifica tu correo.", + "salutation": "Saludos,", + "subject": "Aviso de eliminaci\u00f3n de cuenta", + "team": "El equipo de Ideological Atlas", + "title": "Tu cuenta ha sido eliminada" + } +} diff --git a/src/app/routers/__init__.py b/src/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/routers/notifications.py b/src/app/routers/notifications.py new file mode 100644 index 0000000..b6e7248 --- /dev/null +++ b/src/app/routers/notifications.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends + +from app.core.security import api_key_auth +from app.schemas.email import EmailRequest +from app.services.email_engine import EmailService + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + + +@router.post("/send") +async def send_notification(email_data: EmailRequest, _: str = Depends(api_key_auth)): + result = await EmailService.send_email( + to_email=email_data.to_email, + language=email_data.language, + template_name=email_data.template_name, + context=email_data.context, + ) + + return {"status": "success", "provider_response": result} diff --git a/src/app/schemas/__init__.py b/src/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/schemas/email.py b/src/app/schemas/email.py new file mode 100644 index 0000000..2610ebc --- /dev/null +++ b/src/app/schemas/email.py @@ -0,0 +1,10 @@ +from typing import Any + +from pydantic import BaseModel, EmailStr + + +class EmailRequest(BaseModel): + to_email: EmailStr + template_name: str + language: str = "es" + context: dict[str, Any] = {} diff --git a/src/app/services/__init__.py b/src/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/services/email_engine.py b/src/app/services/email_engine.py new file mode 100644 index 0000000..0e78327 --- /dev/null +++ b/src/app/services/email_engine.py @@ -0,0 +1,131 @@ +import json +import logging +from pathlib import Path + +import resend +from fastapi import HTTPException +from jinja2 import Environment, FileSystemLoader, TemplateNotFound + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +resend.api_key = settings.RESEND_API_KEY +template_env = Environment( + loader=FileSystemLoader(settings.TEMPLATE_FOLDER), autoescape=True +) + + +class EmailService: + _locales_cache: dict[str, dict] = {} + + @classmethod + def load_locales(cls) -> None: + try: + locales_dir = Path(__file__).parent.parent / "locales" + if not locales_dir.exists(): + logger.warning("Locales directory not found: %s", locales_dir) + return + + for file_path in locales_dir.glob("*.json"): + try: + content = json.loads(file_path.read_text(encoding="utf-8")) + cls._locales_cache[file_path.stem] = content + logger.info("Loaded locale: %s", file_path.stem) + except json.JSONDecodeError: + logger.error("Failed to parse JSON for locale: %s", file_path.name) + except Exception as e: + logger.error("Error loading locales: %s", e) + + @classmethod + def _get_translation_from_cache(cls, language: str) -> dict: + if language not in cls._locales_cache: + logger.warning( + "Language '%s' not found in cache, falling back to 'es'", language + ) + return cls._locales_cache.get("es", {}) + return cls._locales_cache[language] + + @staticmethod + def _render_template( + template_path: str, specific_context: dict, translations: dict + ) -> tuple[str, str]: + logger.debug("Rendering template: %s", template_path) + + global_context = { + "site_url": settings.BASE_SITE_URL, + "project_name": settings.PROJECT_NAME, + "t": translations, + } + final_context = {**global_context, **specific_context} + + try: + template = template_env.get_template(template_path) + except TemplateNotFound: + logger.error("Template NOT FOUND at path: %s", template_path) + raise HTTPException( + status_code=404, detail=f"Template not found: {template_path}" + ) + + html_content = template.render(final_context) + + try: + subject_block = template.blocks["subject"] + ctx = template.new_context(final_context) + subject = "".join(subject_block(ctx)) + except KeyError: + logger.warning( + "Could not find subject block for template: %s", template_path + ) + subject = translations.get("base", {}).get("subject_prefix", "Notification") + + return subject, html_content + + @classmethod + async def send_email( + cls, to_email: str, language: str, template_name: str, context: dict + ): + template_path = f"{template_name}/content.html" + + translations = cls._get_translation_from_cache(language) + + logger.info( + "Processing email request -> To: %s | Template: %s | Lang: %s", + to_email, + template_path, + language, + ) + + subject, html_content = cls._render_template( + template_path, context, translations + ) + + from_address = f"{settings.FROM_EMAIL_NAME} <{settings.FROM_EMAIL}>" + + try: + logger.debug("Sending via Resend API to %s...", to_email) + + response = resend.Emails.send( + { + "from": from_address, + "to": to_email, + "subject": subject, + "html": html_content, + } + ) + + logger.info( + "Email sent successfully to %s. Resend ID: %s", + to_email, + response.get("id", "unknown"), + ) + return response + + except Exception as e: + logger.error( + "Failed to send email via Resend to %s. Error: %s", + to_email, + str(e), + exc_info=True, + ) + raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..6d4401c --- /dev/null +++ b/src/main.py @@ -0,0 +1,29 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.core.logging_config import setup_logging +from app.routers import notifications +from app.services.email_engine import EmailService + +setup_logging() +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("πŸš€ Starting Notifications Microservice...") + EmailService.load_locales() + yield + logger.info("πŸ›‘ Shutting down Notifications Microservice...") + + +app = FastAPI(title="Notifications Microservice", lifespan=lifespan) + +app.include_router(notifications.router) + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8000) # nosec B104 diff --git a/src/pyproject.toml b/src/pyproject.toml new file mode 100644 index 0000000..7feb3e1 --- /dev/null +++ b/src/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "Notifications" +version = "1.0.0" +description = "Notifications service for the project. It is connected with resend to manage the emails." +readme = "README.md" +requires-python = ">=3.14" +dependencies = [ + "fastapi==0.128.0", + "jinja2==3.1.6", + "pydantic[email]==2.12.5", + "pydantic-settings==2.12.0", + "resend==2.19.0", + "uvicorn==0.40.0", +] + +[project.optional-dependencies] +dev = [ + "httpx==0.28.1", + "pre-commit==4.5.1", + "coverage==7.13.1", +] + +[tool.coverage.run] +branch = true +source = ["app"] +omit = [ + "*/tests/*", + "*/__init__.py" +] + +[tool.coverage.report] +show_missing = true +skip_empty = true +fail_under = 80 diff --git a/src/templates/base.html b/src/templates/base.html new file mode 100644 index 0000000..2cac7fd --- /dev/null +++ b/src/templates/base.html @@ -0,0 +1,50 @@ + + + + + + {% block subject %}{{ t.base.subject_prefix }}{% endblock %} + + + + + + + +
+ + + + + + + + + +
+ + diff --git a/src/templates/register/content.html b/src/templates/register/content.html new file mode 100644 index 0000000..228c5a9 --- /dev/null +++ b/src/templates/register/content.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block subject %}{{ t.register.subject }}{% endblock %} + +{% block content %} +

{{ t.register.title }}

+

+ {{ t.register.intro|safe }} +

+

+ {{ t.register.verify_text }} +

+

+ + {{ t.register.button }} + +

+

+ {{ t.register.fallback_text }}
+ {{site_url}}/api/users/verify/{{user_uuid}} +

+

+ {{ t.register.salutation }}
+ {{ t.register.team }} +

+{% endblock %} diff --git a/src/templates/registration_reminder_30_days/content.html b/src/templates/registration_reminder_30_days/content.html new file mode 100644 index 0000000..3c590f8 --- /dev/null +++ b/src/templates/registration_reminder_30_days/content.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block subject %}{{ t.registration_reminder_30_days.subject }}{% endblock %} + +{% block content %} +

{{ t.registration_reminder_30_days.title }}

+

+ {{ t.registration_reminder_30_days.intro|safe }} +

+

+ {{ t.registration_reminder_30_days.verify_text }} +

+

+ + {{ t.registration_reminder_30_days.button }} + +

+

+ {{ t.registration_reminder_30_days.fallback_text }}
+ {{site_url}}/api/users/verify/{{user_uuid}} +

+

+ {{ t.registration_reminder_30_days.salutation }}
+ {{ t.registration_reminder_30_days.team }} +

+{% endblock %} diff --git a/src/templates/registration_reminder_3_days/content.html b/src/templates/registration_reminder_3_days/content.html new file mode 100644 index 0000000..45a4525 --- /dev/null +++ b/src/templates/registration_reminder_3_days/content.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block subject %}{{ t.registration_reminder_3_days.subject }}{% endblock %} + +{% block content %} +

{{ t.registration_reminder_3_days.title }}

+

+ {{ t.registration_reminder_3_days.intro|safe }} +

+

+ {{ t.registration_reminder_3_days.verify_text }} +

+

+ + {{ t.registration_reminder_3_days.button }} + +

+

+ {{ t.registration_reminder_3_days.fallback_text }}
+ {{site_url}}/api/users/verify/{{user_uuid}} +

+

+ {{ t.registration_reminder_3_days.salutation }}
+ {{ t.registration_reminder_3_days.team }} +

+{% endblock %} diff --git a/src/templates/registration_reminder_7_days/content.html b/src/templates/registration_reminder_7_days/content.html new file mode 100644 index 0000000..c12bd9f --- /dev/null +++ b/src/templates/registration_reminder_7_days/content.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block subject %}{{ t.registration_reminder_7_days.subject }}{% endblock %} + +{% block content %} +

{{ t.registration_reminder_7_days.title }}

+

+ {{ t.registration_reminder_7_days.intro|safe }} +

+

+ {{ t.registration_reminder_7_days.verify_text }} +

+

+ + {{ t.registration_reminder_7_days.button }} + +

+

+ {{ t.registration_reminder_7_days.fallback_text }}
+ {{site_url}}/api/users/verify/{{user_uuid}} +

+

+ {{ t.registration_reminder_7_days.salutation }}
+ {{ t.registration_reminder_7_days.team }} +

+{% endblock %} diff --git a/src/templates/user_deleted_due_no_verification/content.html b/src/templates/user_deleted_due_no_verification/content.html new file mode 100644 index 0000000..6cff807 --- /dev/null +++ b/src/templates/user_deleted_due_no_verification/content.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} + +{% block subject %}{{ t.user_deleted_due_no_verification.subject }}{% endblock %} + +{% block content %} +

{{ t.user_deleted_due_no_verification.title }}

+

+ {{ t.user_deleted_due_no_verification.intro|safe }} +

+

+ {{ t.user_deleted_due_no_verification.re_register_text }} +

+

+ + {{ t.user_deleted_due_no_verification.button }} + +

+

+ {{ t.user_deleted_due_no_verification.salutation }}
+ {{ t.user_deleted_due_no_verification.team }} +

+{% endblock %} diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..3095ca4 --- /dev/null +++ b/src/tests/__init__.py @@ -0,0 +1,3 @@ +import logging + +logging.disable(logging.CRITICAL) diff --git a/src/tests/app/__init__.py b/src/tests/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/app/core/__init__.py b/src/tests/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/app/core/test_config.py b/src/tests/app/core/test_config.py new file mode 100644 index 0000000..082cdf1 --- /dev/null +++ b/src/tests/app/core/test_config.py @@ -0,0 +1,27 @@ +import os +import unittest +from importlib import reload +from unittest.mock import patch + +from app.core import config + + +class TestConfig(unittest.TestCase): + def test_settings_load_correctly(self): + dummy_required_env = { + "RESEND_API_KEY": "mock_resend_key", + "API_KEY": "mock_api_key", + "FROM_EMAIL": "no-reply@test.com", + "FROM_EMAIL_NAME": "Test Sender", + "BASE_SITE_URL": "http://localhost:8000", + } + with patch.dict(os.environ, dummy_required_env, clear=True): + reload(config) + self.assertEqual(config.settings.PROJECT_NAME, "Email Service") + self.assertEqual(config.settings.TEMPLATE_FOLDER, "templates") + + @patch.dict(os.environ, {"PROJECT_NAME": "Test Project", "LOG_LEVEL": "DEBUG"}) + def test_settings_override_from_env(self): + reload(config) + self.assertEqual(config.settings.PROJECT_NAME, "Test Project") + self.assertEqual(config.settings.LOG_LEVEL, "DEBUG") diff --git a/src/tests/app/core/test_security.py b/src/tests/app/core/test_security.py new file mode 100644 index 0000000..2ecc4b4 --- /dev/null +++ b/src/tests/app/core/test_security.py @@ -0,0 +1,29 @@ +import unittest + +from fastapi import HTTPException, status + +from app.core.config import settings +from app.core.security import api_key_auth + + +class TestSecurity(unittest.IsolatedAsyncioTestCase): + + async def test_api_key_auth_valid(self): + header_value = f"Bearer {settings.API_KEY}" + result = await api_key_auth(auth_value=header_value) + self.assertEqual(result, settings.API_KEY) + + async def test_api_key_auth_invalid_key(self): + header_value = "Bearer wrong-key" + with self.assertRaises(HTTPException) as cm: + await api_key_auth(auth_value=header_value) + + self.assertEqual(cm.exception.status_code, status.HTTP_401_UNAUTHORIZED) + self.assertEqual(cm.exception.detail, "Invalid Credentials") + + async def test_api_key_auth_invalid_scheme(self): + header_value = settings.API_KEY + with self.assertRaises(HTTPException) as cm: + await api_key_auth(auth_value=header_value) + + self.assertEqual(cm.exception.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/src/tests/app/routers/__init__.py b/src/tests/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/app/routers/test_notifications.py b/src/tests/app/routers/test_notifications.py new file mode 100644 index 0000000..a71b626 --- /dev/null +++ b/src/tests/app/routers/test_notifications.py @@ -0,0 +1,58 @@ +import unittest +from unittest.mock import AsyncMock, patch + +from fastapi.testclient import TestClient + +from app.core.config import settings +from main import app + + +class TestNotificationsRouter(unittest.TestCase): + def setUp(self): + self.client = TestClient(app) + self.url = "/notifications/send" + # Changed to Authorization header with Bearer scheme + self.valid_headers = {"Authorization": f"Bearer {settings.API_KEY}"} + self.valid_payload = { + "to_email": "user@example.com", + "template_name": "test_template", + "language": "es", + "context": {"name": "User"}, + } + + def test_send_notification_unauthorized(self): + # Changed to Authorization header + headers = {"Authorization": "Bearer wrong-key"} + response = self.client.post(self.url, json=self.valid_payload, headers=headers) + + self.assertEqual(response.status_code, 401) + # Updated expected error message + self.assertEqual(response.json()["detail"], "Invalid Credentials") + + def test_send_notification_bad_request(self): + invalid_payload = {"template_name": "missing_email"} + response = self.client.post( + self.url, json=invalid_payload, headers=self.valid_headers + ) + self.assertEqual(response.status_code, 422) + + @patch("app.services.email_engine.EmailService.send_email", new_callable=AsyncMock) + def test_send_notification_success(self, mock_send_email): + mock_send_email.return_value = {"id": "resend_id_123"} + + response = self.client.post( + self.url, json=self.valid_payload, headers=self.valid_headers + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + {"status": "success", "provider_response": {"id": "resend_id_123"}}, + ) + + mock_send_email.assert_awaited_once_with( + to_email="user@example.com", + language="es", + template_name="test_template", + context={"name": "User"}, + ) diff --git a/src/tests/app/services/__init__.py b/src/tests/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/tests/app/services/test_email_engine.py b/src/tests/app/services/test_email_engine.py new file mode 100644 index 0000000..e30d8c2 --- /dev/null +++ b/src/tests/app/services/test_email_engine.py @@ -0,0 +1,188 @@ +import json +import unittest +from unittest.mock import Mock, patch + +from fastapi import HTTPException +from jinja2 import FileSystemLoader + +from app.services.email_engine import EmailService, template_env + + +class TestEmailEngine(unittest.IsolatedAsyncioTestCase): + + def setUp(self): + self.original_loader = template_env.loader + template_env.loader = FileSystemLoader(["tests/templates", "templates"]) + self._original_cache = EmailService._locales_cache.copy() + EmailService._locales_cache = { + "es": {"test": "ok_es"}, + "en": {"test": "ok_en"}, + } + + def tearDown(self): + template_env.loader = self.original_loader + EmailService._locales_cache = self._original_cache + + def test_get_translation_fallback(self): + result = EmailService._get_translation_from_cache("en") + self.assertEqual(result, {"test": "ok_en"}) + + result = EmailService._get_translation_from_cache("fr") + self.assertEqual(result, {"test": "ok_es"}) + + def test_render_template(self): + EmailService._locales_cache["en"] = { + "test": {"message": "Works!"}, + "base": { + "subject_prefix": "Test Subject", + "doubts": "ΒΏDudas?", + "contact_text": "Contacta", + "contact_email": "test@test.com", + "help_text": "ayuda", + "thanks": "Gracias", + "footer_reason": "RazΓ³n", + "rights": "Derechos", + }, + } + + context = {"key_from_context": "World"} + translations = EmailService._get_translation_from_cache("en") + + subject, html = EmailService._render_template( + "test_template/content.html", context, translations + ) + + self.assertEqual(subject, "Test Subject") + self.assertIn("Hello World", html) + self.assertIn("Works!", html) + + def test_render_template_not_found(self): + with self.assertRaises(HTTPException) as cm: + EmailService._render_template("non_existent_template.html", {}, {}) + self.assertEqual(cm.exception.status_code, 404) + + def test_render_template_fallback_subject(self): + mock_template = Mock() + mock_template.blocks = {} + mock_template.render.return_value = "Content" + mock_template.new_context.return_value = {} + + translations = {"base": {"subject_prefix": "Fallback Subject"}} + + with patch( + "app.services.email_engine.template_env.get_template", + return_value=mock_template, + ): + subject, html = EmailService._render_template("any.html", {}, translations) + + self.assertEqual(subject, "Fallback Subject") + self.assertEqual(html, "Content") + + @patch("app.services.email_engine.resend.Emails.send") + async def test_send_email_success(self, mock_resend): + EmailService._locales_cache["es"] = { + "base": { + "subject_prefix": "Test", + "doubts": "?", + "contact_text": ".", + "contact_email": "a@b.c", + "help_text": ".", + "thanks": ".", + "footer_reason": ".", + "rights": ".", + }, + "test": {"message": "Mock Message"}, + } + mock_resend.return_value = {"id": "12345"} + + to_email = "test@example.com" + context = {"key_from_context": "Unit Test"} + + response = await EmailService.send_email( + to_email, "es", "test_template", context + ) + + mock_resend.assert_called_once() + call_args = mock_resend.call_args[0][0] + self.assertEqual(call_args["to"], to_email) + self.assertIn("Hello Unit Test", call_args["html"]) + self.assertEqual(response, {"id": "12345"}) + + @patch("app.services.email_engine.resend.Emails.send") + async def test_send_email_failure(self, mock_resend): + EmailService._locales_cache["es"] = { + "base": {"subject_prefix": "S"}, + "test": {"message": "M"}, + } + mock_resend.side_effect = Exception("Resend API Down") + + with self.assertRaises(HTTPException) as cm: + await EmailService.send_email("test@fail.com", "es", "test_template", {}) + + self.assertEqual(cm.exception.status_code, 500) + self.assertIn("Resend API Down", cm.exception.detail) + + @patch("app.services.email_engine.Path") + def test_load_locales(self, mock_path_cls): + mock_locales_dir = Mock() + mock_locales_dir.exists.return_value = True + + mock_file = Mock() + mock_file.stem = "de" + mock_file.read_text.return_value = '{"test": "german"}' + + mock_locales_dir.glob.return_value = [mock_file] + + # Mock the chain Path(__file__).parent.parent / "locales" + mock_path_cls.return_value.parent.parent.__truediv__.return_value = ( + mock_locales_dir + ) + + EmailService._locales_cache = {} + EmailService.load_locales() + + self.assertIn("de", EmailService._locales_cache) + self.assertEqual(EmailService._locales_cache["de"], {"test": "german"}) + + @patch("app.services.email_engine.Path") + def test_load_locales_directory_not_found(self, mock_path_cls): + mock_locales_dir = Mock() + mock_locales_dir.exists.return_value = False + + mock_path_cls.return_value.parent.parent.__truediv__.return_value = ( + mock_locales_dir + ) + + EmailService._locales_cache = {} + EmailService.load_locales() + + self.assertEqual(EmailService._locales_cache, {}) + + @patch("app.services.email_engine.Path") + def test_load_locales_json_error(self, mock_path_cls): + mock_locales_dir = Mock() + mock_locales_dir.exists.return_value = True + + mock_file_ok = Mock() + mock_file_ok.stem = "ok" + mock_file_ok.read_text.return_value = "{}" + + mock_file_bad = Mock() + mock_file_bad.name = "bad.json" + mock_file_bad.read_text.side_effect = json.JSONDecodeError("Fail", "doc", 0) + + mock_locales_dir.glob.return_value = [mock_file_ok, mock_file_bad] + mock_path_cls.return_value.parent.parent.__truediv__.return_value = ( + mock_locales_dir + ) + + EmailService._locales_cache = {} + EmailService.load_locales() + + self.assertIn("ok", EmailService._locales_cache) + + @patch("app.services.email_engine.Path") + def test_load_locales_generic_exception(self, mock_path_cls): + mock_path_cls.side_effect = Exception("Catastrophic filesystem failure") + + EmailService.load_locales() diff --git a/src/tests/templates/test_template/content.html b/src/tests/templates/test_template/content.html new file mode 100644 index 0000000..02849ed --- /dev/null +++ b/src/tests/templates/test_template/content.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} + +{% block subject %}Test Subject{% endblock %} + +{% block content %} +

Hello {{ key_from_context }}

+

{{ t.test.message }}

+{% endblock %} diff --git a/src/uv.lock b/src/uv.lock new file mode 100644 index 0000000..cfcd4dd --- /dev/null +++ b/src/uv.lock @@ -0,0 +1,557 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[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, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[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, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, +] + +[[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 = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "notifications" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "jinja2" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "resend" }, + { name = "uvicorn" }, +] + +[package.optional-dependencies] +dev = [ + { name = "coverage" }, + { name = "httpx" }, + { name = "pre-commit" }, +] + +[package.metadata] +requires-dist = [ + { name = "coverage", marker = "extra == 'dev'", specifier = "==7.13.1" }, + { name = "fastapi", specifier = "==0.128.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.5.1" }, + { name = "pydantic", extras = ["email"], specifier = "==2.12.5" }, + { name = "pydantic-settings", specifier = "==2.12.0" }, + { name = "resend", specifier = "==2.19.0" }, + { name = "uvicorn", specifier = "==0.40.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "resend" +version = "2.19.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d8/21/1ddb4c221fc8310a99956f55c2e5e21b548378bde4a662473dcdda6f4018/resend-2.19.0.tar.gz", hash = "sha256:b11191561cdb0ed7aa193212b7c8865bf635013c4d11bd81caf471d1b362be02", size = 30604, upload-time = "2025-10-31T13:59:32.274Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/1f/f53acaad9299a72671c4314fd42c0b332464eaf0d20ea5303e75b8a9a963/resend-2.19.0-py2.py3-none-any.whl", hash = "sha256:1a8b9fcacbe058876ebce757ac2542103ed7227caec10e5c58613ee58615acaa", size = 50881, upload-time = "2025-10-31T13:59:31.015Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +]