From 6d593b45545524f104c7e8e0c790b43f976a6732 Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 19 Oct 2025 22:09:35 +0300 Subject: [PATCH] initial commit --- CODE_OF_CONDUCT.md | 128 + CONTRIBUTING.md | 99 + Dockerfile | 44 + LICENSE.md | 21 + README.md | 2225 +++++++++++++++++ build-docker.sh | 2 + default.conf | 32 + docker-compose.prod.yml | 57 + docker-compose.test.yml | 8 + docker-compose.yml | 81 + docs/assets/FastAPI-boilerplate.png | Bin 0 -> 399217 bytes docs/community.md | 96 + docs/getting-started/configuration.md | 163 ++ docs/getting-started/first-run.md | 594 +++++ docs/getting-started/index.md | 181 ++ docs/getting-started/installation.md | 366 +++ docs/index.md | 126 + docs/stylesheets/extra.css | 20 + docs/user-guide/admin-panel/adding-models.md | 480 ++++ docs/user-guide/admin-panel/configuration.md | 378 +++ docs/user-guide/admin-panel/index.md | 295 +++ .../user-guide/admin-panel/user-management.md | 213 ++ docs/user-guide/api/endpoints.md | 327 +++ docs/user-guide/api/exceptions.md | 465 ++++ docs/user-guide/api/index.md | 125 + docs/user-guide/api/pagination.md | 316 +++ docs/user-guide/api/versioning.md | 418 ++++ docs/user-guide/authentication/index.md | 198 ++ docs/user-guide/authentication/jwt-tokens.md | 669 +++++ docs/user-guide/authentication/permissions.md | 634 +++++ .../authentication/user-management.md | 879 +++++++ docs/user-guide/background-tasks/index.md | 92 + docs/user-guide/caching/cache-strategies.md | 191 ++ docs/user-guide/caching/client-cache.md | 515 ++++ docs/user-guide/caching/index.md | 77 + docs/user-guide/caching/redis-cache.md | 359 +++ docs/user-guide/configuration/docker-setup.md | 539 ++++ .../configuration/environment-specific.md | 692 +++++ .../configuration/environment-variables.md | 651 +++++ docs/user-guide/configuration/index.md | 311 +++ .../configuration/settings-classes.md | 537 ++++ docs/user-guide/database/crud.md | 491 ++++ docs/user-guide/database/index.md | 235 ++ docs/user-guide/database/migrations.md | 470 ++++ docs/user-guide/database/models.md | 484 ++++ docs/user-guide/database/schemas.md | 650 +++++ docs/user-guide/development.md | 717 ++++++ docs/user-guide/index.md | 86 + docs/user-guide/production.md | 709 ++++++ docs/user-guide/project-structure.md | 296 +++ docs/user-guide/rate-limiting/index.md | 481 ++++ docs/user-guide/testing.md | 810 ++++++ mkdocs.yml | 159 ++ pyproject.toml | 123 + src/__init__.py | 0 src/alembic.ini | 116 + src/app/__init__.py | 0 src/app/admin/__init__.py | 0 src/app/admin/initialize.py | 53 + src/app/admin/views.py | 44 + src/app/api/__init__.py | 6 + src/app/api/dependencies.py | 80 + src/app/api/v1/__init__.py | 13 + src/app/api/v1/counterparty.py | 20 + src/app/api/v1/login.py | 58 + src/app/api/v1/logout.py | 31 + src/app/api/v1/tasks.py | 58 + src/app/api/v1/users.py | 145 ++ src/app/core/__init__.py | 0 src/app/core/config.py | 154 ++ src/app/core/db/__init__.py | 0 src/app/core/db/crud_token_blacklist.py | 14 + src/app/core/db/database.py | 26 + src/app/core/db/models.py | 27 + src/app/core/db/token_blacklist.py | 14 + src/app/core/exceptions/__init__.py | 0 src/app/core/exceptions/cache_exceptions.py | 16 + src/app/core/exceptions/http_exceptions.py | 11 + src/app/core/logger.py | 20 + src/app/core/schemas.py | 75 + src/app/core/security.py | 137 + src/app/core/setup.py | 229 ++ src/app/core/utils/__init__.py | 0 src/app/core/utils/cache.py | 337 +++ src/app/core/utils/queue.py | 3 + src/app/core/utils/rate_limit.py | 61 + src/app/core/worker/__init__.py | 0 src/app/core/worker/functions.py | 24 + src/app/core/worker/settings.py | 15 + src/app/crud/__init__.py | 0 src/app/crud/crud_users.py | 7 + src/app/main.py | 39 + src/app/middleware/client_cache_middleware.py | 56 + src/app/models/__init__.py | 1 + src/app/models/user.py | 28 + src/app/schemas/__init__.py | 0 src/app/schemas/job.py | 5 + src/app/schemas/user.py | 70 + src/app/test.py | 16 + src/migrations/README | 1 + src/migrations/env.py | 88 + src/migrations/script.py.mako | 26 + src/migrations/versions/README.MD | 0 .../a1de33a00c8f_add_user_profile_fields.py | 37 + .../d315dba4434b_add_user_profile_fields.py | 89 + src/scripts/__init__.py | 0 src/scripts/create_first_superuser.py | 78 + src/scripts/create_first_tier.py | 41 + tests/__init__.py | 0 tests/conftest.py | 102 + tests/helpers/generators.py | 25 + tests/helpers/mocks.py | 17 + tests/test_user.py | 195 ++ uv.lock | 1599 ++++++++++++ 114 files changed, 23622 insertions(+) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE.md create mode 100644 README.md create mode 100755 build-docker.sh create mode 100644 default.conf create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.test.yml create mode 100644 docker-compose.yml create mode 100644 docs/assets/FastAPI-boilerplate.png create mode 100644 docs/community.md create mode 100644 docs/getting-started/configuration.md create mode 100644 docs/getting-started/first-run.md create mode 100644 docs/getting-started/index.md create mode 100644 docs/getting-started/installation.md create mode 100644 docs/index.md create mode 100644 docs/stylesheets/extra.css create mode 100644 docs/user-guide/admin-panel/adding-models.md create mode 100644 docs/user-guide/admin-panel/configuration.md create mode 100644 docs/user-guide/admin-panel/index.md create mode 100644 docs/user-guide/admin-panel/user-management.md create mode 100644 docs/user-guide/api/endpoints.md create mode 100644 docs/user-guide/api/exceptions.md create mode 100644 docs/user-guide/api/index.md create mode 100644 docs/user-guide/api/pagination.md create mode 100644 docs/user-guide/api/versioning.md create mode 100644 docs/user-guide/authentication/index.md create mode 100644 docs/user-guide/authentication/jwt-tokens.md create mode 100644 docs/user-guide/authentication/permissions.md create mode 100644 docs/user-guide/authentication/user-management.md create mode 100644 docs/user-guide/background-tasks/index.md create mode 100644 docs/user-guide/caching/cache-strategies.md create mode 100644 docs/user-guide/caching/client-cache.md create mode 100644 docs/user-guide/caching/index.md create mode 100644 docs/user-guide/caching/redis-cache.md create mode 100644 docs/user-guide/configuration/docker-setup.md create mode 100644 docs/user-guide/configuration/environment-specific.md create mode 100644 docs/user-guide/configuration/environment-variables.md create mode 100644 docs/user-guide/configuration/index.md create mode 100644 docs/user-guide/configuration/settings-classes.md create mode 100644 docs/user-guide/database/crud.md create mode 100644 docs/user-guide/database/index.md create mode 100644 docs/user-guide/database/migrations.md create mode 100644 docs/user-guide/database/models.md create mode 100644 docs/user-guide/database/schemas.md create mode 100644 docs/user-guide/development.md create mode 100644 docs/user-guide/index.md create mode 100644 docs/user-guide/production.md create mode 100644 docs/user-guide/project-structure.md create mode 100644 docs/user-guide/rate-limiting/index.md create mode 100644 docs/user-guide/testing.md create mode 100644 mkdocs.yml create mode 100644 pyproject.toml create mode 100644 src/__init__.py create mode 100644 src/alembic.ini create mode 100644 src/app/__init__.py create mode 100644 src/app/admin/__init__.py create mode 100644 src/app/admin/initialize.py create mode 100644 src/app/admin/views.py create mode 100644 src/app/api/__init__.py create mode 100644 src/app/api/dependencies.py create mode 100644 src/app/api/v1/__init__.py create mode 100644 src/app/api/v1/counterparty.py create mode 100644 src/app/api/v1/login.py create mode 100644 src/app/api/v1/logout.py create mode 100644 src/app/api/v1/tasks.py create mode 100644 src/app/api/v1/users.py create mode 100644 src/app/core/__init__.py create mode 100644 src/app/core/config.py create mode 100644 src/app/core/db/__init__.py create mode 100644 src/app/core/db/crud_token_blacklist.py create mode 100644 src/app/core/db/database.py create mode 100644 src/app/core/db/models.py create mode 100644 src/app/core/db/token_blacklist.py create mode 100644 src/app/core/exceptions/__init__.py create mode 100644 src/app/core/exceptions/cache_exceptions.py create mode 100644 src/app/core/exceptions/http_exceptions.py create mode 100644 src/app/core/logger.py create mode 100644 src/app/core/schemas.py create mode 100644 src/app/core/security.py create mode 100644 src/app/core/setup.py create mode 100644 src/app/core/utils/__init__.py create mode 100644 src/app/core/utils/cache.py create mode 100644 src/app/core/utils/queue.py create mode 100644 src/app/core/utils/rate_limit.py create mode 100644 src/app/core/worker/__init__.py create mode 100644 src/app/core/worker/functions.py create mode 100644 src/app/core/worker/settings.py create mode 100644 src/app/crud/__init__.py create mode 100644 src/app/crud/crud_users.py create mode 100644 src/app/main.py create mode 100644 src/app/middleware/client_cache_middleware.py create mode 100644 src/app/models/__init__.py create mode 100644 src/app/models/user.py create mode 100644 src/app/schemas/__init__.py create mode 100644 src/app/schemas/job.py create mode 100644 src/app/schemas/user.py create mode 100644 src/app/test.py create mode 100644 src/migrations/README create mode 100644 src/migrations/env.py create mode 100644 src/migrations/script.py.mako create mode 100644 src/migrations/versions/README.MD create mode 100644 src/migrations/versions/a1de33a00c8f_add_user_profile_fields.py create mode 100644 src/migrations/versions/d315dba4434b_add_user_profile_fields.py create mode 100644 src/scripts/__init__.py create mode 100644 src/scripts/create_first_superuser.py create mode 100644 src/scripts/create_first_tier.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/helpers/generators.py create mode 100644 tests/helpers/mocks.py create mode 100644 tests/test_user.py create mode 100644 uv.lock diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..2a800c9 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +igor.magalhaes.r@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b9d0c12 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,99 @@ +# Contributing to FastAPI-boilerplate + +Thank you for your interest in contributing to FastAPI-boilerplate! This guide is meant to make it easy for you to get started. +Contributions are appreciated, even if just reporting bugs, documenting stuff or answering questions. To contribute with a feature: + +## Setting Up Your Development Environment + +### Cloning the Repository +Start by forking and cloning the FastAPI-boilerplate repository: + +1. **Fork the Repository**: Begin by forking the project repository. You can do this by visiting https://github.com/igormagalhaesr/FastAPI-boilerplate and clicking the "Fork" button. +1. **Create a Feature Branch**: Once you've forked the repo, create a branch for your feature by running `git checkout -b feature/fooBar`. +1. **Testing Changes**: Ensure that your changes do not break existing functionality by running tests. In the root folder, execute `uv run pytest` to run the tests. + +### Using uv for Dependency Management +FastAPI-boilerplate uses uv for managing dependencies. If you don't have uv installed, follow the instructions on the [official uv website](https://docs.astral.sh/uv/). + +Once uv is installed, navigate to the cloned repository and install the dependencies: +```sh +cd FastAPI-boilerplate +uv sync +``` + +### Activating the Virtual Environment +uv creates a virtual environment for your project. Activate it using: + +```sh +source .venv/bin/activate +``` + +Alternatively, you can run commands directly with `uv run` without activating the environment: +```sh +uv run python your_script.py +``` + +## Making Contributions + +### Coding Standards +- Follow PEP 8 guidelines. +- Write meaningful tests for new features or bug fixes. + +### Testing with Pytest +FastAPI-boilerplate uses pytest for testing. Run tests using: +```sh +uv run pytest +``` + +### Linting +Use mypy for type checking: +```sh +mypy src +``` + +Use ruff for style: +```sh +ruff check --fix +ruff format +``` + +Ensure your code passes linting before submitting. + +### Using pre-commit for Better Code Quality + +It helps in identifying simple issues before submission to code review. By running automated checks, pre-commit can ensure code quality and consistency. + +1. **Install Pre-commit**: + - **Installation**: Install pre-commit in your development environment. Use the command `uv add --dev pre-commit` or `pip install pre-commit`. + - **Setting Up Hooks**: After installing pre-commit, set up the hooks with `pre-commit install`. This command will install hooks into your .git/ directory which will automatically check your commits for issues. +1. **Committing Your Changes**: + After making your changes, use `git commit -am 'Add some fooBar'` to commit them. Pre-commit will run automatically on your files when you commit, ensuring that they meet the required standards. + Note: If pre-commit identifies issues, it may block your commit. Fix these issues and commit again. This ensures that all contributions are of high quality. +1. **Pushing Changes and Creating Pull Request**: + Push your changes to the branch using `git push origin feature/fooBar`. + Visit your fork on GitHub and create a new Pull Request to the main repository. + +### Additional Notes + +**Stay Updated**: Keep your fork updated with the main repository to avoid merge conflicts. Regularly fetch and merge changes from the upstream repository. +**Adhere to Project Conventions**: Follow the coding style, conventions, and commit message guidelines of the project. +**Open Communication**: Feel free to ask questions or discuss your ideas by opening an issue or in discussions. + +## Submitting Your Contributions + +### Creating a Pull Request +After making your changes: + +- Push your changes to your fork. +- Open a pull request with a clear description of your changes. +- Update the README.md if necessary. + + +### Code Reviews +- Address any feedback from code reviews. +- Once approved, your contributions will be merged into the main branch. + +## Code of Conduct +Please adhere to our [Code of Conduct](CODE_OF_CONDUCT.md) to maintain a welcoming and inclusive environment. + +Thank you for contributing to FastAPI-boilerplate๐Ÿš€ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2c3795a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# --------- Builder Stage --------- +FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder + +# Set environment variables for uv +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +WORKDIR /app + +# Install dependencies first (for better layer caching) +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project + +# Copy the project source code +COPY . /app + +# Install the project in non-editable mode +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-editable + +# --------- Final Stage --------- +FROM python:3.11-slim-bookworm + +# Create a non-root user for security +RUN groupadd --gid 1000 app \ + && useradd --uid 1000 --gid app --shell /bin/bash --create-home app + +# Copy the virtual environment from the builder stage +COPY --from=builder --chown=app:app /app/.venv /app/.venv + +# Ensure the virtual environment is in the PATH +ENV PATH="/app/.venv/bin:$PATH" + +# Switch to the non-root user +USER app + +# Set the working directory +WORKDIR /code + +# -------- replace with comment to run with gunicorn -------- +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +# CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "-b", "0.0.0.0:8000"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..2e98dd5 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Igor Magalhรฃes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c5dce7 --- /dev/null +++ b/README.md @@ -0,0 +1,2225 @@ +

Benav Labs FastAPI boilerplate

+

+ Yet another template to speed your FastAPI development up. +

+ +

+ + Purple Rocket with FastAPI Logo as its window. + +

+ +

+ + Python + + + FastAPI + + + Pydantic + + + PostgreSQL + + + Redis + + + Docker + + + NGINX + + + DeepWiki + +

+ +--- + +## ๐Ÿ“– Documentation + +๐Ÿ“š **[Visit our comprehensive documentation at benavlabs.github.io/FastAPI-boilerplate](https://benavlabs.github.io/FastAPI-boilerplate/)** + +๐Ÿง  **DeepWiki Docs: [deepwiki.com/benavlabs/FastAPI-boilerplate](https://deepwiki.com/benavlabs/FastAPI-boilerplate)** + +> **โš ๏ธ Documentation Status** +> +> This is our first version of the documentation. While functional, we acknowledge it's rough around the edges - there's a huge amount to document and we needed to start somewhere! We built this foundation (with a lot of AI assistance) so we can improve upon it. +> +> Better documentation, examples, and guides are actively being developed. Contributions and feedback are greatly appreciated! + +This README provides a quick reference for LLMs and developers, but the full documentation contains detailed guides, examples, and best practices. + +--- + +## 0. About + +**FastAPI boilerplate** creates an extendable async API using FastAPI, Pydantic V2, SQLAlchemy 2.0 and PostgreSQL: + +- [`FastAPI`](https://fastapi.tiangolo.com): modern Python web framework for building APIs +- [`Pydantic V2`](https://docs.pydantic.dev/2.4/): the most widely used data Python validation library, rewritten in Rust [`(5x-50x faster)`](https://docs.pydantic.dev/latest/blog/pydantic-v2-alpha/) +- [`SQLAlchemy 2.0`](https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html): Python SQL toolkit and Object Relational Mapper +- [`PostgreSQL`](https://www.postgresql.org): The World's Most Advanced Open Source Relational Database +- [`Redis`](https://redis.io): Open source, in-memory data store used by millions as a cache, message broker and more. +- [`ARQ`](https://arq-docs.helpmanual.io) Job queues and RPC in python with asyncio and redis. +- [`Docker Compose`](https://docs.docker.com/compose/) With a single command, create and start all the services from your configuration. +- [`NGINX`](https://nginx.org/en/) High-performance low resource consumption web server used for Reverse Proxy and Load Balancing. + +
+ + fastroai-banner + +
+ +## ๐Ÿš€ Join Our Community + +๐Ÿ’ฌ **[Join our Discord community](https://discord.gg/jhhbkxBmhj)** - Connect with other developers using the FastAPI boilerplate! + +Our Discord server features: +- **๐Ÿค Networking** - Connect with fellow developers and share experiences +- **๐Ÿ’ก Product Updates** - Stay updated with FastroAI and our other products +- **๐Ÿ“ธ Showcase** - Share what you've built using our tools +- **๐Ÿ—’๏ธ Blog** - Latest blog posts and technical insights +- **๐Ÿ’ฌ General Discussion** - Open space for questions and discussions +- **๐ŸŽค Community Voice** - Join live talks and community events + +Whether you're just getting started or building production applications, our community is here to help you succeed! + +## 1. Features + +- โšก๏ธ Fully async +- ๐Ÿš€ Pydantic V2 and SQLAlchemy 2.0 +- ๐Ÿ” User authentication with JWT +- ๐Ÿช Cookie based refresh token +- ๐Ÿฌ Easy redis caching +- ๐Ÿ‘œ Easy client-side caching +- ๐Ÿšฆ ARQ integration for task queue +- โš™๏ธ Efficient and robust queries with fastcrud +- โŽ˜ Out of the box offset and cursor pagination support with fastcrud +- ๐Ÿ›‘ Rate Limiter dependency +- ๐Ÿ‘ฎ FastAPI docs behind authentication and hidden based on the environment +- ๐Ÿ”ง Modern and light admin interface powered by [CRUDAdmin](https://github.com/benavlabs/crudadmin) +- ๐Ÿšš Easy running with docker compose +- โš–๏ธ NGINX Reverse Proxy and Load Balancing + +## 2. Contents + +0. [About](#0-about) +1. [Features](#1-features) +1. [Contents](#2-contents) +1. [Prerequisites](#3-prerequisites) + 1. [Environment Variables (.env)](#31-environment-variables-env) + 1. [Docker Compose](#32-docker-compose-preferred) + 1. [From Scratch](#33-from-scratch) +1. [Usage](#4-usage) + 1. [Docker Compose](#41-docker-compose) + 1. [From Scratch](#42-from-scratch) + 1. [Packages](#421-packages) + 1. [Running PostgreSQL With Docker](#422-running-postgresql-with-docker) + 1. [Running Redis with Docker](#423-running-redis-with-docker) + 1. [Running the API](#424-running-the-api) + 1. [Creating the first superuser](#43-creating-the-first-superuser) + 1. [Database Migrations](#44-database-migrations) +1. [Extending](#5-extending) + 1. [Project Structure](#51-project-structure) + 1. [Database Model](#52-database-model) + 1. [SQLAlchemy Models](#53-sqlalchemy-models) + 1. [Pydantic Schemas](#54-pydantic-schemas) + 1. [Alembic Migrations](#55-alembic-migrations) + 1. [CRUD](#56-crud) + 1. [Routes](#57-routes) + 1. [Paginated Responses](#571-paginated-responses) + 1. [HTTP Exceptions](#572-http-exceptions) + 1. [Caching](#58-caching) + 1. [More Advanced Caching](#59-more-advanced-caching) + 1. [ARQ Job Queues](#510-arq-job-queues) + 1. [Rate Limiting](#511-rate-limiting) + 1. [JWT Authentication](#512-jwt-authentication) + 1. [Admin Panel](#513-admin-panel) + 1. [Running](#514-running) + 1. [Create Application](#515-create-application) + 2. [Opting Out of Services](#516-opting-out-of-services) +1. [Running in Production](#6-running-in-production) + 1. [Uvicorn Workers with Gunicorn](#61-uvicorn-workers-with-gunicorn) + 1. [Running With NGINX](#62-running-with-nginx) + 1. [One Server](#621-one-server) + 1. [Multiple Servers](#622-multiple-servers) +1. [Testing](#7-testing) +1. [Contributing](#8-contributing) +1. [References](#9-references) +1. [License](#10-license) +1. [Contact](#11-contact) + +______________________________________________________________________ + +## 3. Prerequisites + +> ๐Ÿ“– **[See detailed installation guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/getting-started/installation/)** + +### 3.0 Start + +Start by using the template, and naming the repository to what you want. + +

+ clicking use this template button, then create a new repository option +

+ +Then clone your created repository (I'm using the base for the example) + +```sh +git clone https://github.com/igormagalhaesr/FastAPI-boilerplate +``` + +> \[!TIP\] +> If you are in a hurry, you may use one of the following templates (containing a `.env`, `docker-compose.yml` and `Dockerfile`): + +- [Running locally with uvicorn](https://gist.github.com/igorbenav/48ad745120c3f77817e094f3a609111a) +- [Runing in staging with gunicorn managing uvicorn workers](https://gist.github.com/igorbenav/d0518d4f6bdfb426d4036090f74905ee) +- [Running in production with NGINX](https://gist.github.com/igorbenav/232c3b73339d6ca74e2bf179a5ef48a1) + +> \[!WARNING\] +> Do not forget to place `docker-compose.yml` and `Dockerfile` in the `root` folder, while `.env` should be in the `src` folder. + +### 3.1 Environment Variables (.env) + +> ๐Ÿ“– **[See complete configuration guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/getting-started/configuration/)** + +Then create a `.env` file inside `src` directory: + +```sh +touch .env +``` + +Inside of `.env`, create the following app settings variables: + +``` +# ------------- app settings ------------- +APP_NAME="Your app name here" +APP_DESCRIPTION="Your app description here" +APP_VERSION="0.1" +CONTACT_NAME="Your name" +CONTACT_EMAIL="Your email" +LICENSE_NAME="The license you picked" +``` + +For the database ([`if you don't have a database yet, click here`](#422-running-postgresql-with-docker)), create: + +``` +# ------------- database ------------- +POSTGRES_USER="your_postgres_user" +POSTGRES_PASSWORD="your_password" +POSTGRES_SERVER="your_server" # default "localhost", if using docker compose you should use "db" +POSTGRES_PORT=5432 # default "5432", if using docker compose you should use "5432" +POSTGRES_DB="your_db" +``` + +For database administration using PGAdmin create the following variables in the .env file + +``` +# ------------- pgadmin ------------- +PGADMIN_DEFAULT_EMAIL="your_email_address" +PGADMIN_DEFAULT_PASSWORD="your_password" +PGADMIN_LISTEN_PORT=80 +``` + +To connect to the database, log into the PGAdmin console with the values specified in `PGADMIN_DEFAULT_EMAIL` and `PGADMIN_DEFAULT_PASSWORD`. + +Once in the main PGAdmin screen, click Add Server: + +![pgadmin-connect](https://github.com/igorbenav/docs-images/blob/main/289698727-e15693b6-fae9-4ec6-a597-e70ab6f44133-3.png?raw=true) + +1. Hostname/address is `db` (if using containers) +1. Is the value you specified in `POSTGRES_PORT` +1. Leave this value as `postgres` +1. is the value you specified in `POSTGRES_USER` +1. Is the value you specified in `POSTGRES_PASSWORD` + +For crypt: +Start by running + +```sh +openssl rand -hex 32 +``` + +And then create in `.env`: + +``` +# ------------- crypt ------------- +SECRET_KEY= # result of openssl rand -hex 32 +ALGORITHM= # pick an algorithm, default HS256 +ACCESS_TOKEN_EXPIRE_MINUTES= # minutes until token expires, default 30 +REFRESH_TOKEN_EXPIRE_DAYS= # days until token expires, default 7 +``` + +Then for the first admin user: + +``` +# ------------- admin ------------- +ADMIN_NAME="your_name" +ADMIN_EMAIL="your_email" +ADMIN_USERNAME="your_username" +ADMIN_PASSWORD="your_password" +``` + +For the CRUDAdmin panel: + +``` +# ------------- crud admin ------------- +CRUD_ADMIN_ENABLED=true # default=true, set to false to disable admin panel +CRUD_ADMIN_MOUNT_PATH="/admin" # default="/admin", path where admin panel will be mounted + +# ------------- crud admin security ------------- +CRUD_ADMIN_MAX_SESSIONS=10 # default=10, maximum concurrent sessions per user +CRUD_ADMIN_SESSION_TIMEOUT=1440 # default=1440 (24 hours), session timeout in minutes +SESSION_SECURE_COOKIES=true # default=true, use secure cookies + +# ------------- crud admin tracking ------------- +CRUD_ADMIN_TRACK_EVENTS=true # default=true, track admin events +CRUD_ADMIN_TRACK_SESSIONS=true # default=true, track admin sessions in database + +# ------------- crud admin redis (optional for production) ------------- +CRUD_ADMIN_REDIS_ENABLED=false # default=false, use Redis for session storage +CRUD_ADMIN_REDIS_HOST="localhost" # default="localhost", Redis host for admin sessions +CRUD_ADMIN_REDIS_PORT=6379 # default=6379, Redis port for admin sessions +CRUD_ADMIN_REDIS_DB=0 # default=0, Redis database for admin sessions +CRUD_ADMIN_REDIS_PASSWORD="" # optional, Redis password for admin sessions +CRUD_ADMIN_REDIS_SSL=false # default=false, use SSL for Redis connection +``` + +**Session Backend Options:** +- **Memory** (default): Development-friendly, sessions reset on restart +- **Redis** (production): High performance, scalable, persistent sessions +- **Database**: Audit-friendly with admin visibility +- **Hybrid**: Redis performance + database audit trail + +For redis caching: + +``` +# ------------- redis cache------------- +REDIS_CACHE_HOST="your_host" # default "localhost", if using docker compose you should use "redis" +REDIS_CACHE_PORT=6379 # default "6379", if using docker compose you should use "6379" +``` + +And for client-side caching: + +``` +# ------------- redis client-side cache ------------- +CLIENT_CACHE_MAX_AGE=30 # default "30" +``` + +For ARQ Job Queues: + +``` +# ------------- redis queue ------------- +REDIS_QUEUE_HOST="your_host" # default "localhost", if using docker compose you should use "redis" +REDIS_QUEUE_PORT=6379 # default "6379", if using docker compose you should use "6379" +``` + +> \[!WARNING\] +> You may use the same redis for both caching and queue while developing, but the recommendation is using two separate containers for production. + +To create the first tier: + +``` +# ------------- first tier ------------- +TIER_NAME="free" +``` + +For the rate limiter: + +``` +# ------------- redis rate limit ------------- +REDIS_RATE_LIMIT_HOST="localhost" # default="localhost", if using docker compose you should use "redis" +REDIS_RATE_LIMIT_PORT=6379 # default=6379, if using docker compose you should use "6379" + + +# ------------- default rate limit settings ------------- +DEFAULT_RATE_LIMIT_LIMIT=10 # default=10 +DEFAULT_RATE_LIMIT_PERIOD=3600 # default=3600 +``` + +And Finally the environment: + +``` +# ------------- environment ------------- +ENVIRONMENT="local" +``` + +`ENVIRONMENT` can be one of `local`, `staging` and `production`, defaults to local, and changes the behavior of api `docs` endpoints: + +- **local:** `/docs`, `/redoc` and `/openapi.json` available +- **staging:** `/docs`, `/redoc` and `/openapi.json` available for superusers +- **production:** `/docs`, `/redoc` and `/openapi.json` not available + +### 3.2 Docker Compose (preferred) + +To do it using docker compose, ensure you have docker and docker compose installed, then: +While in the base project directory (FastAPI-boilerplate here), run: + +```sh +docker compose up +``` + +You should have a `web` container, `postgres` container, a `worker` container and a `redis` container running. +Then head to `http://127.0.0.1:8000/docs`. + +### 3.3 From Scratch + +Install uv: + +```sh +pip install uv +``` + +## 4. Usage + +> ๐Ÿ“– **[See complete first run guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/getting-started/first-run/)** + +### 4.1 Docker Compose + +If you used docker compose, your setup is done. You just need to ensure that when you run (while in the base folder): + +```sh +docker compose up +``` + +You get the following outputs (in addition to many other outputs): + +```sh +fastapi-boilerplate-worker-1 | ... redis_version=x.x.x mem_usage=999K clients_connected=1 db_keys=0 +... +fastapi-boilerplate-db-1 | ... [1] LOG: database system is ready to accept connections +... +fastapi-boilerplate-web-1 | INFO: Application startup complete. +``` + +So you may skip to [5. Extending](#5-extending). + +### 4.2 From Scratch + +#### 4.2.1. Packages + +In the `root` directory (`FastAPI-boilerplate` if you didn't change anything), run to install required packages: + +```sh +uv sync +``` + +Ensuring it ran without any problem. + +#### 4.2.2. Running PostgreSQL With Docker + +> \[!NOTE\] +> If you already have a PostgreSQL running, you may skip this step. + +Install docker if you don't have it yet, then run: + +```sh +docker pull postgres +``` + +And pick the port, name, user and password, replacing the fields: + +```sh +docker run -d \ + -p {PORT}:{PORT} \ + --name {NAME} \ + -e POSTGRES_PASSWORD={PASSWORD} \ + -e POSTGRES_USER={USER} \ + postgres +``` + +Such as: + +```sh +docker run -d \ + -p 5432:5432 \ + --name postgres \ + -e POSTGRES_PASSWORD=1234 \ + -e POSTGRES_USER=postgres \ + postgres +``` + +#### 4.2.3. Running redis With Docker + +> \[!NOTE\] +> If you already have a redis running, you may skip this step. + +Install docker if you don't have it yet, then run: + +```sh +docker pull redis:alpine +``` + +And pick the name and port, replacing the fields: + +```sh +docker run -d \ + --name {NAME} \ + -p {PORT}:{PORT} \ +redis:alpine +``` + +Such as + +```sh +docker run -d \ + --name redis \ + -p 6379:6379 \ +redis:alpine +``` + +#### 4.2.4. Running the API + +While in the `root` folder, run to start the application with uvicorn server: + +```sh +uv run uvicorn src.app.main:app --reload +``` + +> \[!TIP\] +> The --reload flag enables auto-reload once you change (and save) something in the project + +### 4.3 Creating the first superuser + +#### 4.3.1 Docker Compose + +> \[!WARNING\] +> Make sure DB and tables are created before running create_superuser (db should be running and the api should run at least once before) + +If you are using docker compose, you should uncomment this part of the docker-compose.yml: + +``` + #-------- uncomment to create first superuser -------- + # create_superuser: + # build: + # context: . + # dockerfile: Dockerfile + # env_file: + # - ./src/.env + # depends_on: + # - db + # command: python -m src.scripts.create_first_superuser + # volumes: + # - ./src:/code/src +``` + +Getting: + +``` + #-------- uncomment to create first superuser -------- + create_superuser: + build: + context: . + dockerfile: Dockerfile + env_file: + - ./src/.env + depends_on: + - db + command: python -m src.scripts.create_first_superuser + volumes: + - ./src:/code/src +``` + +While in the base project folder run to start the services: + +```sh +docker-compose up -d +``` + +It will automatically run the create_superuser script as well, but if you want to rerun eventually: + +```sh +docker-compose run --rm create_superuser +``` + +to stop the create_superuser service: + +```sh +docker-compose stop create_superuser +``` + +#### 4.3.2 From Scratch + +While in the `root` folder, run (after you started the application at least once to create the tables): + +```sh +uv run python -m src.scripts.create_first_superuser +``` + +### 4.3.3 Creating the first tier + +> \[!WARNING\] +> Make sure DB and tables are created before running create_tier (db should be running and the api should run at least once before) + +To create the first tier it's similar, you just replace `create_superuser` for `create_tier` service or `create_first_superuser` to `create_first_tier` for scripts. If using `docker compose`, do not forget to uncomment the `create_tier` service in `docker-compose.yml`. + +### 4.4 Database Migrations + +> \[!WARNING\] +> To create the tables if you did not create the endpoints, ensure that you import the models in src/app/models/__init__.py. This step is crucial to create the new tables. + +If you are using the db in docker, you need to change this in `docker-compose.yml` to run migrations: + +```sh + db: + image: postgres:13 + env_file: + - ./src/.env + volumes: + - postgres-data:/var/lib/postgresql/data + # -------- replace with comment to run migrations with docker -------- + expose: + - "5432" + # ports: + # - 5432:5432 +``` + +Getting: + +```sh + db: + ... + # expose: + # - "5432" + ports: + - 5432:5432 +``` + +While in the `src` folder, run Alembic migrations: + +```sh +uv run alembic revision --autogenerate +``` + +And to apply the migration + +```sh +uv run alembic upgrade head +``` + +> [!NOTE] +> If you do not have uv, you may run it without uv after running `pip install alembic` + +## 5. Extending + +> ๐Ÿ“– **[See comprehensive development guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/development/)** + +### 5.1 Project Structure + +> ๐Ÿ“– **[See detailed project structure guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/project-structure/)** + +First, you may want to take a look at the project structure and understand what each file is doing. + +```sh +. +โ”œโ”€โ”€ Dockerfile # Dockerfile for building the application container. +โ”œโ”€โ”€ docker-compose.yml # Docker Compose file for defining multi-container applications. +โ”œโ”€โ”€ pyproject.toml # Project configuration file with metadata and dependencies (PEP 621). +โ”œโ”€โ”€ uv.lock # uv lock file specifying exact versions of dependencies. +โ”œโ”€โ”€ README.md # Project README providing information and instructions. +โ”œโ”€โ”€ LICENSE.md # License file for the project. +โ”‚ +โ”œโ”€โ”€ tests # Unit tests for the application. +โ”‚ โ”œโ”€โ”€helpers # Helper functions for tests. +โ”‚ โ”‚ โ”œโ”€โ”€ generators.py # Helper functions for generating test data. +โ”‚ โ”‚ โ””โ”€โ”€ mocks.py # Mock functions for testing. +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ conftest.py # Configuration and fixtures for pytest. +โ”‚ โ””โ”€โ”€ test_user_unit.py # Unit test cases for user-related functionality. +โ”‚ +โ””โ”€โ”€ src # Source code directory. + โ”œโ”€โ”€ __init__.py # Initialization file for the src package. + โ”œโ”€โ”€ alembic.ini # Configuration file for Alembic (database migration tool). + โ”‚ + โ”œโ”€โ”€ app # Main application directory. + โ”‚ โ”œโ”€โ”€ __init__.py # Initialization file for the app package. + โ”‚ โ”œโ”€โ”€ main.py # Main entry point of the FastAPI application. + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”œโ”€โ”€ api # Folder containing API-related logic. + โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py + โ”‚ โ”‚ โ”œโ”€โ”€ dependencies.py # Defines dependencies for use across API endpoints. + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ””โ”€โ”€ v1 # Version 1 of the API. + โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py + โ”‚ โ”‚ โ”œโ”€โ”€ login.py # API route for user login. + โ”‚ โ”‚ โ”œโ”€โ”€ logout.py # API route for user logout. + โ”‚ โ”‚ โ”œโ”€โ”€ posts.py # API routes for post operations. + โ”‚ โ”‚ โ”œโ”€โ”€ rate_limits.py # API routes for rate limiting functionalities. + โ”‚ โ”‚ โ”œโ”€โ”€ tasks.py # API routes for task management. + โ”‚ โ”‚ โ”œโ”€โ”€ tiers.py # API routes for user tier functionalities. + โ”‚ โ”‚ โ””โ”€โ”€ users.py # API routes for user management. + โ”‚ โ”‚ + โ”‚ โ”œโ”€โ”€ core # Core utilities and configurations for the application. + โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py + โ”‚ โ”‚ โ”œโ”€โ”€ config.py # Configuration settings for the application. + โ”‚ โ”‚ โ”œโ”€โ”€ logger.py # Configuration for application logging. + โ”‚ โ”‚ โ”œโ”€โ”€ schemas.py # Pydantic schemas for data validation. + โ”‚ โ”‚ โ”œโ”€โ”€ security.py # Security utilities, such as password hashing. + โ”‚ โ”‚ โ”œโ”€โ”€ setup.py # Setup file for the FastAPI app instance. + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”œโ”€โ”€ db # Core Database related modules. + โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py + โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ crud_token_blacklist.py # CRUD operations for token blacklist. + โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ database.py # Database connectivity and session management. + โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ models.py # Core Database models. + โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ token_blacklist.py # Model for token blacklist functionality. + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”œโ”€โ”€ exceptions # Custom exception classes. + โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py + โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ cache_exceptions.py # Exceptions related to cache operations. + โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ http_exceptions.py # HTTP-related exceptions. + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ”œโ”€โ”€ utils # Utility functions and helpers. + โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py + โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ cache.py # Cache-related utilities. + โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ queue.py # Utilities for task queue management. + โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ rate_limit.py # Rate limiting utilities. + โ”‚ โ”‚ โ”‚ + โ”‚ โ”‚ โ””โ”€โ”€ worker # Worker script for background tasks. + โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py + โ”‚ โ”‚ โ”œโ”€โ”€ settings.py # Worker configuration and settings. + โ”‚ โ”‚ โ””โ”€โ”€ functions.py # Async task definitions and management. + โ”‚ โ”‚ + โ”‚ โ”œโ”€โ”€ crud # CRUD operations for the application. + โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py + โ”‚ โ”‚ โ”œโ”€โ”€ crud_base.py # Base class for CRUD operations. + โ”‚ โ”‚ โ”œโ”€โ”€ crud_posts.py # CRUD operations for posts. + โ”‚ โ”‚ โ”œโ”€โ”€ crud_rate_limit.py # CRUD operations for rate limiting. + โ”‚ โ”‚ โ”œโ”€โ”€ crud_tier.py # CRUD operations for user tiers. + โ”‚ โ”‚ โ”œโ”€โ”€ crud_users.py # CRUD operations for users. + โ”‚ โ”‚ โ””โ”€โ”€ helper.py # Helper functions for CRUD operations. + โ”‚ โ”‚ + โ”‚ โ”œโ”€โ”€ logs # Directory for log files. + โ”‚ โ”‚ โ””โ”€โ”€ app.log # Log file for the application. + โ”‚ โ”‚ + โ”‚ โ”œโ”€โ”€ middleware # Middleware components for the application. + โ”‚ โ”‚ โ””โ”€โ”€ client_cache_middleware.py # Middleware for client-side caching. + โ”‚ โ”‚ + โ”‚ โ”œโ”€โ”€ models # ORM models for the application. + โ”‚ โ”‚ โ”œโ”€โ”€ __init__.py + โ”‚ โ”‚ โ”œโ”€โ”€ post.py # ORM model for posts. + โ”‚ โ”‚ โ”œโ”€โ”€ rate_limit.py # ORM model for rate limiting. + โ”‚ โ”‚ โ”œโ”€โ”€ tier.py # ORM model for user tiers. + โ”‚ โ”‚ โ””โ”€โ”€ user.py # ORM model for users. + โ”‚ โ”‚ + โ”‚ โ””โ”€โ”€ schemas # Pydantic schemas for data validation. + โ”‚ โ”œโ”€โ”€ __init__.py + โ”‚ โ”œโ”€โ”€ job.py # Schema for background jobs. + โ”‚ โ”œโ”€โ”€ post.py # Schema for post data. + โ”‚ โ”œโ”€โ”€ rate_limit.py # Schema for rate limiting data. + โ”‚ โ”œโ”€โ”€ tier.py # Schema for user tier data. + โ”‚ โ””โ”€โ”€ user.py # Schema for user data. + โ”‚ + โ”œโ”€โ”€ migrations # Alembic migration scripts for database changes. + โ”‚ โ”œโ”€โ”€ README + โ”‚ โ”œโ”€โ”€ env.py # Environment configuration for Alembic. + โ”‚ โ”œโ”€โ”€ script.py.mako # Template script for Alembic migrations. + โ”‚ โ”‚ + โ”‚ โ””โ”€โ”€ versions # Individual migration scripts. + โ”‚ โ””โ”€โ”€ README.MD + โ”‚ + โ””โ”€โ”€ scripts # Utility scripts for the application. + โ”œโ”€โ”€ __init__.py + โ”œโ”€โ”€ create_first_superuser.py # Script to create the first superuser. + โ””โ”€โ”€ create_first_tier.py # Script to create the first user tier. +``` + +### 5.2 Database Model + +Create the new entities and relationships and add them to the model
+![diagram](https://user-images.githubusercontent.com/43156212/284426387-bdafc637-0473-4b71-890d-29e79da288cf.png) + +#### 5.2.1 Token Blacklist + +Note that this table is used to blacklist the `JWT` tokens (it's how you log a user out)
+![diagram](https://user-images.githubusercontent.com/43156212/284426382-b2f3c0ca-b8ea-4f20-b47e-de1bad2ca283.png) + +### 5.3 SQLAlchemy Models + +> ๐Ÿ“– **[See database models guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/models/)** + +Inside `app/models`, create a new `entity.py` for each new entity (replacing entity with the name) and define the attributes according to [SQLAlchemy 2.0 standards](https://docs.sqlalchemy.org/en/20/orm/mapping_styles.html#orm-mapping-styles): + +> \[!WARNING\] +> Note that since it inherits from `Base`, the new model is mapped as a python `dataclass`, so optional attributes (arguments with a default value) should be defined after required attributes. + +```python +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.db.database import Base + + +class Entity(Base): + __tablename__ = "entity" + + id: Mapped[int] = mapped_column("id", autoincrement=True, nullable=False, unique=True, primary_key=True, init=False) + name: Mapped[str] = mapped_column(String(30)) + ... +``` + +### 5.4 Pydantic Schemas + +> ๐Ÿ“– **[See database schemas guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/schemas/)** + +Inside `app/schemas`, create a new `entity.py` for each new entity (replacing entity with the name) and create the schemas according to [Pydantic V2](https://docs.pydantic.dev/latest/#pydantic-examples) standards: + +```python +from typing import Annotated + +from pydantic import BaseModel, EmailStr, Field, HttpUrl, ConfigDict + + +class EntityBase(BaseModel): + name: Annotated[ + str, + Field(min_length=2, max_length=30, examples=["Entity Name"]), + ] + + +class Entity(EntityBase): + ... + + +class EntityRead(EntityBase): + ... + + +class EntityCreate(EntityBase): + ... + + +class EntityCreateInternal(EntityCreate): + ... + + +class EntityUpdate(BaseModel): + ... + + +class EntityUpdateInternal(BaseModel): + ... + + +class EntityDelete(BaseModel): + model_config = ConfigDict(extra="forbid") + + is_deleted: bool + deleted_at: datetime +``` + +### 5.5 Alembic Migrations + +> ๐Ÿ“– **[See database migrations guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/migrations/)** + +> \[!WARNING\] +> To create the tables if you did not create the endpoints, ensure that you import the models in src/app/models/__init__.py. This step is crucial to create the new models. + +Then, while in the `src` folder, run Alembic migrations: + +```sh +uv run alembic revision --autogenerate +``` + +And to apply the migration + +```sh +uv run alembic upgrade head +``` + +### 5.6 CRUD + +> ๐Ÿ“– **[See CRUD operations guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/database/crud/)** + +Inside `app/crud`, create a new `crud_entity.py` inheriting from `FastCRUD` for each new entity: + +```python +from fastcrud import FastCRUD + +from app.models.entity import Entity +from app.schemas.entity import EntityCreateInternal, EntityUpdate, EntityUpdateInternal, EntityDelete + +CRUDEntity = FastCRUD[Entity, EntityCreateInternal, EntityUpdate, EntityUpdateInternal, EntityDelete] +crud_entity = CRUDEntity(Entity) +``` + +So, for users: + +```python +# crud_users.py +from app.model.user import User +from app.schemas.user import UserCreateInternal, UserUpdate, UserUpdateInternal, UserDelete + +CRUDUser = FastCRUD[User, UserCreateInternal, UserUpdate, UserUpdateInternal, UserDelete] +crud_users = CRUDUser(User) +``` + +#### 5.6.1 Get + +When actually using the crud in an endpoint, to get data you just pass the database connection and the attributes as kwargs: + +```python +# Here I'm getting the first user with email == user.email (email is unique in this case) +user = await crud_users.get(db=db, email=user.email) +``` + +#### 5.6.2 Get Multi + +To get a list of objects with the attributes, you should use the get_multi: + +```python +# Here I'm getting at most 10 users with the name 'User Userson' except for the first 3 +user = await crud_users.get_multi(db=db, offset=3, limit=100, name="User Userson") +``` + +> \[!WARNING\] +> Note that get_multi returns a python `dict`. + +Which will return a python dict with the following structure: + +```javascript +{ + "data": [ + { + "id": 4, + "name": "User Userson", + "username": "userson4", + "email": "user.userson4@example.com", + "profile_image_url": "https://profileimageurl.com" + }, + { + "id": 5, + "name": "User Userson", + "username": "userson5", + "email": "user.userson5@example.com", + "profile_image_url": "https://profileimageurl.com" + } + ], + "total_count": 2, + "has_more": false, + "page": 1, + "items_per_page": 10 +} +``` + +#### 5.6.3 Create + +To create, you pass a `CreateSchemaType` object with the attributes, such as a `UserCreate` pydantic schema: + +```python +from app.schemas.user import UserCreate + +# Creating the object +user_internal = UserCreate(name="user", username="myusername", email="user@example.com") + +# Passing the object to be created +crud_users.create(db=db, object=user_internal) +``` + +#### 5.6.4 Exists + +To just check if there is at least one row that matches a certain set of attributes, you should use `exists` + +```python +# This queries only the email variable +# It returns True if there's at least one or False if there is none +crud_users.exists(db=db, email=user @ example.com) +``` + +#### 5.6.5 Count + +You can also get the count of a certain object with the specified filter: + +```python +# Here I'm getting the count of users with the name 'User Userson' +user = await crud_users.count(db=db, name="User Userson") +``` + +#### 5.6.6 Update + +To update you pass an `object` which may be a `pydantic schema` or just a regular `dict`, and the kwargs. +You will update with `objects` the rows that match your `kwargs`. + +```python +# Here I'm updating the user with username == "myusername". +# #I'll change his name to "Updated Name" +crud_users.update(db=db, object={"name": "Updated Name"}, username="myusername") +``` + +#### 5.6.7 Delete + +To delete we have two options: + +- db_delete: actually deletes the row from the database +- delete: + - adds `"is_deleted": True` and `deleted_at: datetime.now(UTC)` if the model inherits from `PersistentDeletion` (performs a soft delete), but keeps the object in the database. + - actually deletes the row from the database if the model does not inherit from `PersistentDeletion` + +```python +# Here I'll just change is_deleted to True +crud_users.delete(db=db, username="myusername") + +# Here I actually delete it from the database +crud_users.db_delete(db=db, username="myusername") +``` + +#### 5.6.8 Get Joined + +To retrieve data with a join operation, you can use the get_joined method from your CRUD module. Here's how to do it: + +```python +# Fetch a single record with a join on another model (e.g., User and Tier). +result = await crud_users.get_joined( + db=db, # The SQLAlchemy async session. + join_model=Tier, # The model to join with (e.g., Tier). + schema_to_select=UserSchema, # Pydantic schema for selecting User model columns (optional). + join_schema_to_select=TierSchema, # Pydantic schema for selecting Tier model columns (optional). +) +``` + +**Relevant Parameters:** + +- `join_model`: The model you want to join with (e.g., Tier). +- `join_prefix`: Optional prefix to be added to all columns of the joined model. If None, no prefix is added. +- `join_on`: SQLAlchemy Join object for specifying the ON clause of the join. If None, the join condition is auto-detected based on foreign keys. +- `schema_to_select`: A Pydantic schema to select specific columns from the primary model (e.g., UserSchema). +- `join_schema_to_select`: A Pydantic schema to select specific columns from the joined model (e.g., TierSchema). +- `join_type`: pecifies the type of join operation to perform. Can be "left" for a left outer join or "inner" for an inner join. Default "left". +- `kwargs`: Filters to apply to the primary query. + +This method allows you to perform a join operation, selecting columns from both models, and retrieve a single record. + +#### 5.6.9 Get Multi Joined + +Similarly, to retrieve multiple records with a join operation, you can use the get_multi_joined method. Here's how: + +```python +# Retrieve a list of objects with a join on another model (e.g., User and Tier). +result = await crud_users.get_multi_joined( + db=db, # The SQLAlchemy async session. + join_model=Tier, # The model to join with (e.g., Tier). + join_prefix="tier_", # Optional prefix for joined model columns. + join_on=and_(User.tier_id == Tier.id, User.is_superuser == True), # Custom join condition. + schema_to_select=UserSchema, # Pydantic schema for selecting User model columns. + join_schema_to_select=TierSchema, # Pydantic schema for selecting Tier model columns. + username="john_doe", # Additional filter parameters. +) +``` + +**Relevant Parameters:** + +- `join_model`: The model you want to join with (e.g., Tier). +- `join_prefix`: Optional prefix to be added to all columns of the joined model. If None, no prefix is added. +- `join_on`: SQLAlchemy Join object for specifying the ON clause of the join. If None, the join condition is auto-detected based on foreign keys. +- `schema_to_select`: A Pydantic schema to select specific columns from the primary model (e.g., UserSchema). +- `join_schema_to_select`: A Pydantic schema to select specific columns from the joined model (e.g., TierSchema). +- `join_type`: pecifies the type of join operation to perform. Can be "left" for a left outer join or "inner" for an inner join. Default "left". +- `kwargs`: Filters to apply to the primary query. +- `offset`: The offset (number of records to skip) for pagination. Default 0. +- `limit`: The limit (maximum number of records to return) for pagination. Default 100. +- `kwargs`: Filters to apply to the primary query. + +#### More Efficient Selecting + +For the `get` and `get_multi` methods we have the option to define a `schema_to_select` attribute, which is what actually makes the queries more efficient. When you pass a `pydantic schema` (preferred) or a list of the names of the attributes in `schema_to_select` to the `get` or `get_multi` methods, only the attributes in the schema will be selected. + +```python +from app.schemas.user import UserRead + +# Here it's selecting all of the user's data +crud_user.get(db=db, username="myusername") + +# Now it's only selecting the data that is in UserRead. +# Since that's my response_model, it's all I need +crud_user.get(db=db, username="myusername", schema_to_select=UserRead) +``` + +### 5.7 Routes + +> ๐Ÿ“– **[See API endpoints guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/api/endpoints/)** + +Inside `app/api/v1`, create a new `entities.py` file and create the desired routes with proper dependency injection: + +```python +from typing import Annotated, List +from fastapi import Depends, Request, APIRouter +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.entity import EntityRead +from app.core.db.database import async_get_db +from app.crud.crud_entity import crud_entity + +router = APIRouter(tags=["entities"]) + + +@router.get("/entities/{id}", response_model=EntityRead) +async def read_entity( + request: Request, + id: int, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + entity = await crud_entity.get(db=db, id=id) + + if entity is None: # Explicit None check + raise NotFoundException("Entity not found") + + return entity + + +@router.get("/entities", response_model=List[EntityRead]) +async def read_entities( + request: Request, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + entities = await crud_entity.get_multi(db=db, is_deleted=False) + return entities +``` + +Then in `app/api/v1/__init__.py` add the router: + +```python +from fastapi import APIRouter +from app.api.v1.entities import router as entity_router +from app.api.v1.users import router as user_router +from app.api.v1.posts import router as post_router + +router = APIRouter(prefix="/v1") + +router.include_router(user_router) +router.include_router(post_router) +router.include_router(entity_router) # Add your new router +``` + +#### 5.7.1 Paginated Responses + +> ๐Ÿ“– **[See API pagination guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/api/pagination/)** + +With the `get_multi` method we get a python `dict` with full suport for pagination: + +```javascript +{ + "data": [ + { + "id": 4, + "name": "User Userson", + "username": "userson4", + "email": "user.userson4@example.com", + "profile_image_url": "https://profileimageurl.com" + }, + { + "id": 5, + "name": "User Userson", + "username": "userson5", + "email": "user.userson5@example.com", + "profile_image_url": "https://profileimageurl.com" + } + ], + "total_count": 2, + "has_more": false, + "page": 1, + "items_per_page": 10 +} +``` + +And in the endpoint, we can import from `fastcrud.paginated` the following functions and Pydantic Schema: + +```python +from typing import Annotated +from fastapi import Depends, Request +from sqlalchemy.ext.asyncio import AsyncSession +from fastcrud.paginated import ( + PaginatedListResponse, # What you'll use as a response_model to validate + paginated_response, # Creates a paginated response based on the parameters + compute_offset, # Calculate the offset for pagination ((page - 1) * items_per_page) +) +``` + +Then let's create the endpoint: + +```python +import fastapi + +from app.schemas.entity import EntityRead + +... + + +@router.get("/entities", response_model=PaginatedListResponse[EntityRead]) +async def read_entities( + request: Request, + db: Annotated[AsyncSession, Depends(async_get_db)], + page: int = 1, + items_per_page: int = 10 +): + entities_data = await crud_entity.get_multi( + db=db, + offset=compute_offset(page, items_per_page), + limit=items_per_page, + schema_to_select=EntityRead, + is_deleted=False, + ) + + return paginated_response(crud_data=entities_data, page=page, items_per_page=items_per_page) +``` + +#### 5.7.2 HTTP Exceptions + +> ๐Ÿ“– **[See API exceptions guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/api/exceptions/)** + +To add exceptions you may just import from `app/core/exceptions/http_exceptions` and optionally add a detail: + +```python +from app.core.exceptions.http_exceptions import ( + NotFoundException, + ForbiddenException, + DuplicateValueException +) + +@router.post("/entities", response_model=EntityRead, status_code=201) +async def create_entity( + request: Request, + entity_data: EntityCreate, + db: Annotated[AsyncSession, Depends(async_get_db)], + current_user: Annotated[UserRead, Depends(get_current_user)] +): + # Check if entity already exists + if await crud_entity.exists(db=db, name=entity_data.name) is True: + raise DuplicateValueException("Entity with this name already exists") + + # Check user permissions + if current_user.is_active is False: # Explicit boolean check + raise ForbiddenException("User account is disabled") + + # Create the entity + entity = await crud_entity.create(db=db, object=entity_data) + + if entity is None: # Explicit None check + raise CustomException("Failed to create entity") + + return entity + + +@router.get("/entities/{id}", response_model=EntityRead) +async def read_entity( + request: Request, + id: int, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + entity = await crud_entity.get(db=db, id=id) + + if entity is None: # Explicit None check + raise NotFoundException("Entity not found") + + return entity +``` + +**The predefined possibilities in http_exceptions are the following:** + +- `CustomException`: 500 internal error +- `BadRequestException`: 400 bad request +- `NotFoundException`: 404 not found +- `ForbiddenException`: 403 forbidden +- `UnauthorizedException`: 401 unauthorized +- `UnprocessableEntityException`: 422 unprocessable entity +- `DuplicateValueException`: 422 unprocessable entity +- `RateLimitException`: 429 too many requests + +### 5.8 Caching + +> ๐Ÿ“– **[See comprehensive caching guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/caching/)** + +The `cache` decorator allows you to cache the results of FastAPI endpoint functions, enhancing response times and reducing the load on your application by storing and retrieving data in a cache. + +Caching the response of an endpoint is really simple, just apply the `cache` decorator to the endpoint function. + +> \[!WARNING\] +> Note that you should always pass request as a variable to your endpoint function if you plan to use the cache decorator. + +```python +... +from app.core.utils.cache import cache + + +@app.get("/sample/{my_id}") +@cache(key_prefix="sample_data", expiration=3600, resource_id_name="my_id") +async def sample_endpoint(request: Request, my_id: int): + # Endpoint logic here + return {"data": "my_data"} +``` + +The way it works is: + +- the data is saved in redis with the following cache key: `sample_data:{my_id}` +- then the time to expire is set as 3600 seconds (that's the default) + +Another option is not passing the `resource_id_name`, but passing the `resource_id_type` (default int): + +```python +... +from app.core.utils.cache import cache + + +@app.get("/sample/{my_id}") +@cache(key_prefix="sample_data", resource_id_type=int) +async def sample_endpoint(request: Request, my_id: int): + # Endpoint logic here + return {"data": "my_data"} +``` + +In this case, what will happen is: + +- the `resource_id` will be inferred from the keyword arguments (`my_id` in this case) +- the data is saved in redis with the following cache key: `sample_data:{my_id}` +- then the the time to expire is set as 3600 seconds (that's the default) + +Passing resource_id_name is usually preferred. + +### 5.9 More Advanced Caching + +The behaviour of the `cache` decorator changes based on the request method of your endpoint. +It caches the result if you are passing it to a **GET** endpoint, and it invalidates the cache with this key_prefix and id if passed to other endpoints (**PATCH**, **DELETE**). + +#### Invalidating Extra Keys + +If you also want to invalidate cache with a different key, you can use the decorator with the `to_invalidate_extra` variable. + +In the following example, I want to invalidate the cache for a certain `user_id`, since I'm deleting it, but I also want to invalidate the cache for the list of users, so it will not be out of sync. + +```python +# The cache here will be saved as "{username}_posts:{username}": +@router.get("/{username}/posts", response_model=List[PostRead]) +@cache(key_prefix="{username}_posts", resource_id_name="username") +async def read_posts(request: Request, username: str, db: Annotated[AsyncSession, Depends(async_get_db)]): + ... + + +... + +# Invalidating cache for the former endpoint by just passing the key_prefix and id as a dictionary: +@router.delete("/{username}/post/{id}") +@cache( + "{username}_post_cache", + resource_id_name="id", + to_invalidate_extra={"{username}_posts": "{username}"}, # also invalidate "{username}_posts:{username}" cache +) +async def erase_post( + request: Request, + username: str, + id: int, + current_user: Annotated[UserRead, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(async_get_db)], +): + ... + + +# And now I'll also invalidate when I update the user: +@router.patch("/{username}/post/{id}", response_model=PostRead) +@cache("{username}_post_cache", resource_id_name="id", to_invalidate_extra={"{username}_posts": "{username}"}) +async def patch_post( + request: Request, + username: str, + id: int, + values: PostUpdate, + current_user: Annotated[UserRead, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(async_get_db)], +): + ... +``` + +> \[!WARNING\] +> Note that adding `to_invalidate_extra` will not work for **GET** requests. + +#### Invalidate Extra By Pattern + +Let's assume we have an endpoint with a paginated response, such as: + +```python +@router.get("/{username}/posts", response_model=PaginatedListResponse[PostRead]) +@cache( + key_prefix="{username}_posts:page_{page}:items_per_page:{items_per_page}", + resource_id_name="username", + expiration=60, +) +async def read_posts( + request: Request, + username: str, + db: Annotated[AsyncSession, Depends(async_get_db)], + page: int = 1, + items_per_page: int = 10, +): + db_user = await crud_users.get(db=db, schema_to_select=UserRead, username=username, is_deleted=False) + if not db_user: + raise HTTPException(status_code=404, detail="User not found") + + posts_data = await crud_posts.get_multi( + db=db, + offset=compute_offset(page, items_per_page), + limit=items_per_page, + schema_to_select=PostRead, + created_by_user_id=db_user["id"], + is_deleted=False, + ) + + return paginated_response(crud_data=posts_data, page=page, items_per_page=items_per_page) +``` + +Just passing `to_invalidate_extra` will not work to invalidate this cache, since the key will change based on the `page` and `items_per_page` values. +To overcome this we may use the `pattern_to_invalidate_extra` parameter: + +```python +@router.patch("/{username}/post/{id}") +@cache("{username}_post_cache", resource_id_name="id", pattern_to_invalidate_extra=["{username}_posts:*"]) +async def patch_post( + request: Request, + username: str, + id: int, + values: PostUpdate, + current_user: Annotated[UserRead, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(async_get_db)], +): + ... +``` + +Now it will invalidate all caches with a key that matches the pattern `"{username}_posts:*`, which will work for the paginated responses. + +> \[!CAUTION\] +> Using `pattern_to_invalidate_extra` can be resource-intensive on large datasets. Use it judiciously and consider the potential impact on Redis performance. Be cautious with patterns that could match a large number of keys, as deleting many keys simultaneously may impact the performance of the Redis server. + +#### Client-side Caching + +For `client-side caching`, all you have to do is let the `Settings` class defined in `app/core/config.py` inherit from the `ClientSideCacheSettings` class. You can set the `CLIENT_CACHE_MAX_AGE` value in `.env,` it defaults to 60 (seconds). + +### 5.10 ARQ Job Queues + +> ๐Ÿ“– **[See background tasks guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/background-tasks/)** + +Depending on the problem your API is solving, you might want to implement a job queue. A job queue allows you to run tasks in the background, and is usually aimed at functions that require longer run times and don't directly impact user response in your frontend. As a rule of thumb, if a task takes more than 2 seconds to run, can be executed asynchronously, and its result is not needed for the next step of the user's interaction, then it is a good candidate for the job queue. + +> [!TIP] +> Very common candidates for background functions are calls to and from LLM endpoints (e.g. OpenAI or Openrouter). This is because they span tens of seconds and often need to be further parsed and saved. + +#### Background task creation + +For simple background tasks, you can just create a function in the `app/core/worker/functions.py` file. For more complex tasks, we recommend you to create a new file in the `app/core/worker` directory. + +```python +async def sample_background_task(ctx, name: str) -> str: + await asyncio.sleep(5) + return f"Task {name} is complete!" +``` + +Then add the function to the `WorkerSettings` class `functions` variable in `app/core/worker/settings.py` to make it available to the worker. If you created a new file in the `app/core/worker` directory, then simply import this function in the `app/core/worker/settings.py` file: + +```python +from .functions import sample_background_task +from .your_module import sample_complex_background_task + +class WorkerSettings: + functions = [sample_background_task, sample_complex_background_task] + ... +``` + +#### Add the task to an endpoint + +Once you have created the background task, you can add it to any endpoint of your choice to be enqueued. The best practice is to enqueue the task in a **POST** endpoint, while having a **GET** endpoint to get more information on the task. For more details on how job results are handled, check the [ARQ docs](https://arq-docs.helpmanual.io/#job-results). + +```python +@router.post("/task", response_model=Job, status_code=201) +async def create_task(message: str): + job = await queue.pool.enqueue_job("sample_background_task", message) + return {"id": job.job_id} + + +@router.get("/task/{task_id}") +async def get_task(task_id: str): + job = ArqJob(task_id, queue.pool) + return await job.info() +``` + +And finally run the worker in parallel to your fastapi application. + +> [!IMPORTANT] +> For any change to the `sample_background_task` to be reflected in the worker, you need to restart the worker (e.g. the docker container). + +If you are using `docker compose`, the worker is already running. +If you are doing it from scratch, run while in the `root` folder: + +```sh +uv run arq src.app.core.worker.settings.WorkerSettings +``` + +#### Database session with background tasks + +With time your background functions will become 'workflows' increasing in complexity and requirements. Probably, you will need to use a database session to get, create, update, or delete data as part of this workflow. + +To do this, you can add the database session to the `ctx` object in the `startup` and `shutdown` functions in `app/core/worker/functions.py`, like in the example below: + +```python +from arq.worker import Worker +from ...core.db.database import async_get_db + +async def startup(ctx: Worker) -> None: + ctx["db"] = await anext(async_get_db()) + logging.info("Worker Started") + + +async def shutdown(ctx: Worker) -> None: + await ctx["db"].close() + logging.info("Worker end") +``` + +This will allow you to have the async database session always available in any background function and automatically close it on worker shutdown. Once you have this database session, you can use it as follows: + +```python +from arq.worker import Worker + +async def your_background_function( + ctx: Worker, + post_id: int, + ... +) -> Any: + db = ctx["db"] + post = crud_posts.get(db=db, schema_to_select=PostRead, id=post_id) + ... +``` + +> [!WARNING] +> When using database sessions, you will want to use Pydantic objects. However, these objects don't mingle well with the seralization required by ARQ tasks and will be retrieved as a dictionary. + +### 5.11 Rate Limiting + +> ๐Ÿ“– **[See rate limiting guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/rate-limiting/)** + +To limit how many times a user can make a request in a certain interval of time (very useful to create subscription plans or just to protect your API against DDOS), you may just use the `rate_limiter_dependency` dependency: + +```python +from fastapi import Depends + +from app.api.dependencies import rate_limiter_dependency +from app.core.utils import queue +from app.schemas.job import Job + + +@router.post("/task", response_model=Job, status_code=201, dependencies=[Depends(rate_limiter_dependency)]) +async def create_task(message: str): + job = await queue.pool.enqueue_job("sample_background_task", message) + return {"id": job.job_id} +``` + +By default, if no token is passed in the header (that is - the user is not authenticated), the user will be limited by his IP address with the default `limit` (how many times the user can make this request every period) and `period` (time in seconds) defined in `.env`. + +Even though this is useful, real power comes from creating `tiers` (categories of users) and standard `rate_limits` (`limits` and `periods` defined for specific `paths` - that is - endpoints) for these tiers. + +All of the `tier` and `rate_limit` models, schemas, and endpoints are already created in the respective folders (and usable only by superusers). You may use the `create_tier` script to create the first tier (it uses the `.env` variable `TIER_NAME`, which is all you need to create a tier) or just use the api: + +Here I'll create a `free` tier: + +

+ passing name = free to api request body +

+ +And a `pro` tier: + +

+ passing name = pro to api request body +

+ +Then I'll associate a `rate_limit` for the path `api/v1/tasks/task` for each of them, I'll associate a `rate limit` for the path `api/v1/tasks/task`. + +> \[!WARNING\] +> Do not forget to add `api/v1/...` or any other prefix to the beggining of your path. For the structure of the boilerplate, `api/v1/` + +1 request every hour (3600 seconds) for the free tier: + +

+ passing path=api/v1/tasks/task, limit=1, period=3600, name=api_v1_tasks:1:3600 to free tier rate limit +

+ +10 requests every hour for the pro tier: + +

+ passing path=api/v1/tasks/task, limit=10, period=3600, name=api_v1_tasks:10:3600 to pro tier rate limit +

+ +Now let's read all the tiers available (`GET api/v1/tiers`): + +```javascript +{ + "data": [ + { + "name": "free", + "id": 1, + "created_at": "2023-11-11T05:57:25.420360" + }, + { + "name": "pro", + "id": 2, + "created_at": "2023-11-12T00:40:00.759847" + } + ], + "total_count": 2, + "has_more": false, + "page": 1, + "items_per_page": 10 +} +``` + +And read the `rate_limits` for the `pro` tier to ensure it's working (`GET api/v1/tier/pro/rate_limits`): + +```javascript +{ + "data": [ + { + "path": "api_v1_tasks_task", + "limit": 10, + "period": 3600, + "id": 1, + "tier_id": 2, + "name": "api_v1_tasks:10:3600" + } + ], + "total_count": 1, + "has_more": false, + "page": 1, + "items_per_page": 10 +} +``` + +Now, whenever an authenticated user makes a `POST` request to the `api/v1/tasks/task`, they'll use the quota that is defined by their tier. +You may check this getting the token from the `api/v1/login` endpoint, then passing it in the request header: + +```sh +curl -X POST 'http://127.0.0.1:8000/api/v1/tasks/task?message=test' \ +-H 'Authorization: Bearer ' +``` + +> \[!TIP\] +> Since the `rate_limiter_dependency` dependency uses the `get_optional_user` dependency instead of `get_current_user`, it will not require authentication to be used, but will behave accordingly if the user is authenticated (and token is passed in header). If you want to ensure authentication, also use `get_current_user` if you need. + +To change a user's tier, you may just use the `PATCH api/v1/user/{username}/tier` endpoint. +Note that for flexibility (since this is a boilerplate), it's not necessary to previously inform a tier_id to create a user, but you probably should set every user to a certain tier (let's say `free`) once they are created. + +> \[!WARNING\] +> If a user does not have a `tier` or the tier does not have a defined `rate limit` for the path and the token is still passed to the request, the default `limit` and `period` will be used, this will be saved in `app/logs`. + +### 5.12 JWT Authentication + +> ๐Ÿ“– **[See authentication guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/authentication/)** + +#### 5.12.1 Details + +The JWT in this boilerplate is created in the following way: + +1. **JWT Access Tokens:** how you actually access protected resources is passing this token in the request header. +1. **Refresh Tokens:** you use this type of token to get an `access token`, which you'll use to access protected resources. + +The `access token` is short lived (default 30 minutes) to reduce the damage of a potential leak. The `refresh token`, on the other hand, is long lived (default 7 days), and you use it to renew your `access token` without the need to provide username and password every time it expires. + +Since the `refresh token` lasts for a longer time, it's stored as a cookie in a secure way: + +```python +# app/api/v1/login + +... +response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, # Prevent access through JavaScript + secure=True, # Ensure cookie is sent over HTTPS only + samesite="Lax", # Default to Lax for reasonable balance between security and usability + max_age=number_of_seconds, # Set a max age for the cookie +) +... +``` + +You may change it to suit your needs. The possible options for `samesite` are: + +- `Lax`: Cookies will be sent in top-level navigations (like clicking on a link to go to another site), but not in API requests or images loaded from other sites. +- `Strict`: Cookies are sent only on top-level navigations from the same site that set the cookie, enhancing privacy but potentially disrupting user sessions. +- `None`: Cookies will be sent with both same-site and cross-site requests. + +#### 5.12.2 Usage + +What you should do with the client is: + +- `Login`: Send credentials to `/api/v1/login`. Store the returned access token in memory for subsequent requests. +- `Accessing Protected Routes`: Include the access token in the Authorization header. +- `Token Renewal`: On access token expiry, the front end should automatically call `/api/v1/refresh` for a new token. +- `Login Again`: If refresh token is expired, credentials should be sent to `/api/v1/login` again, storing the new access token in memory. +- `Logout`: Call /api/v1/logout to end the session securely. + +This authentication setup in the provides a robust, secure, and user-friendly way to handle user sessions in your API applications. + +### 5.13 Admin Panel + +> ๐Ÿ“– **[See admin panel guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/admin-panel/)** + +The boilerplate includes a powerful web-based admin interface built with [CRUDAdmin](https://github.com/benavlabs/crudadmin) that provides a comprehensive database management system. + +> **About CRUDAdmin**: CRUDAdmin is a modern admin interface generator for FastAPI applications. Learn more at: +> - **๐Ÿ“š Documentation**: [benavlabs.github.io/crudadmin](https://benavlabs.github.io/crudadmin/) +> - **๐Ÿ’ป GitHub**: [github.com/benavlabs/crudadmin](https://github.com/benavlabs/crudadmin) + +#### 5.13.1 Features + +The admin panel includes: + +- **User Management**: Create, view, update users with password hashing +- **Tier Management**: Manage user tiers and permissions +- **Post Management**: Full CRUD operations for posts +- **Authentication**: Secure login system with session management +- **Security**: IP restrictions, session timeouts, and secure cookies +- **Redis Integration**: Optional Redis support for session storage +- **Event Tracking**: Track admin actions and sessions + +#### 5.13.2 Access + +Once your application is running, you can access the admin panel at: + +``` +http://localhost:8000/admin +``` + +Use the admin credentials you defined in your `.env` file: +- Username: `ADMIN_USERNAME` +- Password: `ADMIN_PASSWORD` + +#### 5.13.3 Configuration + +The admin panel is highly configurable through environment variables: + +- **Basic Settings**: Enable/disable, mount path +- **Security**: Session limits, timeouts, IP restrictions +- **Tracking**: Event and session tracking +- **Redis**: Optional Redis session storage + +See the [environment variables section](#31-environment-variables-env) for complete configuration options. + +#### 5.13.4 Customization + +**Adding New Models** + +To add new models to the admin panel, edit `src/app/admin/views.py`: + +```python +from your_app.models import YourModel +from your_app.schemas import YourCreateSchema, YourUpdateSchema + +def register_admin_views(admin: CRUDAdmin) -> None: + # ... existing models ... + + admin.add_view( + model=YourModel, + create_schema=YourCreateSchema, + update_schema=YourUpdateSchema, + allowed_actions={"view", "create", "update", "delete"} + ) +``` + +**Advanced Configuration** + +For more complex model configurations: + +```python +# Handle models with problematic fields (e.g., TSVector) +admin.add_view( + model=Article, + create_schema=ArticleCreate, + update_schema=ArticleUpdate, + select_schema=ArticleSelect, # Exclude problematic fields from read operations + allowed_actions={"view", "create", "update", "delete"} +) + +# Password field handling +admin.add_view( + model=User, + create_schema=UserCreateWithPassword, + update_schema=UserUpdateWithPassword, + password_transformer=password_transformer, # Handles password hashing + allowed_actions={"view", "create", "update"} +) + +# Read-only models +admin.add_view( + model=AuditLog, + create_schema=AuditLogSchema, + update_schema=AuditLogSchema, + allowed_actions={"view"} # Only viewing allowed +) +``` + +**Session Backend Configuration** + +For production environments, consider using Redis for better performance: + +```python +# Enable Redis sessions in your environment +CRUD_ADMIN_REDIS_ENABLED=true +CRUD_ADMIN_REDIS_HOST=localhost +CRUD_ADMIN_REDIS_PORT=6379 +``` + +### 5.14 Running + +If you are using docker compose, just running the following command should ensure everything is working: + +```sh +docker compose up +``` + +If you are doing it from scratch, ensure your postgres and your redis are running, then +while in the `root` folder, run to start the application with uvicorn server: + +```sh +uv run uvicorn src.app.main:app --reload +``` + +And for the worker: + +```sh +uv run arq src.app.core.worker.settings.WorkerSettings +``` +### 5.15 Create Application + +If you want to stop tables from being created every time you run the api, you should disable this here: + +```python +# app/main.py + +from .api import router +from .core.config import settings +from .core.setup import create_application + +# create_tables_on_start defaults to True +app = create_application(router=router, settings=settings, create_tables_on_start=False) +``` + +This `create_application` function is defined in `app/core/setup.py`, and it's a flexible way to configure the behavior of your application. + +A few examples: + +- Deactivate or password protect /docs +- Add client-side cache middleware +- Add Startup and Shutdown event handlers for cache, queue and rate limit + +### 5.16 Opting Out of Services + +To opt out of services (like `Redis`, `Queue`, `Rate Limiter`), head to the `Settings` class in `src/app/core/config`: + +```python +# src/app/core/config +import os +from enum import Enum + +from pydantic_settings import BaseSettings +from starlette.config import Config + +current_file_dir = os.path.dirname(os.path.realpath(__file__)) +env_path = os.path.join(current_file_dir, "..", "..", ".env") +config = Config(env_path) +... + +class Settings( + AppSettings, + PostgresSettings, + CryptSettings, + FirstUserSettings, + TestSettings, + RedisCacheSettings, + ClientSideCacheSettings, + RedisQueueSettings, + RedisRateLimiterSettings, + DefaultRateLimitSettings, + CRUDAdminSettings, + EnvironmentSettings, +): + pass + + +settings = Settings() +``` + +And remove the Settings of the services you do not need. For example, without using redis (removed `Cache`, `Queue` and `Rate limit`): + +```python +class Settings( + AppSettings, + PostgresSettings, + CryptSettings, + FirstUserSettings, + TestSettings, + ClientSideCacheSettings, + DefaultRateLimitSettings, + EnvironmentSettings, +): + pass +``` + +Then comment or remove the services you do not want from `docker-compose.yml`. Here, I removed `redis` and `worker` services: + +```yml +version: '3.8' + +services: + web: + build: + context: . + dockerfile: Dockerfile + # -------- replace with comment to run with gunicorn -------- + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + # command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 + env_file: + - ./src/.env + # -------- replace with comment if you are using nginx -------- + ports: + - "8000:8000" + # expose: + # - "8000" + depends_on: + - db + - redis + volumes: + - ./src/app:/code/app + - ./src/.env:/code/.env + db: + image: postgres:13 + env_file: + - ./src/.env + volumes: + - postgres-data:/var/lib/postgresql/data + # -------- replace with comment to run migrations with docker -------- + expose: + - "5432" + # ports: + # - 5432:5432 + +volumes: + postgres-data: + redis-data: + #pgadmin-data: +``` + +## 6. Running in Production + +> ๐Ÿ“– **[See production deployment guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/production/)** + +### 6.1 Uvicorn Workers with Gunicorn + +In production you may want to run using gunicorn to manage uvicorn workers: + +```sh +command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 +``` + +Here it's running with 4 workers, but you should test it depending on how many cores your machine has. + +To do this if you are using docker compose, just replace the comment: +This part in `docker-compose.yml`: + +```YAML +# docker-compose.yml + +# -------- replace with comment to run with gunicorn -------- +command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +# command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 +``` + +Should be changed to: + +```YAML +# docker-compose.yml + +# -------- replace with comment to run with uvicorn -------- +# command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 +``` + +And the same in `Dockerfile`: +This part: + +```Dockerfile +# Dockerfile + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +# CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker". "-b", "0.0.0.0:8000"] +``` + +Should be changed to: + +```Dockerfile +# Dockerfile + +# CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker". "-b", "0.0.0.0:8000"] +``` + +> \[!CAUTION\] +> Do not forget to set the `ENVIRONMENT` in `.env` to `production` unless you want the API docs to be public. + +### 6.2 Running with NGINX + +NGINX is a high-performance web server, known for its stability, rich feature set, simple configuration, and low resource consumption. NGINX acts as a reverse proxy, that is, it receives client requests, forwards them to the FastAPI server (running via Uvicorn or Gunicorn), and then passes the responses back to the clients. + +To run with NGINX, you start by uncommenting the following part in your `docker-compose.yml`: + +```python +# docker-compose.yml + +... +# -------- uncomment to run with nginx -------- +# nginx: +# image: nginx:latest +# ports: +# - "80:80" +# volumes: +# - ./default.conf:/etc/nginx/conf.d/default.conf +# depends_on: +# - web +... +``` + +Which should be changed to: + +```YAML +# docker-compose.yml + +... + #-------- uncomment to run with nginx -------- + nginx: + image: nginx:latest + ports: + - "80:80" + volumes: + - ./default.conf:/etc/nginx/conf.d/default.conf + depends_on: + - web +... +``` + +Then comment the following part: + +```YAML +# docker-compose.yml + +services: + web: + ... + # -------- Both of the following should be commented to run with nginx -------- + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + # command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 +``` + +Which becomes: + +```YAML +# docker-compose.yml + +services: + web: + ... + # -------- Both of the following should be commented to run with nginx -------- + # command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + # command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 +``` + +Then pick the way you want to run (uvicorn or gunicorn managing uvicorn workers) in `Dockerfile`. +The one you want should be uncommented, comment the other one. + +```Dockerfile +# Dockerfile + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +# CMD ["gunicorn", "app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker". "-b", "0.0.0.0:8000"] +``` + +And finally head to `http://localhost/docs`. + +#### 6.2.1 One Server + +If you want to run with one server only, your setup should be ready. Just make sure the only part that is not a comment in `default.conf` is: + +```conf +# default.conf + +# ---------------- Running With One Server ---------------- +server { + listen 80; + + location / { + proxy_pass http://web:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +So just type on your browser: `http://localhost/docs`. + +#### 6.2.2 Multiple Servers + +NGINX can distribute incoming network traffic across multiple servers, improving the efficiency and capacity utilization of your application. + +To run with multiple servers, just comment the `Running With One Server` part in `default.conf` and Uncomment the other one: + +```conf +# default.conf + +# ---------------- Running With One Server ---------------- +... + +# ---------------- To Run with Multiple Servers, Uncomment below ---------------- +upstream fastapi_app { + server fastapi1:8000; # Replace with actual server names or IP addresses + server fastapi2:8000; + # Add more servers as needed +} + +server { + listen 80; + + location / { + proxy_pass http://fastapi_app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +And finally, on your browser: `http://localhost/docs`. + +> \[!WARNING\] +> Note that we are using `fastapi1:8000` and `fastapi2:8000` as examples, you should replace it with the actual name of your service and the port it's running on. + +## 7. Testing + +> ๐Ÿ“– **[See comprehensive testing guide in our docs](https://benavlabs.github.io/FastAPI-boilerplate/user-guide/testing/)** + +This project uses **fast unit tests** that don't require external services like databases or Redis. Tests are isolated using mocks and run in milliseconds. + +### 7.1 Writing Tests + +Create test files with the name `test_{entity}.py` in the `tests/` folder, replacing `{entity}` with what you're testing: + +```sh +touch tests/test_items.py +``` + +Follow the structure in `tests/test_user.py` for examples. Our tests use: + +- **pytest** with **pytest-asyncio** for async support +- **unittest.mock** for mocking dependencies +- **AsyncMock** for async function mocking +- **Faker** for generating test data + +Example test structure: + +```python +import pytest +from unittest.mock import AsyncMock, patch +from src.app.api.v1.users import write_user + +class TestWriteUser: + @pytest.mark.asyncio + async def test_create_user_success(self, mock_db, sample_user_data): + """Test successful user creation.""" + with patch("src.app.api.v1.users.crud_users") as mock_crud: + mock_crud.exists = AsyncMock(return_value=False) + mock_crud.create = AsyncMock(return_value=Mock(id=1)) + + result = await write_user(Mock(), sample_user_data, mock_db) + + assert result.id == 1 + mock_crud.create.assert_called_once() +``` + +### 7.2 Running Tests + +Run all unit tests: + +```sh +uv run pytest +``` + +Run specific test file: + +```sh +uv run pytest tests/test_user_unit.py +``` + +Run specific test file: + +```sh +uv run pytest tests/test_user_unit.py +``` + +Run with verbose output: + +```sh +uv run pytest -v +``` + +Run specific test: + +```sh +uv run pytest tests/test_user_unit.py::TestWriteUser::test_create_user_success +``` + +### 7.3 Test Configuration + +Tests are configured in `pyproject.toml`: + +```toml +[tool.pytest.ini_options] +filterwarnings = [ + "ignore::PendingDeprecationWarning:starlette.formparsers", +] +``` + +### 7.4 Test Structure + +- **Unit Tests** (`test_*_unit.py`): Fast, isolated tests with mocked dependencies +- **Fixtures** (`conftest.py`): Shared test fixtures and mock setups +- **Helpers** (`tests/helpers/`): Utilities for generating test data and mocks + +### 7.5 Benefits of Our Approach + +โœ… **Fast**: Tests run in ~0.04 seconds +โœ… **Reliable**: No external dependencies required +โœ… **Isolated**: Each test focuses on one piece of functionality +โœ… **Maintainable**: Easy to understand and modify +โœ… **CI/CD Ready**: Run anywhere without infrastructure setup + +## 8. Contributing + +Read [contributing](CONTRIBUTING.md). + +## 9. References + +This project was inspired by a few projects, it's based on them with things changed to the way I like (and pydantic, sqlalchemy updated) + +- [`Full Stack FastAPI and PostgreSQL`](https://github.com/tiangolo/full-stack-fastapi-postgresql) by @tiangolo himself +- [`FastAPI Microservices`](https://github.com/Kludex/fastapi-microservices) by @kludex which heavily inspired this boilerplate +- [`Async Web API with FastAPI + SQLAlchemy 2.0`](https://github.com/rhoboro/async-fastapi-sqlalchemy) for sqlalchemy 2.0 ORM examples +- [`FastaAPI Rocket Boilerplate`](https://github.com/asacristani/fastapi-rocket-boilerplate/tree/main) for docker compose + +## 10. License + +[`MIT`](LICENSE.md) + +## 11. Contact + +Benav Labs โ€“ [benav.io](https://benav.io) +[github.com/benavlabs](https://github.com/benavlabs/) + +
+ + Powered by Benav Labs - benav.io + diff --git a/build-docker.sh b/build-docker.sh new file mode 100755 index 0000000..8fd69a6 --- /dev/null +++ b/build-docker.sh @@ -0,0 +1,2 @@ +docker build -t git.logidex.ru/fakz9/tbank-api-logidex:latest . +docker push git.logidex.ru/fakz9/tbank-api-logidex:latest \ No newline at end of file diff --git a/default.conf b/default.conf new file mode 100644 index 0000000..a763f9f --- /dev/null +++ b/default.conf @@ -0,0 +1,32 @@ +# ---------------- Running With One Server ---------------- +server { + listen 80; + + location / { + proxy_pass http://web:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} + + +# # ---------------- To Run with Multiple Servers, Uncomment below ---------------- +# upstream fastapi_app { +# server fastapi1:8000; # Replace with actual server names or IP addresses +# server fastapi2:8000; +# # Add more servers as needed +# } + +# server { +# listen 80; + +# location / { +# proxy_pass http://fastapi_app; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# } +# } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..6996d66 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,57 @@ +services: + web: + image: git.logidex.ru/fakz9/tbank-api-logidex:latest + build: + context: . + dockerfile: Dockerfile + command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 + env_file: + - ./src/.env + depends_on: + - db + - redis + volumes: + - ./src/app:/code/app + - ./src/.env:/code/.env + networks: + - appnet + - proxy + labels: + - "traefik.enable=true" + worker: + build: + context: . + dockerfile: Dockerfile + command: arq app.core.worker.settings.WorkerSettings + env_file: + - ./src/.env + depends_on: + - db + - redis + volumes: + - ./src/app:/code/app + - ./src/.env:/code/.env + networks: + - appnet + db: + image: postgres:17 + env_file: + - ./src/.env + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - appnet + redis: + image: redis:alpine + volumes: + - redis-data:/data + networks: + - appnet +volumes: + postgres-data: + redis-data: +networks: + appnet: + external: false + proxy: + external: true diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..ad0be15 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,8 @@ +services: + web: + user: root # Run as root for tests to allow global package installation + environment: + - PYTHONPATH=/usr/local/lib/python3.11/site-packages + command: bash -c "pip install faker pytest-asyncio pytest-mock && pytest tests/ -v" + volumes: + - ./tests:/code/tests \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..79baeea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,81 @@ +services: + web: + image: git.logidex.ru/fakz9/tbank-api-logidex:latest + build: + context: . + dockerfile: Dockerfile + # -------- replace with comment to run with gunicorn -------- + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + # command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 + env_file: + - ./src/.env + # -------- replace with comment if you are using nginx -------- + ports: + - "8000:8000" + # expose: + # - "8000" + depends_on: + - db + - redis + volumes: + - ./src/app:/code/app + - ./src/.env:/code/.env + + worker: + build: + context: . + dockerfile: Dockerfile + command: arq app.core.worker.settings.WorkerSettings + env_file: + - ./src/.env + depends_on: + - db + - redis + volumes: + - ./src/app:/code/app + - ./src/.env:/code/.env + + db: + image: postgres:13 + env_file: + - ./src/.env + volumes: + - postgres-data:/var/lib/postgresql/data + # -------- replace with comment to run migrations with docker -------- + expose: + - "5432" + ports: + - "5432:5432" + + redis: + image: redis:alpine + volumes: + - redis-data:/data + expose: + - "6379" + + pgadmin: + container_name: pgadmin4 + image: dpage/pgadmin4:latest + restart: always + ports: + - "5050:80" + volumes: + - pgadmin-data:/var/lib/pgadmin + env_file: + - ./src/.env + depends_on: + - db + + nginx: + image: nginx:latest + ports: + - "80:80" + volumes: + - ./default.conf:/etc/nginx/conf.d/default.conf + depends_on: + - web +volumes: + postgres-data: + redis-data: + pgadmin-data: diff --git a/docs/assets/FastAPI-boilerplate.png b/docs/assets/FastAPI-boilerplate.png new file mode 100644 index 0000000000000000000000000000000000000000..5b7985ad46746b7fb164a55a869a662713d9ab2c GIT binary patch literal 399217 zcmeFZRa9Kt@-N&-a3=(UyCz6*cMa|i0TQHfcPF^JLvVMu#w}QI*Wm8jd~o*u%Q-s_ z-^+cvcZ@l@dvwujt@@SDs+t7L%Zei-;35D30AxuC5k&w1`sE`u01oEm&5MA}@8u1` zPElM4P(F&k2LKQQBt-<3ople};2Xcrs;o9$TOZ$UtJO#eo7R#q##D~Pp;;r{5qDZ< zeFVjP9j?}baq61V3$*8xP@`Jb10QE0^|9{K?Q?Xk>76#j!K4W(a1#<^sekiHNL zCC)ki1VP`6x#4V%tpEBS2L9U^^p#(USdxT+e}Lfsr!c+YjtfN4{`1a^5wBP7*I7^b z(`|cRiA?f@5r4XE^oyEoHqvQh{t3WW1tWtQ>i!e*9IvK=?-T$e{R4pLuL{;9;KK1I z{|N&8R~?FI z>0bTQcpcHkAkae?5dx^s4}JIb0b3*O&iw z4IH^w7rb4-y&6pKpx>V$aDJ5+&rLwdpA6>z3Df8Q z36q@h|8mPDjv;?fM(NXQ_uM|T9ndajNp4;L;y7MjE^hzBiVZ)n_4?XlFe20xy~+syYScj^x}Z${!ARy8DfDw6k!LPPOX1h&P;AZOQi?S{MHH0pWT1{#Ty(%Kcg?y8PYK zvGI@HFVpU%4<|Y_O_bq0v z${GkG^r_T`jXLLa0q9nOUGm`t6yNS4xtiM8eEn@=bv7n2wU;XW&9hoGw&dj4;v0$Kr?t($zKh>Nq<^ZP}y@(L0Z+oGKvOtv{72s%G@r(*-X%$ zQ!MAQ-rbm;7*bp*#e1Qy-t;8$GYJ=)kXggYX z7N3HK45hxweLItui@p{$34w?_Hw(&*O(1<>%n^6Fa7rdr4VPuc%a{%mu;+83i}?SUxY7R z+&o``w>Lr-wH=47jxL9tIN>Ygs+wGWDBO&6r6#*B*QO$GzWMFcb4pqmN2V>#LnI{f zel-ENSJ<7wH;6mgsEk=bv6gZkKV2(2DB}^0OvvRT%mJ`*5|_rrCMo1X`03e5I?g19 zgNezk^`vJ5$YERCr;qPV`*_8Xhpv1hDjhP2gG%88ZGv(NDFK^h3SdagLIR!V1kVV? zME#&e%R&s2#EuP9?!!$C=?b+Fae8j=KXKi((m6Y3wSlmXHk5lwY^qjC>HpoX`w~HB znc#i(mYoU~oy;ZS5kKZ-L9aw?ujOica0K%CyA$9=xMte~H3IXbiDb)6T(c9p%$0#% ztu$kQU8hiq?@`ZAvyy%fwZCnO*4w2lN4Hlv3`C1GXbuiwLUDn>o(@J;sNq0Y zLOwWztIO#LYW1#n+njpC-tC z=IDn{vsOFK5WA02-ZL)N^!4B%Nnc`W?xV}ViflnV0mPuJd7(2{-p&hq5#=Bpeym1z zHtB(2g;CS+mPJ;E1>hqx6x>6aK+8|d?J`Sfru6H3d#jl4@I%8a9A-|!Ku&n|B=PgG zZ2)j!y<-Qd^4~)-$>Zol0yNQ;1*1H6vZMs)p+75OLI4$ud&HDPYw1N`JA8|pgfe&> zjn;ofnrGr&>0UVFg}%f=JYa`Q{lB@UQ+BvVi+B&7FJmM9jIt*(eu6GaG`90b-Fj>q zQ(BWFrkjX#0V;!@0(2gQ&}|XpAcK@$1`KVv-=!wHNm747nW`|NeKH`~1tJz*wY-s^6|_~vJJBg}R;3845y zZGyk}j#hO10|cq#G@MZh0E&)Z{U-Yka0H`nl5t7pO1pb3E(bt@X7PYH5afA32tay^ zRrGk)}%i_td4XP#2eHBl$Csx;#k0Mgn3!WNa0h;rf1crL)h6UCW z1m>eO=baxm7{fjT#FScMNZrCnmgAk}$M?m*#YHOgM3cE^FQYLBAa6go&2n*V@4km# zf@JpcAM5Oa`|9-zw34e|J3)A52R(GU+KoSzU5)#iZ1T#uj{vG4NtdIxFuG$sY>@-jW>hLMh1E2xzjSZSN0A)&H#&Cgmh(8BD z7nzdCYAHc7l6uPuOu{8m<77vBi$mzLQc+q*n8A7Xibr`r339S4F%WXvZ&lIp*FS}@ zI?vk-8VbF*Jf?X#dcS|Oh0!-Yyt4%d9)jJhWMnW{xcZ0fbs}OPDU|~aDx`*BuDnz_(-ZTRyJV*MsTyc#8bI$ImD%eNM#Z7vTh#lCQSNDTynML-SCgv|%gttqE zDpGIknq^f)X~NO5(G_Rqt7Ye>31wa2Rh&f1#)k}z2!$Nt8tYgx@s{df_?4Rvu%|ir z13_>rbvHy_AU}rC{hCHX3IX@-VWaqA^m1&MFb1U>k+ADLYM4L z*PoA0-0%078C<&W1TdUYUSM(N{5$%ClF-$UdUc*ICTFQ`x}AVFp&kFqEx%$mJ}*De zM{ju!h+L4BX?m#Xgfz*F3%CygZ)k!J06C^Qj=V}UzgO7-L5CZNE{Jv%f2PUC3AN4V8>Zep5dXmr1b4gCdrsfAxAjraqq2KN+VM}njkIj6*~k!%OQSOw%E+Qoj| z3h(S8Lv2x`v>o9%p7=%Y{#a}HFdXR8#wQ#5X5Nmdqe!je-9w#{?&4wZg8o`@q$^kM zgN9M$8ZY8z_9`|Ly~fcoQTT z2f+*2N)iypRD^)uB=m<;MMEfyDDPuEhqn@Lg=|96ztLsoH)XrZ(Mi3tsi; zPGtY^HiHA+91W7;?G(wJSGIr`)?gbu;hd??y@0gZ;-Iviy2lrLnwo{8qC285rk}Kh$ta10|G2?>KGyM7U*;^vwx> z-c{3hI_`>JX_u-tBc?|tX6FBP4WFv3%#^mqNY7_xAs*YjuqVyBMlq@5O@#xs^XG1% zEo_GF?y24Jqp-VMY@sU~WVJud>w$3N7OGTJc_B{K3uNFXRJiuX;&xH32Lf@Rh=c~Y zJWGlpav{AbLuyc%+-Z{Jy?F#j>GWH@^C?PY0*1@IUB=hOw;9DeEYhmEi2dQdKsy3h zz0`t`2i^;W=tt=b!U(ZKN=T`1afOu`NZ4I9<7*t^ubxF;&mh}=K4lUAk22|+G`#yU z0jkFdkg9%V#j?I$O9@kHH(E#o#;Lf5+$}M}<4jJ^?ylj;6Mg5H*+Yjf5?Vt42+jGj z4^yeFalACmqa^%#hYI(|0#meYrg*kjBNIvcs7y8MC`(WDXy68ywmU-3ZHmnGw$32q z>a+=uA2xX*y#l=sW;&>{CXCRgvAzgsRMP1bhEmmGb}-#f)ahDof?5Y&0F_s9c4>$A zIn1SlzmO2fPkWo)Rgs1lpevLF=y{iA8IAFbu$0QqOt$u(_Sy#v+jv+O_wNrE`odU{ zjVt)Wz{1aeMT5Puh87SLjz`#ihm)|hPLf0gKbKYfMBGP4w(hGus3NJj9!`73l2KQtav4hQg!}qlPJ3Vol>r}Pju=}0$t&w= zYdh}~*aWM4%seA$nF**>u1lAz@D}Z~z`Ne=aj5sJ5(!sbPyqTwj!DJxo-^~rJukwA z*{_1w<3OzEb$>p?oY47B6sG2h>}zU2pjSuA%8s$=R5-dLmxx^I_4C0c4{|;U;y~wv zS*-iJ4&z`xQaz6KPB=YbXNw==8ai3w;(!Ph!(<2zm?KPYHeJLs7wODzwAJz60rjgw zb`_tcRbNW>fj9CxBtp)SN}(t@h{!Atw|SOI=^foEF8m+a;sJoz*DR-GCdG-3kVwT>$9NMr z3TbeGBxMP%SrmK5ISPBTwF0q-NFCnpuQO2T%IxW&tkf{+)}QsC&@AZVla7@tir*5k zqQV#O@u2H^n|Vfpy)2q%=Xl+q3-1UMqN$cjXayGGFF({YwAt+E7j#2ZH+67?#_+S8 zuoe0MjKJzb$9H4zp&sRQE-TOdMa{mf;vPiJgUHs7Q8@*Ze~3Qe?}gLCyq6f?ckjUW z*QI`VLNi#tt(H<@4>|HTBDzv-|EAs8VY(klS;RH7P=HsDE{p2A>@rZfE%c###u?0z zd`w-Q)p5Jd%kOwqKkQj{gjlnLsP2{#7)60TE>En7KHdtDpv4Bks7P+(t`O?&-w<(L zZ*6xxc(s2E<^$x+MTa{ey|jjBJTJ`uGJ?J^Q0A)hGk>Gy^*O&;%YuGUvAvKo*6E;@ zHep^~y`uaPB~ck&Yr(*T!X1uDu#Q32eW=fdD~nK4XliGokk7tde0nw8jBr|Bx1&tf`CSNv zbe?b0y+Tcwr{y0VJ21L5TvZL z3g63NMOPIHzfNK#gIyJ-gie3IzK5iZwJ}7kM}-{SXHC1j=S!dMnNB<_=HKrv#~87r ze_xw6u#s=o!#y^M$7nxEueIY9d&nfLo!naOI-dTVJx${^So?hna?5P2T>nP6%*uQq z_fc)oRi;mWn>ZH%(g#KaFs~;lj=wRrnN|8zyX9L#0!(@!*Mp<`ft7B(bN){Ej)nG1 zAIN;EZ6`VQU)7y32p@4z_h&VF%M<={Ar0pA5-%zdQ1S(n*mAsBv*hEY9FfbEle?`JWW>??!aKY=l;#`T;vx{32lk; zN8siXwwc4td=xIM=DN|&k5GDC_q(V@Y=m>xTQSCVttqqdL(lCt`y1w_*z8QHh4e?p zpngMQBOH`BkN5hX%1_s*%je@p3g%oMXZbF6`$q3QvQyp^k|}HYr1|C>t^Fi^&+1rN zte9f?_M1)9A)k%wa|HW}$6%M;*d`LaFyB|G*WM76C6`?Czcyp~JaF!3eI8b>BTLr| zGY9naf-uFbURR(H%C7GSMBRQ|GDTk+Qj7|=D1szO+>b6NP9Ex4o1x#bfhb&iZa_Sq3*W>>z!~*jXgRN7FfyP zd^o^_=?e?MTBI>&!JEd>Pq1C)`d(>V*TgoPRNpC!em6jNpcWgd0q%>;=BkSba zW>e;+LTEr8bi?X4ut2k4yRxE`vApVMm7UtE>vq+)EAAESOebW!wI8EDLtNfqJ=(PJ zpsqAe%`@>%s2^sT3f_nvVb&}W(EwPr{cn7(74@GLu{M>L2ZcCyS37nPm8`%u?A;{Z z9TIC_Uq%_$zOHZh{T+Mt?V*po#(w04K3J^{Y&kOxb=V5Cq<-uKnZtmx(Od?gI zdq&i(%d)W!+|IjORliG4mYPlN={BNY684umQM6V@bK_#eGqA*@-6DU&tN~Y7Q`Cpa zF?$w!FVH)|m8IsOhj(iF3!vai9}B|ty{>p~j!?uG8=N`V==O;DNptEML|l-4we3dsrV~^P}s>?EtMu&%p=hQX2N(sno zcaUfrUe0*KG`7R+@}dDf^B|X-AhAIAmg|&{#IUgY+_q0(G2nFDr`6kauVgv1N)|R0 z_w5c7Btyp_@nB)!lR_o&k$@W2ePKG}e7r7Epcv$tY#z)6=gwngB%s`X*>dmQkmdBA zgMpQN#wF{G+Gw)eOzFc?-teMq*X?mYdF*sZyLLrEikHsQ&-X}f7qbU-bvN5>%ts5` zn|4Y^wR1(|;5@r`>w8HxETfdtA4=_KqiMU)gt7ca*xGpK_bSZ6S1_1>gDXhR;hkP+ zkOs`p?!O{f6zBqeP58vHA&sXRXlXgTku{WF%a#1OR1}^8M}f++4CdR!$G0G^Sv+1q zA>;x=e3tX=l+a^Wm;FVc-WC+w-pggu2p}2tT29Ui+JSipEykh2R;iYCkJ>FNlH&rJ z#h%h%5zsD0fD^o#J?f2^Yo2Y& zmwLxo*Sl^gRv8~DBSvK9ls~6S8RHsoPhp`;lH^(_yPuhBiq}Bllrc`8Z|~)e4%M%k zFhiW$c3zyljCBBX;r=cSpVDI9ZnO}!-G>x~9$QSeeYin?yQQE;J(x{jaJ@|8w2s2t zY=1vfOppCZ$6cU8iyr-Dl|q%!5`*k{=Yh0as>0R^iU}!@|)O1bkc@AI04& zr^g(gd49^VLNzYi(Rs1VkSK~@cnQhzE!||4m1*_dn%P#!+Rm7KZlVpY4JE$WM-_#D z`S>{2&NehEI`SP?THJod8)u6$aoDF6N!6%nRSPBN+70-ul%w!iZgJo5=wW?*$=(dm z_oa5?&!L)o`sp@*L3=-j{5Y$Hv6h!MCfsniSXIX6Wo__7<>R#!aQ!(Z5O&*>q4imK z_KUEh9PE>1lmSlAPdRRH zZ1LQ!h!qn&K4m~WdpdbnY=K=`9muA8J6?_@5VqbY?n#cI&5|uOZ9kdn7m66EcZLXQL#`eGp1(Z$F)_~`b6{%t`wr? zQFt#tYL7i1>Kvj1>9@n8ApV0JQQ&C`mApmAf=3}T{sQE9RmC=7AF;sTMy2ZfOS7L({3zC^`f+;Mm1z)q&~KYh-?7;p5TWy?YG>+E4C-4Mn0} zPddIp^A%0lSsta}ch{^L4W|dp22di))SWrhR_L>c&1WQMV)SFCNRdA~%^pwe$um9q zNr6!oQ1)PP$7?N%3pIWL#7Q(~B$K(q8YuhX%pN1d9(Nxcne{Z&x3W{&tB7KE$vEVW z!=KW3Kjh`bkDi&pcCd3!HXcNFJbddQ8; z7XQ|zfuRJX@Iab|KTy0m0?TXl2GK!>M1IPhZ|XYaciiuJLzKJ;oupQejCVc%lHSji z?F9ej9McdXZ#oWk4;o}^J3g&17Wr~F=nK@-dZDuvUaJ~g6*)ARSVxt3-uvjbH3!C4 zl79Espi*f$KJ4y~IBdH&Tx8(J-szbo_oacrF#{XYYCM8y#E&&5ougl!jaH z*&S}2;Bo^UpCl-P1gimkZn)siYs^T0{>&jrd&;c({ZKgtTEl5N`9K(fJJdu0rCw#W zv=YUT^3&1V4|{UUf|a3u<$IT^Lm6^6SOHJJ6H*^1)2UL#b${kseLOzURKGAh;k|BK z)MWNjuMwRNpEwA5lPFG4%58L6Ms!@kg(7f7;GifF^d(=TQTQ_=YR1iaa>-0rlWq8> z_)Erfwuo6C^jA_&@KWaH4dip0Pbgbu-QAs{C&&$8gs=cpA2#4R~p&upFUR^R4354*cL zI)3{RpVF7&8HFHd7cj0_U`JXz#%>{FeDmJ9w&lYbwlR96@LUDv z<~^bu3-}ASQh4|K`>l4vi>~v8!M%GaY=>6k0!LF*M)v#7?)G5%waM1}H})t1KD;0I zaQv3;QvO`T;yAnS!c|(Vdp8mL`E7c|*;ZL*&>A@~oa5nJ<@FzS!>RJ`P4}o^Ccu}< zZJZ4buJqICGTBn_^*|DJ)U(zVPfqj#G5q~SBE#xFLG#QL71p`ocK3x`zd@HKH9e?1 zo_=IbXp;UX$=YP z(B;ObL7SuF_p{B!us}|zasL@{{Wy%?fJ?Y1UF75UQN1)K`kOaKi>Ex?+BFc06f6X( z33jG=`-^0BbQNv)5qGeo`Y!GW@TJ_4dOK`h9=3Xys9wgbj6_Q-EB05~Ze6mqW)#Qy zvJCmOK{yOEd0SF#5BkH{bwA2QJ?z_sqaz#f*$p#N3MP^rsHe;tBPBC5@5ab@#HnJOI8hri~z5~vl1sGslPeab`*S&FKs5D0MMuK!;dD8 z;*y4G*R`6nH}?<*%Q#9U?;m=)s{GX4jSfaxVFM8l97g~AV)xA#9;3Z)1zeJn*`?=B09} zIAyZ#)x@@+UF@v3@0}thpCd2dg&#)yduMiTXf%UH*D7zGcEN7C4ZxkcVY({CZgC`6 z??@3#sLnCiBPPL59K&+jLYl-PWOg+(C-WUQv$_vwq0eXUTUj!NAYByUy#9MyHBATd zru_soYSH^sDHZp~jG~9sU`i?DwzSKO0IEDhAM2SboaOZDF&M2AdI4&UKOr=vdvFa6 zes|moU5i0lLnVH(#d_nZ;$f~nGGm!}V?t8j9iI5MZS~Nth6#Kf9DMJ>y}iT8X(#_i z(A$#B!~b}K6gKb&FaRHLZc96b9hr(2eqCHYxb42%ssIs@QiFUlmK!Plh=MJr^_~f3 z#)H!)@RTs)$%TXHWnAXvB#-N=S(}~;-@?Pk31wDbFLqxu>HdJXp!D(7P{;>|me8V7 z@TzMKIJDeaulaP=Se7@FulRB7=m^#iyZDkB-TJs<(QUQL>-k`8waG3I8b9r1!G>Y%Nw>Tg>*eUmJEw3U<-f+R z)$O1O-Nz>Q+wCtB8vM(V?kQc5_SNc)9g)Pfv%z14Wduf%g9Mjl`4JKD2QGLj<7f?g za(oK|L}XC?m0gS(XBRWHkNuC=<}S;*ovvo^sp*rTbs>BzmRfIqxSn=wX`e-IQ+;zU zQN`3to|~lB`g}y)>1Lx5oauHM=)!Te;QeW}M_(wLq}lb*be?d8vs*bm|DZaG|K@Jf zar)4k>wJd+v;hSVBVwa~p}aCr4A_9GzIg&hYu~ePfps-K zeJD$KVozMUzS_>{-uH}I?j|{7@Okgon+VubaUIAx;d9-O8PWQd;I<1B8gT6eI(xH6 zS{q2dsZ>lFqf`mY!W8K)qG{&3&j=Q%BZ;!hIsbjY7yVOmW~P_JP^ zR(wfu12Z-d2g>soTmF^JWr=%a_6QF9L9ES1LK`Dxn*0kz1F?tZxvxhza$xF zepHZWbwWRPJNSNMycPY%06zl0*Y9bNcE3GUvTEMO#9p>@RPE0xcY3MWmfjF--~DB@ zX?Bp2E4G(6qp2&G^@2s^8w+eMzn0BvPQdD;gr*61_{ki&Mfp#>=h5E+haU>xbCAq))22X-3$iPj-BsR^kMOL zr}lQ|++&%kbO?xY(vs7}$+Ugyol#>Pg#|kAl*n=tSnnFIpuf3s851~qz^#>KO>+r4 z#4cKu1}tYb*L-E?Pc;|%y^@rh!*K>@@owPSD{1Td;#)|cw-iFZjtsGSxiQnKiVC$F z+uj?u>S!D%8$R`NNK8K+sU8*3FVycb{Ipa`hrs3gij3~On@~X2@HSR%OnMrKsAKP- z(He}px@gA*Z$~^H>Z=^P?aP|}_Lor^Tqqr{*N3#`-Qvh&XEWqvg6?qO4sQf<3w@dgizr|94BEtDbMRvb>71^J(L zK4LOsTc(?aUMM=W7{D_5bF`w$CyQ&!SiLhjtI^iGde+FQ=)Q@$KHfQ9C8dbshF1Iq z*)E6zp?GLM=7WcVax`De+eyht><4R6?+S17v`XHF7j4Ce0UQl-!YHfZ^EXOx^JtmU z(Af3=ZFuE)VH{$6ka^fVS*cB|{p$+)3kM#F3oRM3oPH8hQrp%V7rpLE*o{1IP~ku!(P)a$AkGgi*D3EXht^FOQvjJB#Odc4%OJNF&An8ZmY z{yeDVFd(o7t>$&LI1Jvq_mW8|GUOLKNH#gMz8u+w10e5R)pvc&gL*cgzb;|o zcD?XRxif}`_~cj#82{p3yeAmjAv`_Pqpo3WzMRtr`<|2L-Y+9<|3gLTB}}FTEAaVp zW09S|HQ~kw==Bvk!}U41gfh?N`O=8l;eG*>mqM@JmUZ=+S7#jOT}^5(g39;uilgSb zKsow^&t>4p>E|!Q=X6@|nQaO$jowNHr;Ht$e;r1#o`L7F9X=Cxlo`{Tp%FGk6C+Rj zF)bk2@B32v{+clj_VRz%T`fn5oYgV)8&wJN4E9^X8-I2jg$!N@#tLcsFz4|~XekKR z4-LD_<-s0icUzNwq}pAmVa6WAcaUV1fcfW)?M|l|gQMrUPpoxKIwd*${P8irhz-;; zC1sJMukd}HSf?du5)?`UTJ>~3*|)pOmYD8HUyq0gSk<~mf|Gf4U({uiFx|)+RS=N% z^t3;d99qk9IxZ~F*vr6SY~I~ce5&YAd~IJrm#$mUFs=h;7}0#{*S$EiaO*(FRkam& zUk_VfSG{pQd5H8lX-$9fbM5|I;h!f`ixLwDm_VS{lXgQ-R042Bm(oY$Y>2cmMiOza za60eh&$HL)7$G^iSbPYE_pYmw)28^>TpkFQ%=#HQ5PTa_rqvEBe4oJN_R~cf*)fe=ChX0N2(leHYxpzJdVh(?&f#s9nsnO{-}lkf6*exx1GvM@>^gf+s4{tZ5?WBj&(W%(3 zxP~*>6DNHKCkd;r%dx&w8?mM5DE`TOb5AuFxDx@G3pR9q-=4B&mM{(2=g8L+YQxNY z`vsUEAd7`ya)jZR7mX^wZif_}g7_3?Cg=3-=3~Jqcg5X6#8?Ftm?yFB^BNsH-<49G z=9%421N(?98{*{NXI6oQ&u(|fVa!a_ysM&Ow1wcaG9Di}GN)Y76jb*QSmHtsffJwI z)+)ikv)_w|P=IJ60T^O(O#I^v9#6a7X5HX$dXTHk)0Du7VMl2?h(SYMk8iNmgsq<#LOXm_hT zbD}Z#8bQNtbOX3*v1MIeSA*TCo|DVm#I1&?6vr_3mx_E4tTsv?}!}f4yZbLn5|8| zWQ0Rp61nW^9pBAP7zVwZQBHD;NYWFRP#Gi-bc76d6_R2_rK!!0?^Ja7G0Uqib-!~p z^UJIKP>uc|Q&fb91^y8k@}XjNm1lcc&vNFGyFt#4q zsE&ueXJ;@f*rKBQBo-9}qh537SIgJG3r`_=wriQITmFx}yoZz*gR8kwlffRVpc=^tMxoI*aMibgZOHeAw^ZPXbn5)bDYftTLcO$qrUfo06{jT%icG1pzRTDOHyU>2el`3!;< zRwFxZzWSd1kq~Q?Y{!L2S>dwvgvEuPkxC}^6&=Ot8aK*e+eYifs2=_r#iU9N3cZC zBKFNh(2pqumDF0r=d1fWqF0dkC@@oZ-{vQIuPe(<_XiPUpFL^?Y)B|r6r{#Sc(&HhHXX5@O2AuX*UCx7** zcS;G7SBsc6|IJf2156yfgsSPibb&F_37-E!W9rK>CUIHBfak5t`&o6O8L$Mh$Py#< zgR!x|Kq2eTn|XH3_mE+u0{NrwO9!6{a+7)v^LEbE%PMMb-gNH>t_V6>&i(7U&|B(^lm8> zv3C|1)$~1a<)VI)CBQ*B_*7Ew;am&W;$QnMmD@)Rw@n5k$cgyH+wmi}@=2Y?`%FZY z-VUy~4@4@)nR{q7>9j#?5cn`nK8LWCTub<7+t4~7Yj3~@80nyqu70sEj9ov|c0W4G zIdBm*o<|%JaYISF55bV03VnARd&Z>&(^Xxa?2|mr&D^;xe&V}^qP>D3pr>9)oRP?$ z;P-V)56y=3ntHVPotle+-I*m$bQTlYM;usZHW!)yKB-_+((G5H{7|tK|1s_no&*dt z#PmA9YiL+fu!FIRbdB=W@?gO4Vx=o0bF8b(rmb=ruWFzA=z@D%^f@;>GZ9Y5{f&=W zxQGNuVYNw5u(MLFWLlSyBEbtoSR@BsS7DU##6>zzd&nOZ_Ei@YFLqVUOWqJ}gf6!IQI53bnTjcZudU#KMCC<7(oURO5=-ONe z0(L!$jR{EZYrD-@sUX>*gl9{Dltt2_P*MG1bCy`b?dgj1UJwl)(p`NDvi&}@h|aqn zRbMe~2wO^FE=Q1txpH|CLGQ6aORJ`Rg1UGvzBq2p*Rd1_nC_3$Yx140Wvj$l?`k{+ zp4n9f$fO*rE=i>;66#fw3UlnE_`^9J$$OY+9$^0%dVLQhexra={iSaAhiD+6+&TDL z(V`8NT618E!!u@E{N0$gN5gybxTkfOc2{WCSc8Yv7Dg&kamP{FJEbp+s-* zWy_wxw|m)T0Eh-}SLpn~=193Tu48TzXsFT6k zX+_BtQ@44$Eo*b^rx#s5-Vg78Z-WJV{l61Ho7u*1|C*BbQ40WNq@rrKc-UU($=#=% zIOe2$2Uec+sCMu6P(Z19sXrLn>-m}!5e$s8k#R8H`m#|_6gIEJW;lW&CiAHQ)G_ZnSTTdp5v}C_$@=r+%4W?A1R0 z_&jQEcusVfl!h7*U#u5QwEHH!M211@@puB@2SoK?-Z%E)hMHtRZhkNf!P;WEZ!7!` zPMPgW4^Z~rhCq8)Rt%UT9)G`u%(D7@u}X;JH&AQ=8MtO+bGhaF0r@`tQyh^Q%R!5t z-NSa?PM2r4<9F7&_u7YZ80u~HgdJB~{3%!AW3^^7dvP?NMM$fOVsoF^JW+A=y!nqg z7>U_&bbTDlv|B4rurRYv*LO!%ybAQ@GH5UELdF~uTk@~HDLvew$4Qa6Ba1Qp(dSZw zuowLnTA;To|?uCGWM5Glbe|2I>3b<6sA#YuYt)+`!` zpo*MehnEK?#|MMqo0gbjln}?PCEx$6%!D-WP7>eB>fZ25J30qoR}P_GMfjEySFTRzp!*2>9yX* zb7T8*)KG`6{dNmey#4oN;-PvDPU9idYd3NcfjBRmq?c|%_u<}%68B*&}Mbeu66G(cxLwM?p;;8 zy87?m0qHDW6Xe<46-XZ1-|!CZLYAfUxYQ0%jTU&Dc*(67`?-z>9~vc(Zrk6POc}`Y ze)avKD!DI5+-Iq)@t?-}bZuiF72+LXpdXu?h27g-3D|I})a&f7QtIbX9L}dNyxC{;MjA_2 zYqlCox4^KkZ8<|-C#q}6D&y^$dddjxM4Fz*R|uXryS+Ib4&F;HebV1)1J_Y@HFvr9 zHfBPxIU)@l)bPBe^>wYJ4f0Q3^uD#+{aN$A>su48r8<{>4+1)u-oI%19~*u*pirJ^ zJ-_IThMk@DGvseZ3z%FOwin977-c!4b7_h0eM%uq{X9e_^LYf;Y-kcuJ1cKa%?+53 zA2m_Ynr<58{R%2E@kW`PK>#g~kvF~k$rt1o<}?Ja^^0~Jg!nYd%;8NCSRjb|>vF&| zB$B|Y_fiW>5*Ls$8W_m<#>zCnTKq09fQr90P%|SmTM(8-OWvH=sXw{t;&ilFvEh+B zCd72DoF+wU-;+Rn2}!PdTdDk^rK2Euh#XJP8BZX7>1IZ4__{n6+4HTOX8kC{m1NE> z$y9$^V2>$mXp=yD5z`m`y%)erO9qnkbPTMIA%MVZTV=y_UW_HDR=3cdqs=%_n+z9JriFMon1#@WyY zGIk8xZzYV3-EO}t<#ZKZ<=0=35zNHylAAH$_ib~eV)kUBX9M_%zM&B)NBOF%auOv# zb3im`&vE%4e-6D7v!4iNM>SlUT?uo@``Ssdlm>9cVQFXc31eNk-$r4cyC3XJ(8{<8 z{;Nw}s?;;Od^j(S!$z4M)-7J*{*q@-UC1I5gH-<}5qdZCw+BDt8_R`+XkAyC{`9-INC^^0T3OPNF(B9~eQ_l9I~ z9uP4XzR5YCH2+>Y36d{B4h!MXm!u+$r;L+J<>AOb*!L@(CM^gB= zSZC-m^Zw&*={huv^6Ve4>sddYv(s_SocHx@9wbl&m)=68kYD(4e_EFbmnja#i zkhVQXk1ov)j9oJ6BKqygteko6b=xAUJoxDFbh#Zl6;PY093W4(prv3TrZuB2pXD{kqC3wCYbJsQSl@Fg%z~WHdtepn4cY35f|Y=yAXkk(o59odIo1jlJ6HZm=os zB2T2L9k(O2hh|C}ai{$pS0SPIujPV`%8mtoJ$vyfy9AGQxzR0uDGVJ%h7l=S`E{Mz zvi|c6jGlNcqg+R%irp-=sZovX#r;~_;}~%c5btQuvXb;;YLe5jo%X+*jFGYLXd_y{ zsV{jnh3QTae|{S&DiJ2@E_6Kr=Lq9pt|lt;x9RJ{18yrg`Kc=+)jwAx`EDqk8>b;H z+ZLCZqu_=V!Hf%xYD$q@OmiHi-Z3JDcvVRZ!#)FU(C5!Qd+)@4=7OB91^r)0; z=v=#l(^{JH@fn`}B7I>}l`~GNsDu`o7x3^odagenKX+&DI8|3yD2a(z%vUsp!Qk?D z_}AW^!tKmpVa>|{7@VC@jy*!#dTJIu59=pm%tzY|wz5A}` z;AL4H126^8eV%hja`uU?kocQMQg>u0UEE&sa+~~aLrT+ND!q(<2IyN96?!9=>R?Zj zmNys7^x&v@yY5jEVpGa_h<;2}FeFKW|7Z8`!g#+8I^eyzS-PNC=?3IkVa^g!>Ie}e zP?5clqbyX=s4F{d&;oJ;);Y8)_tsM^E;v7_LHR!O6pZa?R96LzJ2Dxwy?r0e(NE6i zKuTu{W$T_JJ@lp>f^I+vTn4!&#qLrY6Z#YFnu3Lh$fc*t186=`6vAmHq0pGg3R^a( z%K6gfzX($OWoXnU=QKL$E>h}esi+_8dU`&6Tr|iYbwv2uwfVLWl)5?HoXE+z?E_n9 zqzBYeUweESDG)7{#X0N_Vf?W}%LU$!Seylp7@M1NI6yXUDu)?= zXZ$r&p=({{zZjfa+46#5fC`r}vBwk^eV0Ye`y?<@4R}|sZeM7cy;c(&8l`-qD+7v&Q{)eOuvD;BiEM57ENF}-G$|o zcQQXeG5YVf5q7VN^KCSD3-jp1uVr`mi*VFk6S-au+*+=8VK)4Q~_Dx*vzxRnV>8;Nc9ICcCoLhUqD#vtRCk>Y#ol2F}mn#YrtlDq)62)3dz*D%<%-W ze6(?9%4s!AnVD*18Kes1G6cc!L9Bh1$JoNro!3kEqtuKk`6S*@Bu!{e3b?( zAdi|3>E(;0sh_2DD|5ebfUXO~Zw*~OoYn3)Y!^lErhlyW93bwtA2)xWWIe%!w$B&W zqB6-I^n5Sxs6mG>Ow7j#K9WOGe(pO|KuY3cfc&WJ?6T5;Y@m!lW9e-z9*FkcM*NN@ zKr;USEofh8-9S)^4WCoDvO?G1WJ}9~P;52(z6>?0%H4jH#3*sC56V9I5>eyB-aVWS z1Vb(O>cuJB5f{PO>&s(7LuAzfL{vAMf}j$tg;#OK{cm`bsOR-FTd_Tavfn=QH&u!g zD#HNiy9HkPKhlq->`QM_8zR>0CSGzEtEg za;CQe6TP{FKey%1J3NE%39akt+{b5ix>)aKwS{(yF zVA={>3Fgq*bPU*kTI)EA-|0IwWnFRa4$7b* z#K9hX>2z2BEh#h$huyyNLyI1bQdtb${) z!i71Bd&-P9NKAt{2C#IOoMNjPaY2oe=jn+DBe|O%zHhxnR_LF>9A!& zx_FsX7a12qN(rgg%86rhQM$G|o$b0bcHpMP%hn;=GiYCfq2j%@A)K5b)%^P!@~?A* zI3UY0><^CDKex)jR+%0rNHtR&l*P=rU*TNHCxFZlQt>236UaaaPTJyyUnZvL&r>CG zNgX`*O=_#otEX%|e~V&4lJ6$Ey*LThr55M8f0JbM+gUXl;G(i}I?4L?apy*tOerEC znY^Mw<}{MlS38F_pZ41^q&0!~zH>6d^pCg{_b0c@|FH?y6i{yyiO&zji`!&-HQCvv zgwg!UZf9Hh1!=;=sH$!X00dfbwh;HXFD|pLp7_g0N4u%3${2Bp>pT|8TuiP%rv!hC z<}t~?bwA_aJeMT;vrWjbThOA+0p_uU4YT;sdhDcy{9?<0{$N5R{EiHohvP>RKx0!7-@2>(@&>uug6#>=*R^GxS+Q<3UbvF0I+mbnj*MFVa?N;}tp zrv^<=Ev3tfxAcnAQCINCi~SFAqqz8g9khRnM)K^$h(9wwLPa7pGYT&){o^aW5jH_( z$=0PedJg&n?7Y#dWG>TYMre0}L|b-peYm`8YDktviFaPjXG3m6?C~u0Va7JV?bTPA zEK1|P4&!B0QE0jek^Aa=AEMaCw4gt#;NyV7YG;G939xo_7b{6&R z)-{{9{+n(8)i)1;F0fEuR46Qy!4RciU=q_pycG~#UvPYU#c3+fp)%uaVdFsW3AZ0# ziny$e@%ZsR9dR$)DzDi1R8f1`DgkXk>_Gh%lJfq?+KM zMPu>!naMt+q)TCXa{;EQi&1{y#Kj(%^cT7^e~?r)qTc0vl0>-iae&AQ)Kc3bdm-U= z8G=O&=0EImaX(Dpu-2n2CaWxLKcZ>1zSh_R;~1d6oHEZVjKzCj$q<*l1%@MQ3ITR% zp-&nSFTt!#@^9FbgQtC^Zqshp=_=njoX~_1j%=~r5N!}BE@q6H(1WkxJ@NZi;2Lt2#>u;ek{J?ABq4~J;Q&}QlO(rQ8 zFl__vCm`Y~?{tww67+;+k%^n}stw4g( zP0hNEpBYh4={5Iuzi)H4M|DL)fX}Z+U2|E(FT46`7@aYVx+020`nz@!UJ*clD$GT7 z%~nsy!-QqG%_yqRcjLN{DoAI{-2}|naMd}~#!#_ShA`6GofOo85k<}lnnHwR>6wfZ z?G-DyGq5(xUNL5syaGq&qvPg{RPF!`4ylg2o6E|?Vp&s(JHHVoO5t_#UoW2BOqHfB9YzRZcY09 zSv-q^AxG;wd82TMMvi!Gx(C2dtSjUH5tml{qNDr$Q=x9niBga}uVx8{^V^L?V~hLY z88?o{JL;jLNg2^RblP%S1+I#{f@skCS4PJaD!a%lN8Ho=_~^LnOD6$U)>L zguYVELDnQ6fC1VBPg<~JwjMlt{=@}z8btvrG3`f0XLHliMY!gxoA&dC&HUjA>splP zYn6~9qQo_A79Iw4rM`Jq0HHg5PA-7QVT(QuO$AY(ewe1CI$)(5F}K*`7Hwl=-&4UK zv5iuc@S<}ZCyv`W)C{l)RF=?5bOVMrUgfQX=qV97)M-0e929W6{+Qa|9&GufQ^)Kjl z9^SdnYh{x}_k-IgD``DBCR>qahK{D=rlSI$;GiGbZ{QfC^YZRnJlGwzdi`Ds^t1r# z1)1*@H3E-{%&4;JkaeY0So4_n97rA2PYgBilaNIUPau~4J#154?m1gFW7_p-@ESeju7!sysj)do@P{=O~# zUoSv4OOYTLSSts1hWB-Y6hi9l_xb(&=X<%f?8TQtk%*}&grT{!&Z4KGJ;*n~JH5R+ zM=zAOhw^0Fstg`iUA?tr>CwEtw14Ce5vhOO$|1lW?@v226g6Wagw{?;h>(b z=hN&RT=B?bx`?SoJvoG3aV*Ln%Kxc}uOO1&3lQjaW~X(L#V2$F$Eipe8KptH5^|@1 z|s{`m)rtU(0_dpw4g`gU!cs&nXi!F4X^NpI-zVw%W{n4A$Cky2> zpE$)z=lf!cD1`8Z20HJ;zsRRtr%?|e8Z(LZyC-B)2+D>^_`1sR0- zRBETc2WAYUHucD|Cg-;l-tyzEuHNzc&Ax4&s^34k3j>aPw6hp(k6FT7q?LrAX(;-J zxcubso9s2{-W=OfIC&uXG8T=Cl!GIAG_$_?+?8+>Ys)nU)nxNR3q!7t7!ZyTz4!8R zBWT@uORuw|>{A8l4mR0R#ya*6=7LRC<^nh*xt8?ed^)B^%pl86k3e=0Ujg3+wYR`yTt2Ag()OXjn^;h57OZ`vZNz3CWq3tfI!q6HYB< zyTcJ{L&b--tL{g$d^U3Ow`d7p$j-s=L=Qm^)*6JssWdRnPH$6waHAi)o)%v|pp`+hpz#lJc zZ3nG+Uxv%h+)4yw^+okr2_zVl{9?^c%IztZqG1nn7 z12|t4UQdhp@dL%>ratALxMUvo+4cHD24tTfoVEZ7t`Qgn< z!O5T`&#ODkR7(P|IpQ+QM^b`se>ubYLO~7(y`x1Yy!ef;|8@6uH+at-9f`FBi$OV? zqtheX;l4u71^1QK^qSE5_748$CB=mLaSuE`TBA}$?3tjv0uIM3XC=3-jI*^%Nx-ig z%4+2rujj{_JL|K<7bKIyN=pp#Y>8uVhQKQ+VXfO1&;cGi4iBQH*oI;v0%$&4*pPyR z;P07cIOwH`(=wL2u6DDLRDp&D(5#~CF#d*TLvhcZa(P;!()blo<4hE?f&E@Pnd4Tl z2U|#?*GRM)gQQUqc92e!{u;Z)Rue zs+TL?tl+J3UAr{P&Od~8P$0N4e=L(Zc%oK=!!@++R&PE25-d!`{JsbcZpoeei(#3r z&v(IIdOR3_6}+XiuzZmzBT9Z-!bR+A6nG{)NOFj3ZOP!P|9U?J#p6Gmtp}&&=Yejk zI~mwkhgMx$txC-q=oYjhUdDC{TwZhZ#ynTcxJQYE3MvyFj_9IDMcMCQmQXe=_r8qN zm1li`i#3@qnaw3$S<&gOcCjXTHq<`A!~l5f7f*M?QI&(Ulr{cYIYGPcX7dfx!RkAn zm}JOY+-RYgVU{dd<;ZsVeyFhvL3+Z{sI%P;d#!DvFYR(4c)fiEoJcz}^DnTCiulGB zX#LEzOqq897D!;`uy1~Jo00tT>{yIog{2CuzZm8u>xbY=1jwXjmfFL4wH6&KVt>zB zF2);<3Ua@RE-!m!*neFXI@gWgBr)j8)XH#Vi9EcMtw9#VhH}}CC+I;!J2}H|$78dH(1M_| znT6D6v<{!!_b_mP6JXm&YO1IOL^s}2gYUxHg(MCZr(Oa9%DQrBXef5lT{a^^VpsIt*LnNg4(Iw*!6}Z#D_?W zN`?gNZEGTCJ!AZ3EsqGRSC%Daj`DJvE8$*y7Pijggi*vF(GVk}0*o;%Z;XCauOS@7 zy=s4r7VPNNwOTpZc&h$)U?vC)aZ{swX3Ss9&M^VkAk+3uL;!>+7S{khkV6;G3`1tB z?NV4zt{B5aq~$v0mEkeR@axE0Tf(P>J#ogSkU7OADVVb+9)5GdT*q&84f8o6*B9}; zhWG20Cr08kugh7c0fCF9eEMh!%r}1CZ%tdXtDQH&d+zvWy`*0+C5TIptE`TX*{ZiK z9X8g;ss?r5I#r48+U-mYi@AoplJCSOlYstfib$oH%zj9oAn?IbT zX2y&{6tb0IaQA$^=hkysE9TDLEecMlLWj<7{k7dq+#!-ewb&Bu@+=YoANbnLn}}S# z@I0cw_A6U%%r7)*&3~1-P-=aSgdopkwVz{WvC8IEbSbAYmOOY^9Tr;f0jw%P)98fe z+%Mc*#&sS7Lbi+36C^tTWBI3`o!{c%V%WX1NmVbBZ46FR(0EH93C^h{^DO+PN`SM& zb%LWykPcH6sYdl*K?-Pk|?DGcb@~!8!w%)HP~yC>E2w#fh#(??w?bw@sd$u5DGjEe+GOFC(mA-Fh}9&yi2khqF^6x!`_jpYx!f zHeGmcBJ~3|Ix^v8>#%)Xq*Lb1kwfY@Glh+sVCNf{jNda~zrFS~03BD3#f0ZWxSW3t z%*L`%OJO6J ziPC+jD?>BxS;cr31@>jlUPl%F)-OG3AKcdh{;S8!2>A9_{n}RHmr)un7&;8QNzxe4 zQz+~)fAE$ypcw|FX$QDGP$x>uaT*AIa;QQ(;VStB)$>Oe`@L}RSd{NA2Od5X5>n55 z$Q<>QV?lqo$Ig=A*kX>?(S~}hU3uq!aKiDn{D>l5 znK%xptu%=IfwZ&8L2&^1=;V@pm_FU$Ap=@q=U0&u9 zz)fW+Mp2PyBaay~I>OaZ4eWRVhrB*wvwmx5djH$5B#=*)Bo7J1P$Hy%W};@OD0Z44 zL}f4hSqd8zJ#6vDV9se%M87x2Nzvb2PtQ-E@&^Y){)X_|5q;mM$ylRymH!DfWalon zq*=|mKGjv)lv*=kO44+L5BB7ECykSz%&CX%A43z!2n$CH*y+U(*7Kk ze};sHpfx)BqFbypxkge#lJn{9NX^IK-1-Q1o0D&x8W&_V7rElH0RkR=(*Bt}6}%Dl?^TpImnmd^O-r#hT>Uy+H)A%90p z^l)t&_RP`jmqpi?D8*BlEd&GC5D2w7ZP$lp!^> zEr@%neK;mW6P@8$Xy7JG8|9pct7uwB;l6(#cc$Zgmpm+YveNtKT_%hE&;--|rwPhJ z`q_4liP_s&T|fJES2`qN(>+o^QfdkPwJ!C`8P`7|(r(;nMLFTpYZvnb|J2^U#xt9R zS;$~s^THy#U156q85D%Zow}ZUEHCiuX}3f|_qAn}nby8#V;m!uL&ddcck%$SH@CUlL#Ws5^J9} zGY8>y`x`|$Lx%fE(({VY6C5ZA0OzGBdL9hl4qRkL~mPo9ly)mN!2L$*`$)Rst=1!?%z(qLr^#B3 z)OFuz$}wZHmH#koYO(u9%7Pwa)NKl$75?N0B>6pDH`gn3oOCAFiGpZSIWxirI!HNPmuKKz6OK>Qw;D4L-@O@2#LkK|^kd=GfnZd9Y9_1xG1 z;Y5KFNQs}&GL{}+OFs8}S%(IY($RZi{k~zh?Z=cPXL{aUq131w<&2soZjoeBK5~~e z;rsPkV^+T39US4I0G-`@I8WL84T?2FY+BMOKQc^khjy5Od>zrgz0(*I5~C~m3stj- zJS@8Qt?7nB4$EdEAi^87lGfkfSWHtgV^n%>`rZFd$e%iD{C0Ay)kZSFxZ;teng9A$ zy@ag^*#nIVpGgD>_BQsmWWgfdkG!f+#i#FJgiE%C($GI$ypZ#Lp1gv6FvWaGv`cbV ziJD~yXtO;bM9b+)=oKJ(DfkaEi~J8V#|QzEd}buS)io4URcSj{O6|`DA2#~ z1`co$Wojkgo3{Ky(jnb{aV+hkK0WJzU~&OatQswlvcj(OuFcOEEL)=ddhFL&!tGO@ zn@BG}#=FF2GXX1V_8RkO{MXt_$F08)PEOhqp2a zQwCtT2X$j^e5Y`^sSCre4QEzjyGGVmh(oQ9bD|RCqhV{Q7Bspl(rdZT{^8T(*Pl(= z{tK;yKIaM1um3UC#~{&^#1D!zqr)P1{8z}NBKMbOe)eBInC_0TBVyC6QRrry29H1) zH)-$PCeuP=b_6K(P)Q zv6|sADvU%=M54rIcns|?s@`w3UbuUdL}}m&Vj!xaz#~Q2%}IhJTEXoi?6--ZSIq(#LZfKc@1yVqSiMb@#u2}CtVE^P{SaYK z7B)K?2Bo5|$4MTIQ~lFfLk+euLv5NK*;Das|ME6{UJ}l#Q4@fScUy%%|DKTQ)=~Pr zwX(9HtVpTW04oD=Ev!H(Ite%PGF#TeOa}kpMN-rEnleVfn0eWXu8&NX7HUi75%H6< zG?POrGfeac?Zw4Gsd(mChr?b;L`+N^J$N)Q!|2c?uLKVBNtkK8a&|>Zcs@L>Yx|hG4`(2{a?^wC`y!s)04^UJm zn(t-5iNDTQRvO+Jm!_F98WGUX_675i0!M>55J#+MZLgfPcFz%pp>57KY|_qRi~)u| z^}CxbV3Za3*NOVJ!dKJ)7J+^t)z~LU1B~S)m5I)l8r$~W2obgCd5AzLRUF6|Z?kLL zU)K1~^a=BYX`%QSSvQJ4$>Uh->*%4b<28jDwd*mOcv{lz8aTi@o|nF>yW7{1vQO81 zo%0e^1P_D3jbj}vX_H>KiJDQqoYpm+2 z9c_%))+Rp}++I$d*Vb(JQxG(WuX)HI z^j&`e4N;ztxZ(es3_f_5=z?@)8U<)`xQ_p^ok;(r9OT-at@AJnfi4(Nu%HlAQjheY@P2 zz|yUW$KW}SON(0I5%{cZmM~MMlnU&}3n#)Ri(~Aey28XdrX}yj7%~y-SRG#|eLMzR zztz2hVGIV7JX2m8zPZoTWHkP|)6ol6s#J7DzY;_sb@r${t&{FjyEnB)51PB=P z?=5XFks6M4dP~9~x?pZNmpeBp**+b-ijI{CT<2=L(0-3HoiCWmzSUT*D3l2QzbEZc|!XVW-?nF5+R2xVDY@cu6 zXO&b_cj_QGDr;kS&65}1nGk;Gp;*W8G>Cod_2aIT3!9TSp}RiP@(3agqlB@@aNG

YHPG*0IVnR}mwD>*>h*;nL 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%0D(XINZ0?%(yR1 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%0AVY!-5wszB(h$P2|?38NG zOJkl5cZZ4S)Vrz|PKcL_BTkF-n;uRAJ{dnZOo`i{hB##}JMiHlwL%=xv<*cDl>c&* zfP{yT{fl?+MqoaQWFDGHmX?+{Y@#v4}ks)9B z1bw?0TbwKg@Ho4HK1Qc`nJvMUILP`}k0llUCE+AB&R(yKqzBeVKuzeyif{A$ms)R* z#C~;eTPV8ZR$09XxD_un8EjmYM4GKfU4hm7*L_3&if2|9rfdN%Ic18=_f6bUBB-8m zf@o#F62gkGG3v(A?rAz`NHEA-Hn}4)oCV@w-q7Qj&h~ZpElr%^LhobcAnhk!CXkgS zuMt|FyL&&Zj=r$w=Rp^sVh;cT<&IG|Z)zM8J3)^{- z3SRh__XoF@Gaeln(}d+bra}Yf9Hg~yzaTXGALsx-N&iPdQ|)ziJ>IvS5V}G)@3)u) zARiygGkIh7Pggp?M}W8pVm#0XRv`BQj>kUaM&Ln+cdV20Y?A@?Gi8;&$LI_~K1QiT zUJze}?*3+WH>6eWV)b+WV>7{tcKC5F;mchWW%JSR)^IgYW5{CO1c8qD#3RNYRw$Ik z?#|4W{UKxWJVcmIi?VM(f&KF)SVRpF)IqSbYeVY96BD;@E|)?Y^JizUNB}Tj)PO*V z=|8*oqac&N?O4i0?*5_ZZhLg=&qw5cAai#3-?X;T?*7~Wnt~NP?}~0k;GBO{)%<0l zlr-BCmF$>*bUg0S%qo;g^o{&5kGeS<7!_0nM_rVSMndw5_^rDZ5&Us`FQ-9n9*FAd z`IS-x6Zm2Ie)##Ql(psfSk*m+L65f+uWAoiGENW6 z#y^>mJnfd1siB8yNDbJLHigE138JTPaOM3X1wjNsngo>#$G{kZ#7UnZW3ZKIjA-6X z)Ax*vfdUnKw+LD~)RqhakPDRC1_34K*hgT5r<|}yV`Mt_kdF!;`v2v4Btqdfl!FG(;-jH$x zRhYi0HLM+N09l1FWJ13Lc9PhMJ@j!l_|iKQ6;ABl5O8-8_nAbj!P92zX#z9`wL!Sm ziK5=-oiTYXmo&*@sS`rl0<17Q3vvU6>*L&p-7b9&;B>4#trQUoKnZwQ8P*(Kc4I?D zpC`qYN7NfjoW$zSEgcD~wj8uj4GU9O)*_x)jP{uR`G$7F!PWAGf9W2)dwa0#1vNE{ zi5Pbk;M%%^3q_R2ey?OHw2C|=D1+F&?Arl3W(kg+W)WpS>iKJ?t+rgOEZ!`db;D=z z4j;cj#XaIbP|;QM{(Fh<2O4}}Bq%JE&wULm7Ke0fLKCEs@TVwWP_9(IU5z( zX#ouoa9`+I?g6U`X#@nEC{@Umw+F%eXnn?nyn>GK2Tz3x(YPr8xR~;^|6s_&HkAI5 zqII`%vC?jMf#VOB(8Usiy0Lx1{b?PU^Yf?Ajnk+W@E3KM{s#G@0ej9aBuSG~AurDpkR^Vx(q zxci)SiQzj3s&m%rS+M=y5%hApRI7=gDO@clj{4tT{iCJBdeHn^Fc9!iq|UrlpXIbv=Rncl9=W|YVf7gHNa6TEvx-Yb+~*B)BbWLRmnhhzDwL5nOeUC z=PeY>9*r?UAyFvik`x59C}b|g-euTSE!k@IuJfij*p?6Iqy)JIupguyc7gf;KlFi9 zVMsuT3+bDD5aw&<)RcNaP3R006O--w2eFCcT4l=>A zuGlU+Gf%y|EvYwv(P6N&*&+ju8};f%?nzW9VD?A82{RynFAK49L;+{;5EW{h`EVx~ zJ6*QjSpjL*moWi;v((qx#@h3x>V5NUP24)5^JDQoFXo;}dY)3c4MxpKfkn(fj;s|j zLA2CKahP{V+Rx!rS?#YZj&fM?i$m(ULRBo^X-6nIA)X3GhIOU&y4Pd>J~fWG=_OF> zV&HzDkL`YSwD8tnU-_?vU^Qksb(k|(L(WW#@T+m~S<^QsaJKK$(V{x=3V)};uh10( zXPeN~kCp#ug~+gS6e1j{X<80=TW|RmIl0b1{99-yw);Sk%t$R!L(t;K@;Qu30E-C@ zwFLv${n{W&?<%Yk?ix&pCj&F3Erpssj3ElM9q41J`K-) zI_W#SfMgObP@s`-;Fr(uELeQzA)2tVZAc}{w>z@Kc#mQ9=+s?X8{TFoh*LVw7X~TX znfjS1EYrk`sNg$@;9ErSG2mt(s#y~qZ`!lLsbsmYLwcNZ(xuE%(ilPBee>&5gUo!ZsXLG;3FDL<=WK)~rC=mQ z`)WAHonz+B)8yHCyMYzcjD)G~Lj>Y(KMCzG;GQUa13;(;vCv+ho*`uWGsVvBzYGHG zPEZ+aDm-6IJDE8h20n2v+qjgN60wVD_q;ojaIMSz6Wbk<*H2-8!D}2j?Z>7h4cmjQ z;g$fT*$pSNL#PS=wTe`yZKvNtiY5;$ufmF%5X0)J z-@+!h;Aa76!H;?j5jpAfNl1Cb61Ehg+#6hWaFh!I~#AIa!ZA9MSmTTD3x?5YFl%+HK&FPV$e%79tiPdth|X2LQ}}!P$~@smX>C z{2;*81^fn^`kqh&F`|2RBx&m71qp_^lT0O#);o9*%3749wzxRA`N7HWOk#TxxqCQv zNfXtL75?7~?pE?wH=XBib&g_YJj<7eb$`0+^4{BaUF)wmu&Ew zlv#ERk2;F52aJZ`gQsW7=nY36CQ)<8LD`FJbW_M*2r`OVcPXD~sEcv&0#< zC4-!&-afk5(NS;LtvffbiX_N*Zx&j?Z^t=I{xPvx$^cFenI7yVo?HEHD3fh#*dJUqitwtLwrXwK4KC|LlKSR?VuXjLy`)qGgpV zhy<`=J3P@U)<#Gc<&KHD#0M!*O^xJar7cc+;H+ei+2?C@RBEnUz1OV)JCCy7vzL;( zJ{qFBU5RwOUY)ex_eCnEx6Imb=K$C6CX&~Bkk2B3zfa!L0atk8pVu(6!l)>_lz}-} z^1H{!7ElUbU2P%4`@e(z&iG?`gDojC(PC818wztVa2RdcFGgBI)XJQ%SH;;r`~Rc7 zD}Vn7GY#S1A1i7U;8`t~A~jvAx&k~MO|us-%<|^UJm7bVKiM}7so^)B;QP7Pj;QYf zut;!0Xr{s)161{5aL{8wzjJj1A-$w-OA>GqEauSoP^+(BCd1>9P*32ATy5XPr8^A~ zA-n4H>}En0tCJ0=5QVMDPaFi!j;REA{0aFCxr-T*{l-Q`29xLRLnK;7bcIknTNgMX z=0yD)YaOS9Q^5+Rp_DdVXXVVwW*-01+ra6)GRw;>2M=k~GU#z}Vz6}yxlhtCV4w{} zl&mRLo!6)~Y6G6AWg0x1%b_^D5UUlUACO%F9xp2R8#2S5@G46Xo(m(*jCn>rAPI+~ z73%27#?8XP2*#?05Cm65w6SWLd$(`Zp%A!SAQ!%9Uug2{0BZIF3@ZBx-_fNx7$VkqFF&cXv zz*U1<+MCH(@nIH77sQ?#+O;bTNaC_`!1B##0eJCL+0Isa!QTBqkUm!(osVI~Y= zLX`l!$QE?+pnVQHvd(x5{aTxb|67{~Tj-xftvSJ|KiVt{28`HV>CSB|+--ay!%)`s zg`)RvaXaqELAs}~7-!)@aB!xY^C3ubix4EC88*7f>Ih}Ze=owh%7x4$_+P^+!nXb# zT@rUXwk@@3={_zrRh&tA__GB)j+ZPjNZ48~Cqw-7Jt4GC2;OxX76;^4>vE6N_WQ}2 zJ~-ljD2*YLGs`xMf(yQ~7w4*!%17LdzMRDVqZGe3{E(* zd0Xm9rIkRCogDwF1zl1;GBXk_YpHTkvY`Jmc!3z@ zh2QfOyofin0<%?A45Ns`P_QPB#FNrx(O`MY^19XKr$k<7M_H@4-u;ZbhRbY_DK~(2u%lCo5M8c*s?XsUIQh-Fr$78qqXoTTuJziRHpK0Ex zy5+St^!PsLQbh;aX!!bcHtXIx|4HtBek&XF&p+M6Os2bfGioTtoBYpJ-Sl4$@~jbStm;t_GPBdvy!=~i-Ox$hg09@NO)O^kfY~6g>ap(y zckvr{D-N*_VujRqn)4r?Z*SJKv;yask-LgGRbqY)uvSerBG-Vbp9Fbd(3H+{+ zc>5P9TQhvqr<7EY7b`|;P3RbO>ehWR$kgfxTM!l#{z*2@)q+`UeBC+t5E>xidG6DE z!cESOW=#-pK_+p_!O{Fll~^2&P+h|L|BMRymXnc> z7B$V&^XvXxcXe#|?`|Q!IldO1Vx$oguM5)QhqOTS>EfQ6J#vRg!kk? zjHq&`cK_OaggW(W5L5$fhsSbGOiTi~pDgb|Eg7)hkG2f!3b5BrC~~r;GO+Jd+pEE! zlnXM)y_10`Dn!iD0Ri~R%RRT0P*k^$efK$;ak{mfF+U8-i)HFMuo71@IJcf>`1Lma zv&FVFqIK_sT6JFd^OP6BX0VtbZhE3VWn6}^Ws{z87wjI#nC8P0+dkVATcLU`1> zv#=jf%DO+@|##}P0!>_UK|Cb^OrfqagI`+qc@19u%|gTwV<$hmM1O^wU3!ly zZetL&n4)Xa8#?^4-glVwiGObC+OOs^WLSku}%lIGgxeFo^dJccyAIH&m|R8B^yHrDiJpCLU+kW5qmaFZfM__E-jaHqrJ0k5BsIG(yRl$njV~qk<>Oy;(e~kA2VUhBaVOv0 zi}HMoD?cw?WP57*`^v>RCjxCl%e=5JIkn)<(q)RAXT8PGSkgRVXg`e_Fl!x52N21G z5DMdK4^HIW?_jtI4!|N<7(J8trp`8CvP9o`|G33(dk(v`$m-oze5H#M&NgxDJ5nm- zoFl|9$U#vq=r@~2n>UU~)ksn0x5BSV=p z>6ZJ#l{1h^ml`b_>K0cPb&PoN2+L6e%L!-5E6WB?*l)MJjd^lsynUA)Sq8P^3AzC8B9HTXf8-SMgr8p zrm^<*nc5LHHbrV>EyP!p6>Y#wf3V{80!MMX^D7ny!XCpXBE6=H**D(L=8l?bzTYKl z?LqSZCGNJ)X%{zjs|LODMX`yyfHG9zCGt{8J=O`X3h3|scT95u)7JnQS~IGY_2N;= z`%sZD!@6!esyryC%(XSD`J zxxWI&j3nMgh|_lu{ZR)eff_s#ieA+#Le-yT!3eF~KoLofonFV~?P`h;zoGPW>C$3x zF6IZG`8kypg4XI2G3c@`Uc-rFfMBqMCWxy=04lBFQG}e}UkGV+48@|Tey1Gc(6Ays z#TE2P%ZeZKuyes;EgTR>x=?kFF2-f+mk>*`-}oApm}X#dLPDttRw)%apT&@P*$nytH$SsVPBzY_)Nr_%V35A`}pfuESZic}g2g8!# z>3SdzNPYMu1{uv5CU1B4B51^Jj8+wlEK^>#8!@&tB+cFYBRBhW`E$r1ESqKMSvdJmECNK~i!2Nb-~ zq(R_Eq^2Sihs8{m)-MWvwgsz4|*f zO|qKO8uC%c6M#C4K?0>H;+T8^U5bUUD$0YxBALJ(#7a!4pMyWLlwHAYUesh|r6u~K z>{LYU!qy#so7P#9%-tbx{LEmI(gzF3NjCm>IX}~v@m|nGc)fQ$SGU4gDk1(f>fgu4 zMvw;G&uGv9@F4iC45mc|=^5}V52`SgLU_Vs<2IBcV?>@p2f|~kteEQeiw7)Yi^$&{ zRSug1r1N^?(!SP_NpMc<15hIKw4p&TdXIJC@tj^zxa|+C96g_5qE8Bhe8LDK8gJ&m zN`IzJ%&~vG-bb#r_n$o)2^gn593=X?n}nTRfM>wN3;$BT34r=0{6kFQz87myz;(+E z?H5L#dU@c)!xO>FbpM{W8gkd&^eF$TCMA#%4K1;L!2$A!*NQcCi6{Ygi?N1BFrRk& z!)yiS5>1+&nA3N(PnX41=DD8+H+>$*hQ5()NyXFSbGiET)2S_{?Rc>3C97}>KP$^i z`n&FR>)V8G0l%aq9_oG&6QCX%WEhA_?lYxo zW)gc-n9#eIZ|GO87U||{wa~D((ZcR!;O&4F$kH|cuSfr*_pe7s)Jk}d`#gj8xTJ+! zXn2@#5FbMN2?;av{KHRa45T4&f~_PirBN|x$%V`yFOBZ?%h}$)$P->N4h8tzAx$o~ zLKMpM2h*CTCTg~}W~TIkdBUK><+)f~ru5U<+q^-s;bb@WB-NmfRY6XZkj9oo1=fw^ zKJrJ=V&1v2s^No*+P5-5Q((;EwT~wD2o90h-wT^ zrAP|N&-q~a*?gtH7!z}C;xOw*(LzD{%6Z2#clN51_B=h6vS}4Dhh%(S|F5_c3pKD@FNZZPm!7gCT9~R)Or;OTjsYIuY*#CVGVw*wqYo17zk!CU!&-^b^wMQWf4%dqg8D^zVbP z5fjwDsWnW7Gm)yOx0qMG-`zD>g$q1k-&d>Rw#7O~P7v37`meF-WNo{wdOmTNOH6v( z+4p_z?2z15R@fn_eAOuhbvspZK`la5=bD%XRcZAp8yo)vAxX=}t?*2-MVN{+%VQJ? zH`z0(c@FTD!t+5WQVLlp9k{~{U2xzBzCnF)sb345s^mrJc-y?q%W7Lwg>I+C7TsLt z;^(39MeIL*?Hf}2F!dJAw2VJ9Ckn_g>gkJ%(FxSmkI`Y|6=*2l(1cGsuzwEE&r`l> z@jC#`^O4I3NWbI{3+2Fsc0I)qzvp<<=MiA201s_%?Jvd_z_WFow7FIY zJohRjgq6{C?P|cmxd4RaP7QiW+A23+3WGGehl{~}y@AdE(`A_M%b@>+iFI0vhi6)u z&K3mGn!!~G>G%&&B;n>O*UFGrtd4Z)M$eSHd_e&xDa_Yf93c?Od(h*I`tjUwQUpiF zZ+U6p**VYhSQ(M^P6RwytSQvxy*C4gITvjXHg2Z^a>OebZB$*uJfPhF2y1#r)iX|> zfs~&=xi5RqpM4n<^Ijz@Z?Qp%Y2l_NqeHqAHrsO;jOc=8W9 zkoNe5z^pzpDRnqe{wpn?0X4PuuystvFcprx-9M1^0@EGNWu^(#sxlm1ot>MsZT?Su ztZl4i>EHakJ-OAJ_5Wx%vkPN)=Vh?wio{-^j$jdAh@&`6#uai4EKrv4_r#IZ#m3$G z1;9D@JNQ&)O(;Cp#=UTPxH@H!bDzeh0BE#yrGJ@HrYca~nT zszE^jD`d&&6afJvLx9g72tgsUqwHu=DTUV3_TSRuEXhR7fl>zbVM6kmO!&=W+s@h5 z>3UU3nEBlMSpE3w`5gz4sk8I`F7mr#!ERq7{E}dwMO}O*Rh3K}A|Rlfg1E%3!ifT* zqoHR}m-E&lvKk@(ITI>5nlLS+la?(vlROtbQD!tlDN4I%QMNS1fPCI;XwDBFa1B_F z97Eo`-xq0PQNDr~{Q3LBNpb~EdQ7+Ng0znR&qvkzW$T>{@AUiOPU=XTmhMAaC_74) zDVGR?IdKZD|E+Dg!qOMO2tJ5%W{Q&^Apjga?f~n2P3bW3fDc3S*uZS~wKpn%KAea5 z#tQ{o=N`=|S1saxq+y|8?P~JqFL0xw__ln4^zYn%gU(l2?V%;8W$*G0-iB(vH3?KS zpCYy%bPjXOi*=p9Wh@CfXzg8wzUWp-QZQz`CCwFTDyW|XU@NKZ9X;9M^{JAGI^X>l|#kr01Jhc-S_08=jgX7!Skvxp3zfDL6yx72ZvRN zN@Q*McSTphx6xApt%yNfVb>x;waSzM3iK(q=9Mjqa&utLk=RbVw;#55jmgCE*f~V3FhL#GAM@nNuLiNP; z!j(u|w+lTUGRKQtZ|7j@@pynRfRHn0oQ1T4>L0o#&V!PJpu_n0Cf@xn!tvz zt`)#M6{feJ`jBX|c{{Vx_KPSF-Q!%ob2GZeA_lmkkJ9aG+y3|U9N{j$sYx?;OI|ov zVjEhN-_RCtny8j^?SWQu)2+lehXreP2sRv_&Q7W9WuOW^J8+vV8?f^Y>AL|&o&bs@ z8#pCZZxwMXnAC)f>BsI$gPe07`&|&yxbq+NW599T^atbC zmtOoiC=>QL7Bsnd5K0R-yIoKP!cz9O`}yNT1@gToVJY$RHo-hhOL~D7mkee>D8O3W zDwK5t+$N^ed3X&Ymga=4rqf;i;0tiqv+`T^ADtC;M(xuAz7p_4UW+IF01=IF=uj5* z-yQzM_eXSu&@?e9URc=#0i!<6gP?$-sF`w zTAhIPt<8|f>|Dg_MaRq_`4N$lED+#Sx!{o+y7vRjSo|7tah!rtl)tI(<>5*2D~59V z0XQI(-ynW_RF-W7J|uCnm0&n510VljVICZar%g?=7G2^MP!Aku0pQ_cy>>f7XR&)n zT6j1xq0cbtkd2RIj#>)pKZ34thkCheJxMbJ)Hso3#j!FTH=`qeIdnF3K92U>cs?1U z2V7`BJbX|S0o0ntZ)F{n0~;#Q@g=1l8~O=@lbKU$jA6-qMv!qaRVXndP9B>Q!t?Sy zmvp23KIIWK+iU#jT(B(UoQf)D&;?%Yg91YI^SN79!vDo3gr9^7yMQPCn6)3SXV; zIC@}zZ#3@)@lCb~WclZIsoW1}dXxba4lLR4B~+4mBm%mFI0tx9i2E#ZZG+89#p&si zvu9K5e7(ahnO)MiG~=WU|1Da}Z)O5E}emoZ)M zD}cYw;nfQ&H~ z6Bwy?Y%FGv!?-|#D2Y~NC1JIOlYw0e>+#fi#}iyz*K=0}w}-2Gw(HZOBa4Jii|dr3 zt1BD@8>&H&gb1@g?Mguci4aPn>;-LONmPckN)I+G zoAE>7LgZ$eg-j>a&$q3`pXj~b^jYdJi@qZ#Yi2~d%Xj$#SkxXe-aQn>RwWtV(06c1 zJYO(k2Y%OH8Gepo!Q5&Iv8LV>MK;Qm=?1^_+^A6x#$NuHt|e@YR9b`cYZRRk&Y6{^(H z{KBWIZ9E^B#fQ8PxpZssJ>QZod_0a~8@~FS!Ka4|d&$vK&{*q$hq_=9VlPWJN?F?= zA~&RW&Pf0ZFldPLuLb}UR)B~(lBiZ8DprPFCH8?8{T(ctGEy(S-0`$MtO547QT|j_ zWrJquYBJpC#qmXguGW{1jQ>wZE>E_(r~B-C%Am&J`W*ip>@-Vv<5!qpo5*p>OC?wG zs)$tR^|PG}J&nmqr4k_(4mL&tboOY%b+NRg$S%O(6^6yLnx&QGg*q1Ed@oL-yB{)7 z{P4cum$yEDJl4@^7P_uZx6b0#;8(DE>WN{++xM3u&X-9Q{4&h@_0uP4NVjtVr;9n1 z1%xL^e<3GEh73ZBT6lQM4AE<;Yr&u$4pTaOW`DEWP5kd@nHLR$BGf`V?e3iPG zitoGd7LM*RxzU6wHolZev>yS~kWB>gR>_Dacp%I)A{z&ZUj_EV8$>pv5^y;Rz)4P= z(0Yf?la@t63s5caF|s)0(Wg(JKIVsaTyon0A3}l6A2gG?srU@9+*h5`Ef3uu?F(|5 zCLa@QWH^=yHu{7?)41Kn994xl5e?Lqe_BKDBrRGOO}8zU<#~hJsknGoY-+NGs%QDc z)CfKR_5KC+@sKE;t@T?(w(#dQIa-C_+0SGe8>gSIGZZ+{`{j+?B-g$9xJO8`i$~m6 z!@$3y;o@1)JRzLfAz%E)A=0R#XrY56%&~ESgAr&cVh}qrn4ZiKZIW?pi4WA|ZW$;a z*YIahP~yQQ1P@d~VXNQ6A7G`G4K_L){|xr`(rg z%!Qa1bLu>Oh2H{l@ktvLf-C8Lg&D_GGvTJ&H!Q(BsSYHM6Hjk17tX~=tUTWWKYML= zPnytd$5_}{{M%U~ZaGHp$l-}x&vBp4+pQ_DOP3pP#onEj1xfaT=H9JGK!%T0YMM7z z!3&e_mVKDeZZnjN5=U;i06s4j^d=TN(bf=0=}3h*Or@i2A~bw#jL(X&{RvK74D%7# z0G0MI+x-JS^BaAYwGh7I2_SM6#@?NQYRwo*TS|p zKr>rmvy$SpiN7#cI|;ZV2HyEnRkzjbY7IpCUMl4tsj+(p-PrY}NUu*bFPwYE^QENlnum045v^N*QKc_aB_&B#DRk&=m^m!KW z(zf3r$NFOZ;!>lay0(Y8NhG$fb0%4Af!~@!b(e_O5hMM! ztqNGTB}A>93d`n87ZNKkyALNe@SFT^;YLXr-2TNA@|%j$BO(1lnmDW^t9_^Ht+p9F2lr!I(ZF*F+EguIMcV<5=1Y z8cuF|9vUIi;-9a{#&nmX0I%rq&sEQf*H^u>_1IAVhJtKr69yBuUml{`a=WC4F#1$d zV*N8XL;2&*B9~Gx+wFxcOd235BN!xSr>gkb9Nu1M6#QM)S!UlPFIY&tgBrG(ZMqZ` z17}Ak9MEoF)0RR($vHt4u;*e@5bjf_XX)*(%8sU(x?W_d2P06i?HI=fCYq`1Ls5F&OGm4oat zGL}dkriAEVH$@|#$n+pNJ__T$b@o?O8eMo9i3k< zKb<`-r}%MVt9w{mB5gk3R~($rxA>bDpd1Ky-&OScsj_n8T&4|~ir!DBR5xTwiM9x7`oI#-#L(=*7hdsk6`T|jxg;Bw7A4HRkIQqn)ppRqp zh`xpOY0jcHP8NDgfD0(pC~+ko5~qox!;P>}Wg{do3dMH^3r&9+aQ2duur#Zdd0n3N z#21@9gx`zb=&MwTn-?PB3bOL1r+gQmRJa;GZuAG zOVOOHP0x2*Xk59_>USKz{J#eg2uV3@5)~~aeUpNSf?fsAn2|GfhU+a24*mvOE&B6e zKocFp-G#xGyI^C)ad9}T}U!_HWLMdr!TlMl(1 zQh5-l%nY8W|H9DGQa5+bTPe+cskqc_$*if{gc<0fN?eSRkzcM%az^xbXHa1fzQ<`) z%;2*RvLQl;RO8SfH-MgygHt>ltgC|%HEY!4@EQafQ58tn&!BkZz(J%hj#9gw2q`bL zDi{7H#oMB(Sxv?QZoQ<31CMeC&-riVzGr4Nq3E}OhZS!c0LIA(dNoNN=*Y1u?qn9f zgMao4niD{fK)z-0%-x3z-EV$FdgNdIUHW7Tp^z9So2QO3Rb|EZ=lo`nzYX=tiMMme zmbUAwgwe0N+5%j)R!Ft%y6_y4;Vrfk>9;v!qdrNX<0fK8h zD;0eS+02@ZyW1y8#OWgUv9-*?BXStHXi{Rq5B1fJvy&ZPvxr;iYNpW^UK}{QB)=q@I`2zeqxa(!ZC8vht9w zJsJ4CH`d@){ju?KU%y*UPP7FjTR zvZn+oWkteVaVieVK}IZ?7j1U_B$NOhqv<$?)>Q13-Va{dI{)W^QpF^Da(dt}!H0Bq z<|sNa>PR3Q|)L3y8w>?NNVqE|v6qNfU422VB2G+}Q* zsixpp9;Fd9F*qm&B{r^J3nC6|zt{pSs?)sVa|LlQ2O;|SJ&l=GN~M|5 z#KP#UoBk}QM)!`FvrcF5&d8udqKA`XeFPcX_mWiQd$>8V@XD+ib1h4x;Bw3#H z>b1*|FaMr~`_6U$v#kGoaVQw1oF$52X) zPzoqeO-N<$JLtp^5GmeS=N@E+MXl>PsWe3Mz zvxyGdZkn}|{CX$wT8sNZayNwm>~8!8i?jZ30)^0q=&v-m-qzjoh##TjtcnFEAh3V^ z!VXoym%|n4zZOinnoShfy@?540NpP@dOyd^AC8~3)=wZdq#=o;4B#J55*h9T_wZ6N zJc6!S9Ixk?=td!>GsunclyD%-sow>UNWegAc^Bg4?KPJ%SB!}44CI7e<@XeW?hn(WNKm@r1LO^>GcP2m>x-Dj)BO_@< zM(M1q@3{x=mnVSMuRL$C!l@x1FwTbHQCoztX8Iq2xW+?Bh5j**uhfmPrXU=S_modW z#~!r;`<7yLNDff?H}TXWll+C+qT+tIp+9JmTbD)j*nI_1?S{UwL5R=+A^|;>$hU>6 z`DODXhhpBW7X;%JKyLfDeXK-ry@G!)iZ%SO(a#(1XPPdjT*r`#jiA6feVNnoKY9Ud3nR2k73@3ND0y>7+d=)MKp>LjKVi zS}n2HvW2^V$f{0<($-kt}EZ}NH$3($?dNrsUt zX4goP2wwu#!jA0{U>6WMLzu5Fk8NqLx%7IkLkrAo8}quRd7Fdb4Fh)m46o+rVP9N_ zFN^0ApxTdQRD>T~)+a`afT@+*kHkjr6V%H12tv2B{~WNM%)vpita*ma*{(lUT#IxX;9^Ksxp2T?DkGOcrs{!=rU9JL=M!w6Z~G_H!a zB}2mP(}!{fAiRcik*=|as+X*n+rJNQm(`!qp{ii4gxr&5t^?~)34O4MTIs&e4xdQ$(GHa`Tck@;!W&z^Ov{|DihZ@_|G9vN{<${K z>=vqTc)fSCl=nWO_?-1DZJf5vw&2ODaz3aw$hhu2Y*AyxX&Jg=VpXQcP}p5l)%qsK4PWpiCFQB^Kj zvWvqo5YQNz?tYk&x#6jYq&F7u)-M@o3M~5M4N3gXyi=@5#3{#|ADCG9lYjac^=kN9 z{zO~ZwR0?EWv2XcnPX#_3zz^mCBi=Tx2Ptq7g4{OlNd*~v|e@|as1#A6Urg(^C`lZ zQ#kpv6ByAk>)(IYob#^2%XE~K{vntlxAD-w`N;a&dYKmO{kf9}WifDy7GD@=LDBmF z@;GF&;5G<@)2c2|8?A#`@-U1Ocf$Xv&w<*HH1+z@oGJUkwriB|mitCP}+R%A0{e`KwC{pkfj>sgSn%cgG9#4U; z6TCo&>hiyWDUsHqey*ySkzC+ssGn&Q4qi6dwH!>{u4g{bizlgAVf(qBgEnZlseSpq zOIpZ9Tj%i0EK`YJ6&c<1MvZUi_O4};=4pQ`e~k$VrEpH6p{vov0V<+MqTDUoB#@xh z?SsasKa;W@;Cmc<{;~fA@(Mbxy9{QUx8^0Gtw^IxFMc?_I;b~5A-?&J%ZybuHZWqt z6)iyEYeDd(i`Y&iZ@;$sP+W2HCDqeZ;}N81+i$+{(U79p7?1=NS3Kvz*=65qNh}?A z(g>{|qi}35^lXYaA$@nkS)!>nu!<>DhQd+pV*OhhZdGCaeT{#u-89ax^{ne<_uU~M z^JVL${D=-OYneTnoDbi{W?(Af16A^O$)9l^qho71P>8~_@2;4=Kg2%qd|)~=B}PGyZb0Vy9==2ug@{-z$1qwj~c^r*P$drVDEL5@s7eRmmg!l+e=faIra9n-itt&n*ca@rWS~%^_ ze?~`Ebe|yAVsd{p$n#_7^%bwtFz+q6waJp`U1O$s`<)7oK{>c)jbTo+1$q)$f#9uK z`og73zXRz|AZqi47{F7ML?CmjWlSWrf|w9N>d<|9rlUyYC>uu?zB;*v_C*`f%=FLM zp~vjClbZ2r`hWLf=3mMYd!u2)&iS!)Nw=ipW$^^J7*YPqeTdjTN6sYCp5hfAgyUcx zoW!{^pQItja+6QMd|2+`=s#HO=#v(&#wE4WM0zN{Zy-zucu3ljkXMx+xPL!{IIHYB z%hct$zQD)I=<0^Z4f)7)pQ7L7XtOC>y1A|t+KHZp@DC5!qoWviIGt6*?m_!8rXIF~ z7l#gJCc>f_^Y%PXg`esJDYYU}#$r6tl4#tdfhQ9!bOY=VLfkZ^(f-w9b>(Q4k1u4i zEgVovN`(4FuDwHJg#3fIr)LxUzLV|b(@ds5u-s_)7g`|@$9p!^*Mmm~<|%Nvd7h#S zXQ)hT=J8$0LA$(^_e&Om^7f^*(|$iyu={vv$;csvY6$l}g z?P90jWs4I5uH(2leTA9;kjGYoF<4@lcM_R`(!~Syo2j-lD}%E`4}&n$ZEe1-T;gSQ zSaL$Sw{VR2ey)nA@O~bSB~8cG_tBWlbtK(QWAHL7t6>*@_=)#%j0$xH-7Hw`V?|)+ zg?y!cb$vy0f?ba@jR|26y7~5nAXb=v-G9A3oDerIy8n#_Qm%CX#If?R@;Ya}g0pMc zYPEH2XInT8oQJ|U`t;)PFe(n&tQ;eHANOUB$25y}C@i#^Fs)lluP=eDZYf78_kkEbKI6`)V!YGuIB8`XR-6Jw$w~$j_Ur~p!9WB%2EH74%ObY zu9NI2!2BnB+k|rPP&A8AwrmF26{=fn_lYKBitq^8?)bhV$+vu?sJWo~W9i-Y68Ra9 zVQT6EFF!!cp%38Wx!~vK&u~$1I}0i~;oC)LuY+zAF*B#EcK+qp*}%Zx?nlMk_)uds z&6BuH?=UJ;McDg4lwhT)j_N_9d6_9D$>-7ecE=;UiAiRkc*DHo6Nd73PZGoZ-4rA1 zaX#jr0!o;^yi#MGsk?_k%pJZ=cKw3Lw>;6^#srE9PjE5Wh8006iqA2*niO`+@MWll%2fb`JrH{Ce@~!;TCWmy=7k($5F622-|1 zR3j2NUQztKn7=O&(GU`!Gwqtj?@DB#yCe#>h}YM!NiTlWn_+vL*_?- z=hM*eidz5NpVl1>b}qG;HV6r4+!7iA;aM?~%=p4bcXR*uQL%5>t2mNN!x@;pIfruX5VeJ%dxd0r#}A4 zVs6#I%Jj!^{L?=fxX^|uX4ce2!Au(kP@YLBCUf)&yy!zDuZ-m{vEpbX-y-T*SmBEF z@&ui}g!bx+f||&u_kNq`YVAn(6_~4+Naw1^H9vk&J&)Se6St-VRdff9srBj6_hkV& z9tcmoT?KN%z+oZOTnO%UO1S;N87jyT6$%P92v3Z{4pDg`g~4?wZMxO3-Ss!6frp1k zqRR2EH8mfZ*YADd)fOt|TPY=p3xnpT2r{e8n-H}83X94MNO9pDXv;+X!0R3}gc0QX z(QEurj*BrC(Eyi#{w+63w%MI%w=L?2NfxO`F^ ztP`%dB~nO&-32ZY!chp}Jb4kJ8Kmwa3}8U?EUPtf6I#gvGw=4cgn=J=WO_RYgVqZIHoz4 zZ6){Lyz~IC;ved4!{X{EQAHa(upnwvOWB_z>2HB`Ou%O^Y&NT${k6DONSzy)};szRU8bY}#ZY zyNluRh>deDEXYwe>6;?T@A!f42rc*!*`iFN@#x#4+-XJjD?cQD)fD7dp?mpNMigN& z$%Mnsb5o9$ODzOBl+#G_@_O#e%AZ_X{ON0Z_t$Z8G19FFuRhNt2v4I8u>i5_p+%UG zHh5*6MpK7l!L;q|E?9F(a|*vDIr>;WlG!hG=_l@;GcFMi!->8Bc6F~l(p(ePIMr@@ zIUfDK=qudD6VY~h8_i#T`&BZFz3dwj$r;nQ#WV&oR%iFnJ^mt3JCGg*d0BvN8Y4)c zqq}Y$BiYs=+Xs0+NJS@-hV-l`ThMstyv})Z7Vn%#U4Uo|h8M3LA*qzRx0+ptQf?S} z2`qm%*bWZH5AjcECSE}vZG^3jQQA-@^EvW`B3A?xgd&Ua_z3p%f`^?Z6<9N}C9a8x zkrynEi2}r`rza*RcJPydVWS39B@|;y_0#CqhG+JPeHd zM)7_&4{NbC-}8k}{cK;9e>?s7Ax8pWy2dX|k*pEk6)UGu1ZyX2qzP?BB+kQQpU8>1 zFO&du%N}ZH8U)b^`c`RZ+6_JJ4;vZT$#Ftgt*6iXi9?S)e)ku7-qw2O7%`K_i_{QJ zTrjKwh;v;^7A0$6wrdW!kPV~J?1`J^8|HZaG=J1v`pcU<9QpwRa%4JL#$Zu- z3n{g;qe;4o^@nr^=IW8dasGWJ@F;%Ao$dGM^ASJR)-{A}UFFce+F0Qnv=@Bg3pQ*A zWbUL&#x|bkI3>IwT285{R9|hqvC>2j!xq zfKVmCK5NmFaumc4jhjE8_9d|P78DNIl|NiASK_@cQA81$0HZ#sn>3EUFEsW9^soM~ zF{mfmiXnJ8G$~v*Bt|+i4o1GNnRWfo-|qKA*(J0WQ6YyvQ~xbzeE9EU(NS@4;gJoz z8OpA%Y&`!`?Gfb)safgnR)n5-865H-c}kneED&Kn@-(w0??G#Z1fzYW*(=>=8kV+g z@`SsS^+SH^w;zIufXS3#gqyW>DK1}UcHKl(j{^@aJkEYT>}^YX*Cb|evUS*uBFN0b zA?7Xo&>5BjS0^!@rIFXl!P|PWoQ7kgW^eNhwz{QY1#aN{xMo++>ry0~*w_NdC zboYL)Yd9!(Q(oGh2)>pM5i5lY&(wUn3Z=6qM-&Q)6^+!({5gs9Rm#DB$Vo$tNB^SJ zxa9X!VzQ#*3YNz2y^mE<5Ed2XG$xif=y)pRkh1tq&DA!m(Y06lI?vktq5T$HOrVYX zlbVa4mrw2FApuTk7n2z-w_V{Z95U}bMA2H2+AS&kJxzW5*d-+e;EqxQ6t>!C%4XPR zzIKBmtNv+c=}}|28*Y(qVYS7Z!OfzTPJ6$;rSJr>fFGds8`{S0pFQ| zyeU%5A@m)J3~HXJ6ez(4?eTm6Zyf;Wk2AesU7>%k(Pe|RmJYnLk{|yJw{S3dDeg>z z_IUGM*y=KJ+ogA!MP3Cnc%=XJg7W>C$R)gMMk-~T?a{j;18tMEB_@jS4g4H41Qd}8 zjzQ>zDb$*0t};WO+-|yYoL{ z@K>yd^}kq;v19ez)91fX&m2p;$M3yqNBUex!Z?T5X@rmCfK#Na@AGd0$4V8FEGLB} zsJ&U&Rl+_eXbWwzOQhW5Cs!)hm`H!3>SJeQ6RFKB+pHI%G`;ses|m47cY(@ZGcr3| ze}|@V0`vP0SjnYttu_}=*n%kg!*jb5f)ZD$`pxOi}~GUu6Hp#V9P4?S>SIfez6 z*;VD90R@U(rG5~Clg`lYmC`&!_!o7Y2{cBUL}*cllyb4O=!N-r+)RrqxJ6D^04bvyjG?&82xuhgaA_H3znLN1bZ}$iXrF{e-pRwL;^R>AL z$t;9*l6u#p`k~CxP!=B8&&XIZU)(2-w)z@XIh;4deU(V7s^taO6~gwnak%RAWpCwz zhAjzg=+RXPcgngOZ^Pdj_rZzZw;-noCtqi-MXE#KbQstwF%rxh-~0zQU+LFx`Zwhd z+Rw}DjDMbL)9Ys2@U_3v44tA~i5E9!8fwr0kZQA`h>32{izK`UVR9S{)s~r8y1&_g zk4;7PELOcEGNbWO%t+4?7nqWc^y{urf}5VqhLKSEMCQ%ABb0Z!O{Cn>oLx+uJ{!^< zyA`VOdH;v0uX6nN*-VT&cKY=C7tNWbJ59FE;IZ#UswbTGNv%^lJ%m2Q;d$pgV3W)(*0Ly8 zxLWt~RwoMhWl&Zs{>$*wMulDfD$p7M8wdSpbd}MtHpiQTy)@a)63%fNpHyH~T zstkSsRtvR^MT(rFiw@0edIYP84y%@0XK|Jp&K@Cu;OG6*UR8I)z2{~B@HVh|T|M2M zl+3Vgq??=YSGh`@IR+RRIgu^OTi=t?un~w`%_ueQpg}XIOT+9M2Rn8%VaG*2GnVeg z!hY9-hKQc}Gm@F&6kolUp<9JxZB=;LLC%Zx97gJ{i9O-&6EQ>=wopiAamL;|dk+x% z;CO785;1ND`QXP=2IJpYKq)eGI1T~FIG@Cmy$g~@)xst#^SrdyVsm zcjkYU!KLBfY^0ja{^jc9CyLAM&}0%j7Y+SZ^U)$FE5nFhj5>pH=WM3H2VVf2M`l}uJv{(4gbgziSC8U|fS<%Xrwi{=qjd{)^ex#vf*Af_ zjV30BF0pj_0c(M)DYka`9_?lPM?b;9<)y>pjQ1-5d@&jBe3_Z9wJGCxDjZ@ZGseJD z%t04V)~<-Yn}5TmpMue-Ex;1n+r|uYev@+rs`H0uG&qtSN}dNU}(r)M#IxfkvM zVv2M8b-H$EJgSU%9~=yO8z6#%8SV!)D8X=>tMJ!N-_vtU{G?Np?_L+Zy?X!S;OgO& zjokEFkzR$ZWuYTh@>v?YX~WLA4gK4~p;2-bWz z`A%%TJRI2YUTMQ$-|4dF_}dLE$n99W?zuSAe=G=k*OZ$~k5My5l8+l4TPDW}07Fde zCv*nkjmtI%!&Xot0-KEdO)^9$WZ8c<2vka}^r^GNuUf^*!^_HZuA*w3;O)EyZ^en0 zUE*`)-}uyH>z~Mdg`4*du@>50tmBOi}qC(qPLA;_h;YDKNM!kq6{jn!cIwlFO=Crlaw6x*5yz5$=9q50d zb!F{}Kn|QJyw22o94EYZP&r1dZ>(8HtZVe8##fBK4Gvq(^AA!7YeK}V_}Xm`=g-+R zY%~MI>x5o^*ATQ&j;mQ@GfVgyDlgB|rn!6PuS3BByl}vpTKDW*zaP}V(EuaF*uyr4 z7S=UANe&q$qY;r3G+9ISLHm7tFti_2W?%}1{9A4Vn0tcW0h)0~7Zp*xAmeenYk&9P zJT-ngbj+Crd?7$-#yfD#?j7e zPhn(h7E0Zx?1YRN>fZt1=Fizx2&OO~U-YUlo!@;`+ro-_!R2FUiKSnj4Cy}DP0?)! z-dpX(emX-97JIcbs_8!*LQyZPBeW|G6cqDVg35wlb>*aG%5`mAe5xsxz0X<^me`-t zBdAadO5AY*>qto%c(6JjsurLSWuv|4IWJx%;6#>`5y8tK;V8pdyr%g@=H)$zMapz> zAoPe(=>YUvY$yKDP8M_CQ`6e zU6p?kJXdgg+-?sftx#wl&#AeuoUo^K@DNfY1$K~mcX4hLxMGo|p42gZ81<-A^y)!>6kLtmV)vkV|i^j%LT9o|aK#nq$G!il`qss46H+_1?lA z(Y{>~ex@iUwKoic7?wo}$$lp!eiL!?im++?`Gt70xlY85l7$85rHKs-_AE8@O_*z+ zlN|0}ma}3l?~`p2&(6RRelkHSqERon7n)7M&@s> zCLPM&v`S`pM35|)(c;mS+Q>O2$y8>VGd|iR>-G!(gSoY?)Az(B642?pziJ)m zuB?K&*k~)uV#Ic4Y0dW-SnE)3U{A6C&_zva?U>TIYj)4@c z?)4%UhO&U;`jng>2?C~f$&pufT=~dRU(a!evO&$A@P511yjaJa6)GpEg9e0%*xIK8cD$ij8g6GkcBF64 z>{N(eme#!NJomgFF*aB@dw5PT2oA$yn&BXKH_GX_t1sfD_OOHHl&S^p#pxY#J~O7B zTMx2?8d+S;Ilju=c@4O4!&3*;jZ!Me{sLB5=JbXKlbbx{J==bCFTey8HLc=@c0W0@bVBrd>PdG_Pf z3wifA#LQ!1U&!kA>XZdvYgXuhNHkY4x!Qs__1DgggSWA}SFd77B@LM*^{-1^qKs`P zn~qa%Q!PhtTaO(PpC+RG9W%d0Qc70%M-+Iif*gcazv(=pEZ=^QTyL^7Lh~yrIah~n z!!m#dTF*v0QLkMz83xOi_N)wQ)*lcKnB>3YEo!_R!yg?nb>Y3*aCicz0a z*W69t@>s)?um%1V-_*Nz2Tg+C*^t9U=L*Vgx0{oX+$vTy>T>Iik!p|d&{Jg*VXouK zK+B8Qn;&&-vJ@448m2r(8%~8jl(m(&i#M71vfXBC`fN~&^}B1NLXU*`VO=4F{Z(Tc^6h->JK*Hh;M zUfd3$nyCj&Dyu@q_&4`P{xF65YxJ~8;+9Q-b~2Ta-!Cjk`k_7d_H48Nluxdi1-HI7 zeY>>oxM15;k7A0T91mHxR+KAcx}8>eo5MA!$T1S*_=JSMPy(Ekbh66i9BcWiLe~ht z|LB%#0`Fd`oZp|J19v0O(9JhyC)3^ta0fs{61yV6oHQ;3Pc!5b9)t%7O7FoP6PeX2Q^Z`5VCIfF+`hMB%wXC$v% z`j7Flwe%=w=7Loo67w~)!D~RaQj2;NA?NL)FSu=}#`yHxbVaGX&C6ZsYHsYKdPVg= zz`>3`1ZUrCQ}oCXp;xHlZRZCpjedN8U~%#BS*!Z_i_ym$v}322#s4w)A-m;9ZC)#Y zl5yZ7nm;LY$mpzrk31+6-$H^%mlsOa>WfJ>5Xz5~<&6TQ90lTNJU7-pO!CBq6+Uk@ zKYwmEEXw14Oqe{sSW8742(&wo?T0&lwQ5j>)00M(G4FpZHFR~AFMw?o&CMVkfm|6Q ztB4y_grhLWr?%&bP+*`llqYB1G>kM&u&l3_b^QBuc7v6DX=(N2=hR@$``^hC79{7p zH&HvXhW*1}H{uK2pA-(FdsFP>*f;TJuu!`0%j~;yVv2TY1LCHxA~pa_n8`?7Pm=O- zI5cz$acz1!!3Hg7q=QOc5JlqA)`C+0W*LlxUh^~aUCbXA_ zEy$;^jl}AXlcwbiJJKr~X@@8o_W(Y@rYtke-mYm;0%-{4|Gt! z37em`(^AscCnWHH5fXG8IP!Q&!&|Gf{|W4LJOn;8HG;}AR4fDw83@if}{d(7ba z`r_y_DgHNtfE3-9g_Z>be*S*ZufhV-WzY@>LqFWO3-NxG`TdA)hE4*E=pPc0ZQenn$J;?BQB?p zwoJ_4mhDzZ&YvFrHwH7p$LhEwBwDBNR%ZGj|Rm8T8y?5zOrpgs~@pW}zCOPxB05a7da$y!i zP+q|IXnq{Og?|DvEX31YJxE%f zl|`5|vPph!2>n#)YLoOG!((X)!Vl2uX+wYJUb@=JMU-Njm;p|w~^OkywLbSQq;+$STO1_yvhk=N^ zAjlbRm`B0$P5HR`3eQ;5oeJ}u&_~h8Y1Ctfa~MF3cAT3)DUxCs#jN;#V2$#~mFTo< ztB*P$0MRz}9Sc*1NO39kx){T5)cQ(p^_Nm2_p9yp_rD@|*e7n7L>U6nyS z3Wn{(+^v;GnNPC|p6OVdLCKtrVQFgYK!y15OR}#^?KSx0X({Q|X@hu8+hT4TgKsQ6 z301mnECZ4E-b@c)^|e+8$BRfh#BXkxk#g}3dZ)U&7#S&(vlCDdVh>>~+s=SfpQ5d$ z97?gL3awq1o~2e^pE@<4&oA|jZ7f*vKQhbe zh=DYpFCecDY(I4OifH3VqsQe$Jkvr@3x!xkpEcJ}x0uwHuAAvecU*c|={S^Nz9dY{ zOqhwBl6mcJp#`^QeJU4e&GM3^4y$8n!GShnJ*YlrKX5c?edbsU*nWB(p}%1|9X_?r z>R>9hlw_SNGWUJ{B*jTJiVum2Eo@T!ce^_ngr003t>vVk$qW=UHe<)OJk)YAm_CcP z9Grq;^f;lJ6bXrq2fJeOJA)W= zE5Ar(io|J3Kn(ZkIK$WM*xeKKm^!>3u$KBP0v05&a`cBzTZe<2&CT7rKhoE_fmKM~ zJj=c6Rz?k^#byDsU1+}}c13HOh0}y%3Ke0SISj*kiS)d`7-sZT=Jx2Y)Whdie)RUth|?Bg+_|Q;17q_ zYs`eM<|j3+U8@z1U}E#C@!_Q!n;st*D^Rc_f8Zwi`IWH!2_9n`%+2_xOtbcSDDq(x zm?h^3h^@dzJJFKX2?=9cQquDs;8Rml#1E0UkC}+s3Iyq`tFvslthu)6b4oS3I5$+y z2Q*F6h)5%B@~l$^pd;HPkh#+A6#Dw1w$V&`p$-Cqhd>3W&X`cc5fW)4V!=Cwi|o7z zqrMom8B2}2n1{tCY6xXxf*Va=!iE1l9erL8I>e#b+)y>|sM^5P?NG$mqkWcHBT;8% zWFwD*?gS@o^lgpsBW{JdQ^CI#3Iz9O5)LPaf9=zQQlv0d9*r^*4b!f+u8?G2$|SDR zv~@o)(CBoded_hX>>3H9fC&70Vgv& zfF>q>`RLjlr#k7|0~<$pf&2y_9YnkM+=mbol&%PpUPEAC0XAu+>;2Jpm^*q!shEl# zQu_S~Tgbu9us?wdZ=Eam#-h-#4L^Mq$0=(of)iZ@wtK0lD6*sq^ilWUQgruAxp@#$ z+O6RGd!Uml<2~E-PYxWyrepv5ts$_p2KnAVOD$5k( z)Zb#NTG{j#(h>&}?M70^NZi5o<>_-{?QlAsTfp0A>w|({#T{rbVqjaTkJR|XYnQ7I zs0WLQKNTD<%*u;J3WA=C-``c|`8-oet4&!OOQpuJjRW`ruz*>{1H413Oftx)_3xA( zw=get9K5lNxYzA^a7UkxJSue9c}p!#awhV}=x4X;HSep@iTB2llHbe122FnzuR;)H z&4YH8aUqB>lt;g*MIGF!6jcXM`zk{=H&m8!(O?rG33Bs%*y=xz>;S){4`DYEeFiau z50cp9XlBd(yn7L-T@fOg>8#@_G|QV!9BMitsz`R_wzDh**;Flq#slA-8(_u2(Bhxp z16d5|d#m*MpUxv%Dp@zOs7a>qmch4Yo-eF#yQuJw$A zx}Py^o!Rnl1#&HCZxA^!{Cazen*4}b{e$``N4L)#o-)98ju0za1Sw0PkN{h?N~sE^ znc(ZhXBA^?=Iv;ms>cxu!%0{~LkZXOYX%%9L)$TT22xWgq(=4HHrqnxuy?c7#I~OW zh8mBYzx!xfebZp_$1C-M4MuPxz*ZBAyl&}{Mq*tKjT_Juwhb{QGz9~2&#aPX>vlH# z1*8Je00mh3&X~VTR)Yys)8FnjeJuY@DOua@mHq2__u__I8+!*A%HQZUWGgpwxS5)T zTrn+AKFF>i7!V$MdzyecjxqemNRr|GJ$ZdSG%!uSl(hMvlr*n`hN*-{rd2R^pU!Fp zH1t{z7pH!@SqerAc>nqjspp;h7r|7?dYML-Xjscrh6j?h6}ELvBqYmdM}B06;}Q*W zHRk<^xvSKoNjs_H8itB#whJ>I{hPhQH+My7Csd698zerKHP{#{+ba!hr^-du=^%(X z3Yaa>U6h^z_;Fa@(-{{9aD8D7^k<$x=P@g9l9VrGH#Hb#yVwL*AyQ(R!u&%<#XP}W zd`=19#jp^(HxF%A=a>~KwsS|!oGwLqx`!}>@1cUn%W>`ZMDWh)^_kb2xdj{6ZL%$% z^bgH|5Mc>(-X0Z865L#I0x1QQU|t%Tbh)g6?C`CeuXtc?)mlARz*;6mTb-LAKX*5KQ4#M!avCm zP&Wq{oEaaU8}k})Hf5=w-BZA78A#j0?3Q`*@I+y`TA9L7>BBT&7r*d#Jk7{mT7yU? z2d)RELO&bbBqKPm!|EcpXYzq(YHERiJvqb-d67U%oAo@V@J)nwL?dT@ipolO9i|#U zHcKezaC`wqmua_&piB^?QmfBAw%*?xZaq9$DF@u-bNCTWYQyx2TRz(}5WEJg`s2v3 z@4TlY`Ad5GSnAB}YU3|1Mj%V+&tiTjg&4UEekXu3lV5szSZP3<^wG{uVn7=$Eo#)s ztQ`G_`Mgr2AN0HXX03dMnnb=ep8@OVs$A#O2T7LCW-!DN)x~tc60!S3Z|6DAntJmB z=T}*V<6KK>JRt65jtGW=pqyH_GI#~fAGMls74_}uC-5|9UVv%8)Xp|}j!u2!bB6R_ zGIn;`j+J$CXTRQNwXKioqiIB19fjrvt~o)vQpa~5S=N3IC0<|YQXkyQn8Gguy|F%? zk{UZHuDZ$M@Y{qI{9xfJOvLP1$MvXxYzY1|#5^9=RJ;*k za*5RMC!&T;vnWn|@q8|BOV@yRtei4JtMT>fT|e{BU{IR?1863 zZw1vYB20*{FUsr(`QnIglRsh!x;U&r3Ch2y%Ua~LGtllK@L3dTBqtR~wBDz6ooIhq z8o~jx{3l`=2%l=pwr);)m=MJ3T_MC2(dufe3GKX|&pZn&04WaD+|s7cwrA> zZW+1OdN`beW~s^Gl))atp3;XzV&Xj1qd9R@xARSzQnvjm6fNXbc;NX|)_pv8hp#`i zpV$W<)#P{JQ(o~V&vjLZPTPmF#kr~-V<6|x24P6=6D(B=r90BuXJgZlQ<5gkq51r6 zB*Bqs%EG0t4LRZLeDe)P0sxAhIU3Psmh_G+RIEU2J*NQ1;jfLW zr9Q_~x5(?d{{fjR;-uVwN$S>gyET%|vz6=Hw#LVW3YStO5j@_KI$Pv-ircfPxTf65 zB6VQx2KZ98R$FyJKJ}O8xw|iuNtcAPeM#*J8v(~%O=OhXr=l~VGpVsNkMG;<6%&R} zkGI93&eI2=v;AY`SgdX5nW`(nkC8*Lda-(`YFCvfJ~!-|Qy^s|xbd%gsBPCuM6-EE zoM3yH=C8xBp2vaAEhrbgjF33Ct!B(z9HLvD1jd?@*g1po0O_=Hsjzp!IqFrIJ z6eK>o&t-rMNXgMjRIZ@RJYRBC>{3Kd`Q?|1uU3~y0ip)k!UYL$15ZT-nK_&>GuNmMUztNisFWKq*not3X0ZccTP}b z_;lb-1*Bre1>i9`%&t7lIKa$;Q_)gaP4>AwB*%;UVnZFJ!`mt1 z(N8t-tKTl23S*Z0VFlTNT(7}ZkH7u}Xb7UOYh+<&uL^g0wy}BNd4nQ3I*qOyBGiEv zYDhTNmgE^k;Rqh@i5Oj1Vn(h)Dja2;vrP{5Dqdebd@0I{JLHz06sK!WH@uFphp`uw z+i9&uSXs!&$uN0z(v1AJ+sjNhp(LOqn`d*(SzZ%S@+ZWc?a9pa3JAl@ zUG*I+n@4_bF)s-qDM*!&5@BBHfQrC;V<0XKn{Lbr`qg_H8+1&AUF=~Su2N|RS|3pQ z_u4Iz{8J-Aj@1_)fF_@FSx2$NiUUbK?dW~pAQ~F~PmmU)V$w2^rc1We-c+jf-4Cb* zFqNS}ZH3m*!-f=&@;hr$EC-{8@Rde29I13>wq~Mi>6@dFgQ!>&NI%|bdkl{uR7Cc9 z?e#m?oKN5OStjAh)`1XJ1r_ddj!jVBFw8SN^G;WW-&MFfOV!RZzy^7A}{pk zLcD-mCwj=p(2-YHrru9Ta_}z1E-FI+J&ek|dUt)x&NRb)Xl|JZ10-dn3(0yoigvL* z@^b^Y58e38Jnm~WFBm)$368DSR!`=-nVf1B&9a3`yTQ7xA$~Y-uf&$|6X#QwQHKE0 z{~9w_kSP@Z;WDK7TTfw|yuMIUvVf{RH?Ihth_YJNo}`9gGFNBpdL^dBg>!z9dm8QK zEaw3tNKCFgfz^B>^4%j0Tuj3VXOhR*F;aAt?`6-|_6>^?igcm0z$~#I4rb$hfP|gI zQD9EVz#RHPb0%tQ(DKdsSp3ys0R!hA854hD+iLMZrI~_)Sq$-z5MebC;k6^cfc%&Lo`JrbTfvG1zlC1w+Vm1hRikPcRtWRcDUl{P(I-7U(}vlchl z&5nsq=b570|1>f_Y$5r1-Vt&Rm3BCVQ%UlE%0%>jsoDD}eb$5tWAR!nKdT4}K4W}4 zuScAI&UX_H3>$AiHU<2#!xbb8EX*xY87>IOQ3+C=484AC!K333wsBKmOoQ&u7Ko9u z5mJw_{&AN#Sz-M<4Ymv!+>SlFI_-`UHCuQ91sqtvZ5j>B8T$~K17FoXi|QvzfP4(a ztno&i?@cNPQcCJetisICfiOqmHFWaP`(_H#Hr80~l--HX~Q% zGKAj9#Gtv6Re0qJQo(8h;wAqgeAlC2gAWrf+m@1&qJidb`nh#IbNHtCv%nGOk&3)p z1@-AdkE5d25+s%R*GGRe)xjLo;mertunoQ^#k{7qbFcercBC|M`=O&32Eq5OJ@5aK zZ5_k@MMJKhXWGo`bASTHb%czp-uO@z+^UY9&t-ZSE1eyZ$qZY84nImp;B2fiXNMK7 z&R#3vyD5^Lxc5pge-hIs?wpkOd&WP1( z0j2e!AiCUjjFsqW-mDPkwek(q9FS|{6#n150hyWA%Lp*^Z`6! z=4i!uq0OwIWz%{I9Zd}H_J`7<=wBVOjJ)8jJR>#7R(08XzG1z4dEIhds?qPr}#JuE3#conKsKt5r_Msza3T6|ES z9M?6BlCMzmQ-Wgd%yE`0xaHpXj*dc0ZYK-t%eNT!$lTIKVW4@W_{30FMe4MPaLO@Zeb*P1)OS#m`gjD?+1?HrJHt9iKlRSMD9 z9~^2jZdBwsx(u@)1C_S>k1~94yerpVaN!hu=zSyw8wZghq$*iH3|R@fp~J)Iu9t=L zHDffli^_76PM?RD+L^7@+1ZnmhHU~1Rx+}%ShXC}ZUPF|d^?_NeJwJvO|t&AaDN$p zjo!%dDQ3Ng=kH7}qhgD!IMsas4e6FXIw#Wv137XR&%y z)GqzlI_y_Oe?_E81US~dBYul2EGfJy9aPO5nM`pd;I}asiuy{cqTz|cu@yXvuhtU6 zsiWk|q9>h;>BCj|WTIcS zaS#{HC>)G~IqG+lSyBCcsUx!}7N%d;w$x&yNUq_aNxhdHJvG~kr<>x&dThCzpW{1i ze$Xi2Ahz2h2-zh}?cnvsz-Pf6M|o-PYJys7dR2#_Zqrkc>b^~ytWW*-le!1-GXJj^ z;2)~tX8y;u?WZR2w(2~@l?AKbX8u+--oE@4)4n_%wF)v4R4H!hrG)ABWwk!4pU0KV z*FD%4g8b-_$s}52tt%22&;oN;E>X!Z%3Rj+fFkJp zUG3m()8!oD@e|u`<$5vC|Ll{2y~~Z9{_Cr3237B@$hIdBxmzMuE&p9G^~Imq9>>0k`|w%YL7dPFLg|#~cCeWM2#7oQCzkHvIDE~1JGkB@H5#2b zO3ZLolFZape$_L4^Txw@Xx<8%4uq*q!L|zhy6iLhuay=|1QrFz%)|BCR1h=bWij*$ zJyGOexxZEL^p2~Zt0fyOK1amX*w3;K;rttn|2KQD*d;!>mOeENal<6YWd&t0WR2y- zX1=3(R8}^JBj-|%Y8Vnwi1z)3&U~Iuil{1Y+`3bpw3u3u-yQtUBo8_#t*}5i+C4Jx zY##RWC;Qp3Obm(b{QUR3y9mJcoct9;&}X~ve(&?@a~W0-UvRgR6@vr24$u_I2XC0? z%{>N&&H1rCgZ0*D^=xKONvRMdAOYsLEvjw{;ZU zkLTlsKCucNk3Yx~iAb`Ps&o}Co5gsX3zFjbMXd>*r)Vl6v0Nvr=mvEL{FZd4ihNm+%xD>!K0RCWEL$x!qaSuY076O3i>Wo^o|K zw5c8&R&@L@##CZ$pR8Mo^2oP$S{=4qjw22L3*ZUT7y(lX=Fk^S`$>7yN}!Iv>QATr zTzcosWB<2y-9_uUls6Q{Xp*j+sxA{Fx{}svS=!%$LavIa*MT2cY?#&grfTAWN)Dai zJ@Dfl@nGv%H2xyR;p1RItnfs1$VJ=Z!l~vPB?;me)BEEyWmRXZ#5BXFzg{@TV!JkA zh1JY25fIjbP}bT}i9|=C_BJe4(o-W?%mqUo2Mw4UsKWS$PF>*lX#UNu{`)Db;rLn`Y->4kt$49nZUvZI`qTbYXO5;$fE9 zs^n@P$VxDoC1E@LA>e7hw(5wsi_+(fE~^|6 z_U%1EbtI^BTASW1NI5|tj+Lq%L?!-Vj*#P+qkOGKO)5^$OVslXO`SYw{OET|M*3^d zSIu9#SP>u&d+Mok?hm1d?v{;+CyW0-;ZXi1oZI9A-R1E={ZWVeCa8i)?7eJ3y@+hz zAv6ZXv?K_BR2J(FpC`>{G~XL+jY;-vvNrvt*z~?e2p@s!;&2@Ds-?tt{DY-ANaUa- zO!jNl`u*@7bLXFh8nt3wFMp$+)r5SJ-h!ueJ@%(wv|m9M`hNy3uT(wwP3c8eD!01J zkRd!BZlyNhdmyD4(UE)^LCQOMPF#Qg#K`*kAe1L!b|&oa>w3{KawUC?q?6#iMIa%L zSX|;$w{LmAJpFY$56E0di*LM8m=2erb(#@D@goKd@R?C!+VIt6=lzoYUA(2D3%&;K z@W6qN{i&|V?qUtA#pq)uTo<;ZhR__M=z3Yd4%9pW&*xkwH>GJFvOt^cZ6Oy5*Gm5p z*#A8q|H-ay3)0WYWii<557#l0%hl{DoH?@)dVyc_>-33Y@TEt{BM$0s`Lv^)^YD%q zzpgYf1&6R`p%>^0`gd`}t2O*0mqcJ;UBU2H=?;Q!x2-&A97YQUL}ip1qgyV*kJ~>R z{=tOr%E-lDai!4J1VU@$MoxfCvNe3Vc%HEGA)){ApJ!n#g^ML$|v0zhROwC7j#F*e*zZ*dvLF!EcNb&YcV}!lei!_S!$27FX^_D6>tRBTHC8H+ zn+J5h^kv!p{YM|eh3)_UVg9K(X#dn42^)wmlr+%G-A%-M1q4Lz0r)|N$cj?mkGKbf zzePmwbf}YbKE!nzxWy`EfEuTMU6|DDq0*-ir+pOZqX-FwB8;&uXj0L*&yTL0zUK( zctNZkp7d~MD3sWXHV8GT7#oj?>WBC)VSqW_nzlOu6X0uL_WC;i8$VLUs>_F-A}ewwP^K@7~)(3rD8^PZpg< ze)5#^TR7f1lIW5yKe?<8;B@16n!F>X@dFuzi2o(14ym6_&g8D$0g2DpZHYgcHu3$X-z)!vJ)*Aq#=^f1`QyfxCoa5`#BrZBJsB zN#JYcy?tIIIrn}|?Udy1eIWj4f%&uF4fP%nfKYmmL}KT~7oJJF4~y-Tp2O#-3yz7g zTVx00>!HH>aoaoHppb!04kPR@QUPwRQ6OPOO$t;jI;b7}L>+D~X$)@-5UYMsTt%cXp3pRR6NS5VR@J0`Di2H@-++2SDMk&nu=)O`H7XwDq$50sN;21x z%1l7Yqy$53KHC<~s@(Cg6w#@~pO3Iy#llX1OVVyxqAsT=rDNx-dNy+ePdTb8n!_R0 zwHPm#cUM=NOhaA<5|7IU7SHd^j<42yw*8{{%#rNXQu1Hz;M&oR?pQ(zVprbSio(xN z0*aTRkc(g-0zl?zN@f&R_1XnW1&{hr9A7g*`{&VjUfbn_BmJx_^?`zA#T^vpdH3Mf?DF`AtZAhi3-8l{ zIHG|jN?zb9DR*s}7VlB@Tvj>2#cC`%GdTC#LrIc}827iAA3c>23V`D6NtjfyyVjm5CO+nm~}a{%rmFcF};V__+^h95BHTD6M;Lc zHKZp~X?t}z$cWfp3ZX&4YPf2mM^kv-2~r|Rv~&g39FVn-6o0psMIga)Qrw`r>Fs`V z3-iNQLy4k_3*L)Z~2HS&0#9MD1xs9v+b`@lY>{sOqox@g?AXOBRq`LH3lNFOhnlcwJR=_#?r-LBV zFRSq)hL^86ZDL*rAXRcV%@$=#i-`lVPJn;9^5pEq>XJ3R<|$W`p=NaQ_nm;M)wz$~ z@hiVP75@A3eHv>%k&{q!c8!w6HLz-~&#UmbT zg-l=?!MN{MmVUn-T{M1dB?-a(mJ~EkyNhqi=8?sZ&aH<8>y51d4}J@`^f~ZHQAUVC z063xd8k;K6`S}flVQQer1glE?J^?1)9?!npzF0nbaChfsC+a=jR8S1DtgtU97m<~# z6S-0C*C&Z+(bDkpfo;Oh3yWxqe;)^dY-EEYuzq4Ea42i!OdE{72B1*j0AV!(*Pv1X zmKj)B`|G2#6q-J{dy91r%J%u6dT3~O{AJe1K?WzuIk&Yw!%k9%S~HMAyluD5&m8fE zet4U!lc(99!Wh$ zOvkkjB!fy!xwab_eF6$yJ5f&mCUAcqeClpx+zpLdh(%R+wF+%}PiGtT(q4ut?`r2C z5G2Oy4gXO9u4@0$Qts(7#Qi`lj#ImIt6pNo14G0HR|SW6B|m_pdRYr z8{6}j_s{qY2+M)0pi`S$5Ys1bcWv^pOUhLH897kEsqBpF|zU!>qQiND^7 z$|=0PlM=PR*MC0OcCTP@MfAb@7^bQ{7K!G@-&?Wc3Q+liRKcrD#Yd)%5_sFIuimXX zm%W|qcJZT64+6SfwgjJbu(Cn}=#(PW{r_;(u5kCLpO~MiT<0W}wAR zXZ%$mG;wQ5MW$4jh@@pKzT2g(T}R# zq5I3*VMi*fA*elWemr%*a)WUeH$+5t+P|ghoqHuS7oZmV!h*rF7C)MP2_u(4Qv(-? z$9>CR%L^?2z@(zSNeMdxg!c4;!S}jh!&HwHCW;2+tIik2vJAfs-<>_(8(iS?R%Axm zhQt0Oh0IzPd=&5s8U=-=zFSTp#M9b@#_~X4(4x-d1jvnAH4-6ZwKX>%bQ@!WhUN9I zp?ae^!+LUh(1eN$s(Vkb2hT1IK*E=oJ%(2uGb3IQdQfIQn%>PG?#k}B_dS)32o)He zv2h(pEgQ9s)=byXcOox98h=(Nu_8(`y7D0}o$$?eOoqjlqazxdu7kP&1A$i$5+K=s z+z#T9ivKbAeJkLeJ{5Af%@wt_eEyr%Sh2wzM3?QN%aAJ?^oDoZJ3gJA00>q6R3fP+ z145%dy0y(wPD{Ar=Z=3r#mhi#PY(K><$RWK04js(>XVTM<`kC<>~Bh z`QG4UrB=jf1+(q`>>@@dsjkVDz-C_529EyIG4w+m_BCM|#o%6eDwMTDvtT-Y3`;|X?heZp(r{JBhC{-lB>;&)Y=^o;@gp!Q zkKeGZJP?{m0f+O>OB4$&m?S@NYb_V*?}~)o1~Otd@ON@LzWv_%i*Gq7&Cht%>3zCf_c=+_ZJ)iWJn`2c*SgChWgNLdFIf{W zhr`zzjR~LtcoQ@W$D(Vp{vAKNj(*0_t7Oe51;KBE=V8i876-MDlVql+HGiCBG2IX! zTJ-~IqPueq;DVkHR+czV?tQYx4z{M(W;WC3;1`px($ugAaYRGYtd^e#3I|j>uX5#p zUt2Esfn=RG>Zrn|hn-%xj_>E^6>>-kWo*~g$kPdu8tk>@ig-=$u_U3uTPn=~l)c57OtOLkf*Z{z zn^`wg6qxCN^hftF58{7_(fdD$F|*IZ#=GAsGU)-uB@1}g{Z@}`14>aa!5a#mN+lv1 zcMCjr$Q)GWaq^83e@BHCs!O`ch$3-Y4q-5 zxd5HL)hs@MZufpZ3u?!&eU$|RQ}Y^uv66I%#jy##ov^P{@e$w$3*Q}mw25M=GtZBz zf8;3#jn`zYGF~YaK>xm#q*Y-iVB;2GW7}G4+UxKzHNnvHr|Brm?l``hS^9~$sog+U zK5w0)fvFcHexB^7kgwb~_cKf~Za#cgToGEqpIMO!E!B4gU9Tq%l&FR!L$NJEpFvAa zo!}r#l9@()ZhpFHZxO%iK1;KWX%H$f6z?+r7SeyRRbF3T--sE>3a%zhHcP51aX{ua zt`-UP0W?5wS+I;5TNo7os^PO2uH^E?E}VqG;3Jy!>Pev3$Hh$N2&9ivt77{EscNP4 zi+Ov(4kfvh3Ayi^d8gf02sHp`(#pNDGSl1Bo;3L0Oc84)6B91a%Wl~eLJ~=P;FNzN zZqq2_vBrh+OOQ}W)sfNxT00Kl;?6@IS;e42C zxhoaCI%!Ko;C_jzBt{lMy#V}nhk=n`0lHH|RK-fjiE&eHkX`MFYzi^sSSh>RjnxJx z#q2(p^0%ZnLQ4!K)z;RY-#y9DQasWJ$pM>7O3A&2+$Q|C&dTmSw$A_T9$9{k#ZCFq za~m_z5DMJJmB8;Z!aGk)fH&bPFQQ{5n|sHo@lO&WZACB&x=KqrCBr=ym+K^z#2$>Q zxI@W#DsFE5@dB}7XYn>X{9UZC<325hp^-R277?7Xgmr}R!@^A_F#_NQMTeO>nC-45 zjGfGBip4od`I}=-Jq01Ag-*+iK*Gedr97@Le^1Kx?WMc%NAImJ;TX!6z$1ye^v6=A zst9%ic7(;%eXFDXNIzt%vCw!sHYX~dks|!L>XB)WxdEp(Q^-7H1tU<6@yNDqALF3L zv1V)H1FvK;+il9&I;W6}=i5D)AD-@`S~|`nvVsA!XHa{LH2H)%XdgzJ9)HsM^Dv7m z*b~bKwBJx*MNA=sGm?3c93ugNc0?F&Z8N9ygqhB3I?++`?X}?b;*4MbQO70Sk#mZH zvnt&=()w4<+xJqqF{CAVjFiNed6*C!t0bCzXlap zYODVI9QY*oB2t3$#D2moqKaSKo>R_Scz?3)6m8;ggg#~NQe1)!F4pNEWg?m#IFqhd zwDm5dCdpE+-iSA?yItD1muUWn%I@SSWwA_#%`U3{mzm1LWG!;j1Iu2bO9U`~bc0*fC< zLv1~G<-V5u{X4`CS?RS3p(tsrBKB%|o z3Ez@;Br4MM+8A%hN6UJsz%i$gdD`J#onzbex)IM5Z0bpVx-|@=WIYO(cA>6Q=WHA1 zx$VPN`rVm-0yPWHzh)nheH`84VBj{e`u<)a(Athck3{z^Z!SM&^|Am`Pc8kngpb3k zsH+{e9e+Fx@CDUB7<0peyW$8vBI{m#Qk6Yt$ekVYHc0u_xkEYgZNgx~<=SUu+b(nV z!t0{d#H9&yO-Oq-NV!ahES6A7d)bh3_jtunpyx%hDwdI5PMrP4WXQ*rGX9wS+PbH= zux`6>tXnElPlqa&f@AM1U4ImhkCov`!Y`-z_NNFQ!pvsFF~ekQS52kN8rJYx$?oW0 zxC+Dy#MJYt!7$b$(p_3MB|E0R`3#EDP)m_;Fn79Ame5m1JBRXHa60&bT5Jk64&b%* z&L5qJJ7!z-l0`3`Z12Z)0lAjmVmaz!?9%20MLFwbnLtEr=`z3g!fxUrJj31?l$?6f z3cqIj{-No~7D=HhF}|vsq0fcF()n$x2lccYexN4JrN?F3$5UftW!oV>swbGodTmdj zkr0k9SI(B%CeBbx^$tR`RSUzo`Wa)Icpe$R1_LJcYD4JO0RjmoDvcH6Zjix%b2L(; zccR6vm&oxg!!e+{X_M`F@3yU#xlaH2JA#@v7JDo#T6M`IOQ2&M_{X%QF^mz6 zY7ab4g_CMS%whhM`H|L?vyeMMr3Xb^bVrNH4Gyl+~y(^PJ{UUCDWzH4hMEqM2 z_ho-%93*JTB15+w!MK1N-B=>U{0K%i6F+(QQfSvDM@< zk?iq!a}l{s6-8?rS2oM!)tmi~*$!mUkMO6Xr_;a0rb;*A z4a)30(xGRDKcH=)42wg%+PQ;n%IPKOMgx3ORR14S-@u(&xHKEvw(UtWv2EKE+cqY) zZCexDwllG9>%EzCzO&Z-4ZC(f-Bs1qVIr2>s)~2P^=sl;=iUqSu~N9Q#d23duV46y zfMVM1FGdjwoJzK2t{++hDFGd&|MLQP_Qk6Pv&U(>pU(E|PKqTZhjscNf_h`Q$zt4%PbmZd%D#?C#R+{)JWLNd(6%S+~TSF z^$Q<%-x@DB9^t8=UexO}Z{M<}Pm*$!7Q-Osj6ECqlczZ|6T-ifJGqHuVZ}r3bheb7 zti!J^dO=^Y#gN+VRly)q^rGXOXy4w-wE3^#^wQd`h0ly5K^6kZ|KMRY>py7VwR^tm zR+r~-`Wm%A>eue2f(Pw+tT+zB3e}#_Z<~F z^An2*bEG2y7Nlh>1sH{Yq(V_E{j@6eJ+A3G1{}k~ELdDF=bXoWC}mOsM9u1z>DX}$ z3Q-9T0()i5=HC)aNsfq&+oo8xqqUMoQCU$<+WebgfL*ErL>h^DX>@V$b?W0-NEb#h zlAgG?-d=7tEG};#{1!-C2>g8W8V(CG>ZZAfe>`fE1{TB$Q5;@SgZr0TD=uaT6$6_v zmXv}xLzHN!Q{5v-X zkU9_6uGgdZ%|(Yb3YnDM-S^jof%C`j)#w0e5Xab{f}yx$9zbk_{OK&HQ(thxazk9= z-Ol0%?+V}I?KL|ZP^Ph2_hSkvepm9?1q4DNFd9W% z3M=`50Bv3#cAij-Brw))LLM}l-A3&!W=R2q5sPGKi#yZ=jaX&+`uMFn|0?qzNrT9a z*YED~(`_1$z}AM)7Rm%y%^YVF6*1DpwpD`(R|D4Qkda3mZVuM|b*HT~+`fNI*n0Qd z*%~g(0iK%CEET>OYLJ8!twfR-f7s@ues$VOl2i774j2vne=ma1Er6Tb zaZAuHUCxHjkx>aL)kTj|Q6{6uTS&f_hZ;9KfiO9*RD#yG+3Z7|3H|4!_D>y~A#4+w zqrc)~&eo18={U5&E50<|=IfG^E);S%ObEr5XkvL9OBk?3lPy2)*_6JGQ6GbQMbDC+ zdMt8Zzg||jT)nTsgMwoCuQ?I!uP|=QR6a`b( zC}yvzI>JKGgEZs?!sekhwJXzuA4Z1Hiyz1EyY8#EY5y2~JphZE4EPO&3zew84u0M-9>NQNIBthbzeY1FR8sEs~-p=`hu!LkR+x02t=B z*1&29EMbbm>f;tQtX=anxaa2&4);81eHI$vaMqAoy-2oSrY;UoOSx8;Hp6A}${h5W zU&S+52yMyh7Xj7T*}~J2tdA!s^;?K*h&Rw7CnMZ~!amc0iFwV&B`{@ab7UbUWXJU0 z?17su;&;x+=`k6QQ}$V8iQQ<}?qa&7fY;Obm8;XWwo~9}$bSyA?|zZ^AVn%vB8?uJa-i+Ad-B@3`MZcoj z@Ce$JKGUqeD4xRlz=a`STzs4)T13JSSelE>>X&PXve1In-A+QBXrfxi&t{rGXi-^J z)2B;XpG|gthCAt8aCim;qj7uHI zX$CA4sS8-C_C_6Js)4T4#N#z{$Yo~AHc7JKyH!~EBt;yZ9rymWco*`^eaYXo`*RxA zuzGlxRkm=;ikgG1`jXrRh4|z>kWPU#@%YNPVibLa`jKagj$??%3%tTPvum9v2)6d6 z0LM)rbwPy$g42siN`EuYuaC9ewRb0ySC-zIA-yeEX{($>e-S8xJM|^;_uh&e(N2YY zaA{f_`npM1kO|rozH-~`ID#**?e|8KFh;YD&ft}~Suzhyqh&pp{ZDs;>Bzb8v@`U& zck^?a-3{3oL1I6osJ@qmV=k<(Pvx?qTp(A7m_G&QJCiBLDtn#6x0RMc0;g2AQVhEtg!0O(y7=R^-J2k#*1@kmWZmVStxK z|J}yAJE9^1B`W7{To)ohrA1U@QPDJL+~i+I&C=j}6R!`LGik9UGS|DS*?3>a+)|=( z_4Uo0B~LwXT7-!w)8d{GhMAw7*9pS9Sj5XuW{NuYu2Xv>qc4VU(U=s31gTg-kAUpALO)=r8V zDHiWdD=C;aV2z?cw@L#hju?!xkwZ{M6A4f)P0Wx&Ll!6@8dbKl86)dEFuoeSSZ%a( zcb_o9g-d|%;GVh4vDPdL9vH~(%C1x${Rk2oLu}(`J}3j)=^D8V;$p;u8YebLqLSCb zyxLTl0MY|md?U$#uo@eC#r3la4$;C9!J@KsG5GaVs?N9SavK6}9(1+Z;yPn{?o>=F zNld6lKisrod2p4Ei16%mm24uG;eEB*0dMyE5szwd^!f339xnd0zgfWVuplTy_rW|7 zQai}vS;VIKwv@p#qe$gP-0oTJjXb8I%{TFH{m_kS1*0VP^6b2(stP7Pp+0mrg`RH> zDXn|o>M4E@e>aJUkxC$K%2NfJ)?Rz};5qg5GvVs9$g;WG5=AnUwM2 zK5}8Z%~XCS`gTzC|LiVr|1|aUNIk8Jq`65y#SNbFJb!x9oNlv7J%eX) zbT9qrDGOTE!E*BMc*~2T8-wa#*G!&;TMOFs)wfFDOp(g%@~6dL?L_a2s9DJ4C%{cE zuak$e#RK2hH!|jxFTBf2SJ98ut@N;JWtl{B(6Kg+iiLKaCacNyJ#ArUD>4!rR{djq zFv1c9dx-n1NrPhu zIk=uSa0zkSAEF3kP;ITF#kv7cak+|(%G%uAw6yS;bc+3J&Dw?Xpty{X{BhQ|F}T35 zT3LDE#=vkW`B;k&dld4-B$o6m??} zDemE)_zkPe^i>8X&dBp|-~OHHakU2(a2$5sU0A{M7A9Z$I^0T;`_ZG595#)yu3f=G zZM9_u_g!{=$-+e!kJ%@x1cMazw;o-1H^&TW75XLVz4wnOyn2gCRSPnc~Q^?~z{3vhe$ec=g#c_cn zfSAIZRHQJ$@JQ?Ha-8hNC7ywH*XXDbXXD(=P+U!)tkA?-W+nv3l|W#ypEVqP2_CBfRzDM31%-`blVmNMC^Q&2|vzuxK%Ce7@}w9r_pRK z`PZ%LfDgrTzX^oDl?t%hP<>5gqR?+fNs+UXvAN`6?OKn#9m1BZ$K)AsC{{|z5w zdCnO6_q()DDQBvF-mUNb8)uHZ^;;xA@Lk%!xn)c1rQ|hdMK=6o*~xkGCN9M1Una4o zoZy_u@JT9di?|x7YEJ1xg=DOVN^=R7tvYgP$HTelAD6j$n{F70k6Cta`R8H`$|WDp zcKMrhD(89@D$MhohI~f#L2Eut2rNH95|o?knJF|`=jO{f1-7)SblvP_Nvc`j7-rci zR4Gbp0ypp(A zUc*Z8qS7$cLXj~>Gs%$H6FO+6Hjcel;WScL=mTM|$dW`V&7;6HBkVa0$;mw6b>1D} z2S9;PeTmwb+KP*-`{wUTM!71Q{4lU%XXVzMAAIJ>vXlxHEP@mI ztGL!i#0{&}UB~fMpGmlWH&NPre7kfW-OIks#pdVW=J(eSh8aOpqG42@=Q%7%R1T^2 z7pk8uGWK^f5)UF6g`}2X&lsok$qWGmd8lwJPYS_sclss7?obCLKq9CgaRe{;hhlz8 zwXz{a-`ZlU2EX}<)X{y3M0K+Ar&-tUw`FriUITUyK#LzffoCI0E1p|OAmHDHP8k_3%iE>uFrQ0Y4BnBgOglbZ4en6pK@^z3MYX`ujbRx>6 zZ~U5QiTQBX9@#1GlhU{1h!#?eFhGV9w^S@aFxv~G!;ByqOIMJYOq9va3Z_zOAbF#5 z4o89Y*TWBloj1Sih@j!&|M@j|-ouCVvyjqB5t=w>)#p!|$^?sWHvUj>`^_?U)Tu@O z40CF3x<8b{s71aUI$8L*Mbc!3ed||zyf>TbbU~jySYZ4qk64%<5jR3+{}iM;s2quT z-%B7zQ<@sT`%unuY|a7kV5$iekMH|ncXf7GYPD9`<6N>x1net4%4e8o!s4e%IHs!M zWMkjyek)e<%5T=7bsMYO61?Fy3}VtBb4w|fJmW`j;Yr=bML3%2-Yehl3}DS&i475C z+hgUR9{-Xzs2nqY3n?eZi|)FwC29Cxa{{5-8ccXrHel?x2nO^E!8;KZRlg|(ajUp; zF5`lu$&dU84zfdew%cP9h)t~4L^PFkrMR}-7~DZfud9 zLIF~ba06q|&a?9g)+y`MWhc8T^3oVM@krNmV>>De>okn0p{p!a`R3Jl*5chKYL`m8 z8ZK`O*B*w)*=C3S%N~WfU9UHkuFW=G6VR8+R_LlkI{}crB10qvBiN%q!KuP_ZUCXd zc)PH3)C?+)d3onJsxUp-BV&B9(KIJI8H^DJCl8CH?0(fP<&#u1!{xu1F)zY=++tI$ zV8`lnQl?!qJu@L{o7hM=VmE9tfU4R&6(3KEQ`ReXn)VMMW;;BKJXE|vm}N?yyL<2ngYL&XkDS(8*5`A!De80iSMd~P7FY!bD@$` zs-<2e=hhw+`VExlc078HC|{@XZRlIz%4IjhbCqk#A$5=OJArDQ1I0-oRr8U6&0XZY z)e@Q2r_$a65wZR9`W4`;utAXahKPb(A8?{KJ;_jlP7Td0IJ?}Db*pwPd;5BNYi0Z8 zeNVnuwbB-o`!Cu^1WfPWy2OprEcev&7k{l!!vq5vB;?hq3qj@`Fm%X&vXd{g9H=~XcdOD=5y zVR;)(-YMZVd%KS3bxeJ2m7x*th)v0#HX2d|r1+vVK`R@S#P>ng2?703bWe0#(sArU zN3Mf9+Bc=)(!l84k$IeVWoZXKmgiLCTz@hko4FxU!F2cF+WkeEnb+atQeUjW+_Y#F*Ya}SF9ZbRNDQg`k-cQZvv+J5W6Ay%;{XIn#l?D1 zJPTpDY^=g^_9Xy9@|%w4@Ls7xctTmf`*osA79HH{%uZ*^M=M(EeTr+d=-n5fIz|TGZ)V)!SSKpMnMMCOfZh z5>U7BN?CSMF8_!8PbsQYi?^{btEtA=kOi5BLUbLB{;3*;bTjrNHk{xKXyKoFqo}I> z%*#rX1m#q3ehwymnQk8IF}w$8QhA#jjtkZg8j1 z0;&gwuQ&0X-v?LCAi3VQ5?02imT`&@O3!jO4w#-w_X2?oFMz(aiy&o>(R-t5BG7(? zX8Q{~{X%0?!3<}cE~HCQZi*)XEOFeNw=B00DMI8{-2 zvz*edXRUJpAoKZixn^}UT+IT1)x|RVpszyty{d8f8DLdy=N_6~KclaS1{mo))2M`> zSXlV`2I#n5!?yv1ZhG@bHeoE+k)yGe?%CxqU0Si^XA$AuFlP|sAt<*4+4FHU z#=kww{gAUroeWG6(jI|l#gh;iq46hiI~o(Z9`lbyVNRiYdPAvjym-Dx-W??gEF z?&g}?lYL5YUvar=ecu6^DtTNwv!0ly$Y?f)?d|8Jake=Rb*Cd0cZ2g>*-G5i?r#Qm z;55oAhZFwvt`s7i%(f6>TqmXLu z;m9N87HQkm+NkJ|%+8u-n9bU`r5GA$*4bSS`e>hRIRRk0!|nWVJAh|?RzA`|`dKI(- zA+Q2KRQ>j)`4PwW`d<;au+jMLU6F!Bp*=c6-Q-Mw{XBUU*Plu&oVsE7yRdAA z9LAZrh@@27;OEv}8%eEyaqz;}fi|35a1^{2+l{MOh`sj|>=~*@p0b=q*@cC-=4W#S$w;AS1vOswRi8XjMmZwv)oDR>S^vDcidlwV;iO>LzBIPQ2N z&_87^or(Mf+gD<>`7Sn()i9jK0XsRy!_pNH|Lpw@6}bo?{9{Sqv%R3)zg@rX4@_)rO_+2D!*2zMh?MeE9%1(0-boHT8kOgPe$f+Bp~?uaK%7mD?K(a61YTgi!S-37 zL_D(GOY^^;$-4j!pS#NTKmnFuS6!Z0k3PxcTUt7j=i#7Vxp$GwdzhomAyu^HhG^hO z?IB3)V0no>@V!9L5Oj7IsJC7_pmUtM;nlR_!+e~t8xNV?twv=U2~RP-s4KF|N9vaY zW)IJ~ZR>E|eMth`I5mzaz4yn1JLdOIhlL<12sFMAUB%m!!Mv*EC#-!AAb-8wF$5$O ziiA$%6SaR+g_?oiq4mR3Rnf8y0qYPM8D^JJVwz63e>BU=H@mS$Hqw$Ph~$O!mZJw0 z0N?ObJ&)*pJ#H1PxtmHKPxI}oru?47@fJ*uC3auYE)x}JbLm%cZ2(UP?y(tCt!H@ctvOL zK1x>?Ykn)vKZ}!1pt$)Iq46@wg7}Ev1e*Ntv07$)66dtU$CjJmQS3)d>nK{zYP1ku zBgNGzOByGJPv>QJdg<-x_FSJnJ_N}f(GRpxvhm(K!WSmByMw zB1tgkpq}v{b0f~6qM}fQX$VM=e@|Dyx$d%7YF6e^5>rsmQc8{~L&jBIW9Rf8Nq)Ab zdl??7-^%TB-p)_8UTq_n^jU|aDTyO7#~kKHX9A+o9JnI}O5#Ixn*8{2F=DPUDz2ei z+8OZ&T65I@9CMMMUc!mgo)wch>dEmEa^92&V_y3$MZos=lF6vjaR-;7D)TxXRAX1| zXGg%$erwfUAwcNc!RPUzE@lbZqZd<9wKYlA^-Ewlz=}|s0ek7lDntwjV?3~%EF$x& zEEzXlK|^9=&KzXck!9SH+~#RU-cx_c$IBK+j{&3LlVld9CLEHY%=ABMx2ttS?og=buy3Y1#seU2R_BW;iOkjWgoi>rtxTaN zcxkmvfxS^jxTHF`xh}fXuQ@hlhvgE#8%`9B#5h24aNA zqh;%Smnez$y;S$ZUd2i%759H$0Nv!Ug|VPUR6n1?&CIR)PW>Cn4_Lc4=k0#l=F5xk z9ddrU1$<=d|*w~RKI7ae(q z(!NVx_)6I{`$iz2J_^`_Qcl*c2*jiV+_moiZmmt9aCzRM31q(rBoe=%4;v;4@xzOv>+ zjuiN4kivJ=D0Mo?f~^C)1#-wxsAoM31l&RV8PWT&fno`BNP>S=sn_6~yBk`RbGA2d z*&k-(szs2~{im=9AO}1}2+RR#3_QLJr1Gt=Ku!7JT55B<44jupl3Y$Oz>ITb2JaXd z(fxGdAwF^s!YQVoJKD>WGjHN}l=x*5B`6CnQWG)yR61dI1w6FO4FgQd`!TyYhmJ5s zs6*{mu>hDFFq^L0J>;tm?S5?v)iaVxhV-(jwK5J5e9BM5Mn?NC=t+RI41bH9O2bHZ z5L({|{75iRl8pO&aN1~FSZB;E*!Ds+Birf`jNz31(X7X2MjKooJ;o=G7n7DwUjU{n zqP36DXqC2%QZXsMqI8z>PGKUcT(HPOEVE&sXHwY4_j#Jw4Hkd`EH#X8tNB+t63Jl~ ztZuSK`C@1gENX81-Inb2clG&6x9rnVb=w4s~iHax*nD`mDG#S%@=~Q~p?3UP-J~|NrkuRV$ zRk^znM8Lj8+)RDjrw^Z(P#-hEE-|w8JQ}qH(e+7pO*2L~qdwl$VWkhnf0q0f8m(hE zMFXxCn8Oc*ztz7eMzDsoVNvAPXktM+U)rwkpa;p!I&tU=Q!XeGU~cN{W#^|)x?2a-v#&-6Dkc;0IItpT371KvY`HZ*O!mwZt5x8)WP&$ON)lg;XGw&F z7^9+lnweSgHzc!LP#sV36cWzt;F0EfDTV!05e3M}Nf0Cq3-e6V(OFR=UT?xO!X&K) z`*3-z|4{@?K$eL{l$fn=nsqoqLS208Jv#eb?B;s^nBS?-|ME-8xQ>Bb9h9cO+ylbsG^Yg)cZ<@^l>LX#Z(UCsX3XsUxUeB;x%4<3?F3gL(e@f6|{Fp zJ4ahzImv^;rX@nZm9n~X+cLljaX>b1)^RHhS%H5qkqijdn;r;BE=112&l-=y?)+95 zlvw*M^4yCJTMZcnn9Z8Z1pPzj!bO?{P2^NvJ(YA#^Y>?pg^VyKR-E3bYf?|} z%~OBaF??R6M41mn6x0B|x!11qB|o4+sW7vndB}o7PKzFszJ4=b zrDjq*-3GNL71tV?B9+Br(u!m2mxI(A7ZgPE;Ue(FStDS48xsH(R!r z?)L4zKChKEJi|RW=Rx%GrJs*m+f%Ss+YH?j?({I(3oKXQ5?^a1bIoX1UD^lJx<^NF zIHGJsSqxZO@PHtQ`RoDPF$QekOZ@bzx0kE_rfUYh-FAm>237S?$Kjr^o6Ysw3&dfy zwj8RBVV>BJ&Ex5PITQPM>t^pfW2!+cmfb-NlTmV7YLAE)`7wI3bv0P>pZU}G>^eaC;9#<<_E9~{Soz+C%q?_5Owuvqw zZlZTUq@+0Dcj+8z1 zfcaXmk3>Hgf^Fvo)F4R4uoPE>54wjc=wB((Vv%8>bHnH50XIt zEuX3b{)X@@$De{|*rSw(xjBNoR4XQYAXL$Dp6@%-ebUz(Y8-87nXUiuK>}if_ua_a z2Vx!@G4^z<{G=(=Zg+Jdn_8rmZl`OsZay7ayUqn~DuyY(LE!BON8h2<5jhFiL6>|p zI0c?6BIGxus|L+~nIXZT;U_5f5dS{jvQV$^JXXnn^O(x#7|3}0ef*q~(fz5j;_A)O z&8#P*wpQTJBn`ggj>i5%Z`f@KH?Is3^siF9zz7KlsPUl9m;~rYq`j*HVXjfuL@9q7 zr)2V#S&xeksJmXCg_LyE+{T~ zl1Uxk`(+eO0SsY4Xk}C))jL5{aB~d)>O8@Pih>m~k80zZq|y_GPRf36OFo%^*q`lH zE*`6p+dM2S4tYIZ9-`pK$|mqc46_Kq?`C>yPJd?bbGh&1VMv7x0^cxG`Q_t6M9qL; zf49}cJ({qFg~pLGK{xWUI-OvKu>qk2I1vX&I$g)57N$)4R>mQM#>P+PH$oY|E@nrT z4~71$iSjx=njZ_1c78{#0c^{XCVZA!8yAs>kX3f*v7mx1s9#yk3A~pY8Mo2&|asR;tWaWsIvAqY@dEAEAOa&!X^ExXOS#B#% zdqDbccrdn!j>7SxHL{;Yzm;H>*M=nOnQ9`$5uMkgnq_r!pqYD- z2H%w!`#IY97XI4rV#7;r=ddd;^lk7JQV)3~xfUS>@^NpxVj&*zv)I0K43a90G23?bFcu`cj}2jv7dCB)o&fkpW>LE~#qtJe!v zkt=E$dxLn{GS^fvxq7;ST-Vdo^P+sk&vqel`OxqiC{1X)+NF=ZE}>i*@Ex0!U!6!F z=~~InDK;k(LEG25?j#Fyyy_dAj{ZA0AC)`rEo%i|jHN+8zs^YS3vnU6ATiZnB%0jS z3Q0r%BJBgmo1iOT{L-jeSQ|lqmHR*(530GKBQug!qo=;lZX<0);A4zX8ZXprtChy+g*Tu zQ9US{t`P?!9260JhQ(eRJY+gBx!~a7Cvz7uAa`GSFm`*~0T_uH_4*#}G|BZ)Nm8C( zo_w4l^<(r?o6xfl)ILytM;VjHZ6qcjT3u|~G?L+mnm8TlDPCW8GV0h`ks+e!YW@jF zK)8^gznYa)T7K1IIgUhbE6@VW;0k1}%aF+?oFpq7|LchQX3M3S+|6NC$3RmAGZ{eN z)85l?WOiq#-w}gZz}|*z9RmUoyh2VUQhqhmsrMpCk7|GPu;WlSe*8cH7!p9FC**S$ zI4DIiuoadn0#YFaTPOCBN`bTf;>eh}MJ+-$YqbijU39i^HuXIE_$vmnvKjd26}+$e zzC9Cag->hMuU2`uTZvtwuyKm6Z5(q`)G6s`8_wa-8_IeN$%#rg$k`)8%2?Hx$sm@(6tTvAo z@wWkzO0jif?vRX~D$Y6%@v_S#6_=iWlRhcCqJ0Q132TnB>=|)Xu`pdSfi4Fqu zNTRIX#Fxq7LU!Ae%Z{3Z3ET1Rl^pXn9br!HtDRl9-92MYue4@rsHLn+{KxZ*908Bf zz|C;6DF$!waBtP_gP zB7kcOaMgpE_?2h?+;fg5cxhg8R9+2Ant3ic>TiO2s8s@kP0;BW%RFYFL_|Ty`K<4+ z75LS+(M`@pCq)<+3KU+p1l)fgaPR92%2&NelK@8wNjS>tI z0;8?(a$d5ONIO2XhVlRX`!B>RUoF$!&s7N(e9S6qc$8Z~xsiD3H5>E(;%~C-y1UE7 z0sIxf0))0_R*?um*=~U$b%f3UDs+G3X7?}Pk?CDs=_vR`KFp3QNAk%<%Kbq$hWFl; znXi6C4@sN*eZb2%U%FoQG}T>(K~G*DK>{WroFHKZyGTlotnh&Q00LGnht6dP%z+9z z7jJ`(EtAOA_Ym40um~j39+dW+H1Vr}6Sdk{IaLR6$g%?OQ_z%`%LnMqwZq?d=%$KH z94dn5Ei;gcdlfH%M21dsRq+pGV3m>?!ATs@#|uV7|D`5zM;+`tp&PNGh!7WVm|;n+ zCcC1bEbT<-vEP;H*Ji_}mYE9$zQur>%kMQ1a#b91|42rq%e>lRt=gCU7sz(ET75Wm z@K2__AD>Rq9C?_YWlR}Tfk6=IaM>fcP;DU%kR8@+ic!kfL!aG!Jg{zM zLY*pJ7z_p2J+xGe%EG37LW=(fH?JbgsfFn6_S4@Aj=ukLKi>{ z6uR#aw7AxrW)%0>L$mQLUcHlDj>~5fP=Gc@97XTF>riWaPc4->jLNGl8G5lts)=4F z%5;1qz91EUMlZ5=XU~e1ieG%`t}Z=qsQmX+sar3?I&#{dC(*|Q>&d4Fr&F7_4ToeW zSWZ$*SU+AML2;6~Rlfd&YX!jwp^|^em^xM(Ijim1)d-C;(OE#}Er`t3dF1Ju>C8)y zHKfOW_43 z@~eLjg{6M2@zhOO?vR_VD7ryd&JYJi$f(N62F`A(p#zQ5m3?%vXyCon^}Lah zI{Q#SuZ{J5yzSM?+-a{8Cu!-WY)n?6cv362M6VcQa;`|$0UBnXk1%Q z35IpKK$(ajLFQl?6F@?wsrG=4%VafwG8aWMq{E*1W%tcrQ8@aV>C%lyF)Leqb*;m{ zs1fx4*Y{C!d=HR2kZ>pB`gg$#m7X_qQ_1+*EMs_zt!+wd(o{AU`y*~8aEEuPG_U|~ z64@^T;|xwCbReJ%khB2{SSq&8E)Mf#qaaNio~X9*;onLs^RQDtsvC}Qx1I+luD^g8 zi!XgI_G|fGDzsF5TWPQ0D=knpgn!@|y@r!P2{?&}KOmF&2>OEc{?O~PA26Uz>>}tyv#os{eP>c`)%TW}ln=;jO zteglhI9>g0c~p-%ev6p-S6Odr#dmgFCS~AU9^qF|uSMb!-C9}{KKRGUlEwtC*1|J0 zpKD66nQ*<`!OXGv1*kE@h_mm(UflDlXo0_X@y8i}w7TvFQ&aN7+CBH9mA8y`W>P_b zoOHQp4;ZlnnFomZm zso4J!5}wg7+N&c)aY2(>Kz3MUPDdqwFT6fjKG)0u{`00NaJ@&GQ4L%Nsv^GeE$%A) z!J%OA1FXz+okO-pHGjnWxgQ2NRTjsHzm5BN5^Ksfg&UqBg4>nF8&K&C&#O>;$!PF^ zkGDePNDCzIuo1ngSqmy>EEOw2u;ExvERChPhwrwDQmC%&cP0JmIm zI;&2(ZY*#Ah@=0B8Yl-Byfw#I-ybB9E8?9e$^x_N6c<8eGM2jds>9mSqt&i1qv)ns!o@xwDQJ}s zEp!5^*^jjuZP(W0;2_MW-mu7l5C{kiCp^6D&uCz2ER?nxFw6Iq#bQTRB#T=^AhMLl z-W;H6NiTEmG8Im3C7R3gn0?{K`^Tz6x1GRD=Pr%Te?`^z1&ZgPWGq~^OE^dx+Vr7M z#4-W28oGGbMu`C8&=Ma}e`lSSHgA8${XQ?1ztI5aVp5(Q;EKm&OZ zy#q5I7HPHTE{%f&WuW&9rlz#uG9dVM4m7*#Z%vP6;iV-&^6>p1U6WWAZ};@6)F$P2z9dkQbIt zKd4X(ZC7H3O*r3l>W-2)rWMB)`6yt327EFtis&^Esvznkb25%kI)5AB|Cu&)Hs`V) zqs%9eWiRD_sezRL4%dO7eCDXX{`m=*94cISNa5yRTUbNIIJdaz3m#PxR2=t|iDW(_ z8vVn*lG^K^3p3w_S{(H@r#I$iS_3w91&l^Ln>DBAGSu9bQIk@7=G5cy$gAypXdHD6 z>oWbU_v2?zy;)u5Rh?gcVecv7um?tPPg!studq6D4LWWF9q8y%FZ122<6>7N14+V6 zBs>I^xCYY(hW8s&y?9W-6h88kcp^L^T?R-*d{7-E3BHbgDLsCUzQ)~_4Sik@OXWaqyFYQLtUV zLc#Q&WRDbv8|yrr4iR!O512``GzMpT%+NfP0TjaD;byZ~uOm(7ym&Ys5paQe?03N- z*Hg{^)>7;7RwbqKd8Dvtr=B?3A<=gOHw4)l6M^K5ZQL-cm@Yx1R30S?Uv%*UIY2|} z3)7!e@z`Am0!h+yQ&_ATPn7GoX<7~A*A+Lx*4_1AE)M?QF`u``(C3G=GfYU!F3aw< zN&m-YWY^R%g$R7|r?j7GxYz?BFwbUIUy(DMWfT&lbL$11czLM(cr#t}-bY-t=-Z=e zd#8u5+wK!S+FuKZ_T!}ou{?KQNVR<2F7D^Ov%Q8OdGft*`XTmc`-XpGTS;7@$el(| z17v|OPnUtl;^Qn$3U!QfM_3kFO;& z9HlGkTvNYzrO~h^l$RuuPKe&*9mxLZ5c!MbdM1hVz7?tj5Ar^Y--wEHfestZj-M)o zkEbt$r&~sS`CWV3KgTxt_{ZFX*>xSvlIxauJ}cPiYiL+Dz?$@CLuuNNYn&+4Xbi-& z!%VQV$@~T(LV-q#V?hevD+}WVwpE}=S&Stq;sL|#JZDH9P-;M%Suyk5t?+XDcyh?O zqJF+jSt1O%dcI80S!HA8_%lDL1M|eY;6?GF0;CwfGK}r4TQ*xu1Oha~4~Z-j03F|n z%)vR>K2Hg%V?Yzk91NKU`e5J)ee;n2_j#^qs+?8mmr@qN8&<=flnHTBJwkgWZ3Ke+ zWDUdW*H{19b<*F4A@Ev7u&W+JPwVH|-jG+%BUGx-Na^UL%>-iq0&oxo2D^oXXO@#4 z*<{RF;9&DoD8gC2j!NvRpJ7BmdK~xp}-dl-&qdH)Gpb#bPNU#qNlp)c`$znQ~ z27tx|R7k5tZkTBRj?M4Ay8p^T?m7Qtp_m^#J@x1Ju{q0L)6!}gE{M8#z$ds?Ca#R# z+{SMc257oocV{aV0;sIWB=BWfQ!^_KbDhvU28=PMFo=lX z83x)zC9UjP-n2*l$;*Uxp&V`ejMna~BcCh?Tm4ZxN7bZglqsB;#j`v@)t zV}!8t^{`fMMkPD`!KdMYpMP2J(p00SC^$!uSY*o|PvwBPh{8$K-E<*2!6FO|a{`yT z0}S!2a;rn7_En7-Oq}RvU6<<{eZ1q-97jSsbUK$K#cdE%#yFqnV!VC0J}r=lrvac ztzH&O#o;SI9HBQF((}WSWc*^4U^w&4l4lCCLwvz54uo2PK%3~a*2qg`!l3-ah!OM> zSwY7Rb;7`qK8a7S#}~5QK@E_CBC3Aq)~mgnnTZ`QZdj;qc6WI6gDT?H{g=KM1=EZB zN4^+Az1L=c2b8WUdYlsGDM<<8V_23={YVbtz&LUcIT?ptPVfr7%kdK`wiEH4Xh|#0zZ8|<9L4n~L`IVd*&+qT+4ZnxZ?Es?0JR^x|gwN%?}CH&D6Txg>AA21xv zl_#ze*-}d5)n->v2kut2bsLa$S@^)Pa#YQwK(cFvhJw^73R~2=^dp{}m6dBpd)(We z&%-W8f^Ey}Gn?O5pm|RIdMo=Pr|&%0N$w2qNOnw$N3>(mfUuL)lq&KaVVT$P-}$Af zWuYXYX1FL7n9R58(-!i*eh6|x)V~7r)r0CH!B6~DmVuWIMPTZRzbSIsbt>(7OG^vi zUwjYIXo-NPE$pFN`Cup>2VE+B&9n``-tMk!<>O9bp(cnJ5SprD{M>ZC4q`kL$j{DT zps&nF1p@>B!4?;^UR3z4B&(PF{SB83{$5rRP zhCt+H1?5V~s~HsLsi%z@<<&mQYHT$BT8CAty6bY^ty|85A{uPIzfC4UJ|!;LjZ2~E zmh83V_CQe@96*2sDB69Ud{}-Wuju|^H6mUpk#cAhvqgpoXsxC_3y$rkP4J(K#R&C- z1pZPl=}C#|aX>98@MWn@@Tw|&SYIcPo$WvKi9qLwd)P`3LF)M~oQ|-aNX`#L zWZ3HH54aCF;Rjc}R|r-FOy0k)?HruxF{j_y>EG>AajXN~+zRRS9O&ead|%D9MTjXf zA_`!|9VVMX4;NIH5li68jT9oh0;$0^zdP#2S*=P#4C2{!{@s?cAP9DM-kOTPcwWIX zt4!u@s@j>wA@Q=Zn|nC2Y@Mwxh%{{AkdOp@3%2PuR+`(Z@#aCaJW?L*4yF6SM0=+A z0as5b6rRHdE?Nm4NBT#%3_m%(pG$@|xvK(cn~q7mebA;>uVo zAg|^-M1{(L{iD0?_br0SFfWCSq2V?}O>Tz641aM$u3U>b?f%D!EU79g`i?ag22WoT)1&C&c2RiVZgwu6nvJx^}` zIdw4Oba}Yt>X`7I4$~eiGw%CB%S8*E0sAz_He3L>VFZM>ZDm`c&6{#g7p3rGBtv}Z zvGn~;ElLs5PMBqwVsHTSB$3e7b*R04xPmqv5+q&La)-!AvwhpCs?vwhNnU+?n zx}V!A{`sE22mW)1U(}9rowl^I>YinvFxymCJX2kWP}lL2?@qpZyhr`eqEb+%zpKMjdUab` zS@%xds+p0>fI=k7hdH4wrrp2Jv)k{r68no{%MNuU)J1BfH%RitOY07vbBu(nJd}ed z^nIE4LWHr5J-{I`bgvHvcx%<(T>Zb3j`~b$sC0HWui;JMn1NhJB|!U3w&Rw@5obm| zgwN|Ac@c|2m4}^F@rwh8x7H@BNV-;b4(N*si$k#3cUCYP;g}5~GRS41A~prHV^@Otytz-IXotY9p(X!m)n-VfH_#u%++2Ul1$8Lzw>%YZv@-?cPnr$ zu2C0cxs(y_G%+`wQg&4YbZr@*5w^Lug`tuFyzm0FFp03AyP4?d*4K^x<}Rl$FM_^u z*Qj4{r$&XR?`$BS;pOMmZ~7lOo0M?pKYdV{m$V83C|58GZ14fab32%&?03$j3z$_2Ep z?h$=a1azP=Y>7GJT}}NS!3s-=)UyxJ8}u=A{dp9%hM^NF4q= zU4IU$DfN2Z8g@IO;$Ws~Upg>eLhQ7eo+NL^Ptf_}EKVA!syltyOQ7osqgxI8A#AqF zgP_=mqAs4%EwZR1eDi(C!J5EUzmj^)?qjNXA$oITqkCT`d8q8g>5WLV3uF!;F+wVM zi!p=U!dro@cy63fy+r#)m*??YaDS_C6Hoj@$c|+?v^St@w!pzq1yzF|XJk#;~}VKL1p_^y5O#gforVR-s!-2qDhQ zlG4oO~(195fv6>Rj5;|n+e=fJlMTJEI)~`Fm}D)!LE5rhiuP` zZzsL`jlRkRF=Ui}t7)6NM3)cu8qv&{4fBg6SaFpuj-A7CcJ*&oJ@Qpw%jF6dpbLIG zV*YyLooqJVHfq))99AnuDlqfD7ezO%m6G;T9V#mUbUVVf6E$Gj?6$xsC&w(l2^up) zwmm=FNP|kGVhVQ-vDXmkO!5+urqnj9)IEweBzhVh^8#IqJ?t!x1w#O(-jOpk?xCMj z020GGGW(ZzbVyJ$SiSPQuzksDm(u$#bhxvNg?()GP9`c%4ukW}w%Uy9b^l?zh5wls z9yC`s_&qOv5y+bHCH!_YFXI6l4Vy?hoZh2z&w&Sw(b+#n)JDmBlIRA{rsnKr&ZFD?`hLh`bnziud9ZRS2T;yqr z`em{HJoUBj2yLUKAm(Q_L?ymr!Z`U=4)`$h9=kR$L5If$>x?X+K<%I(pqejj#M8t3 zTpCEf%K7i5K*H>GACY$MZd}vU%U-8isGqu2hB<+$>R_oQhrS%`gW*TVV35NvZDcMS zuOhEh=6(xHJ{^LN4zN@>(;fEU-Kj~d8vLB7bYFi83ZMSoVNW);WlidR)jzwP?w|1U z8{PeKB5IO=lygUp1>QbEnfYdjjDR-?V$2xo_de_Omy zEpf&FkY732Wn5=6N{U`KTidqAUvPSlxbvL;G01N?awglzP(bC1(v%=z@qSVfW#=G6 z`V(K>px0rTo4w-KkPgUG7j;XcE}ozGT|S_0&(lN${snRChsn3S@-8Pe#;Uai5|o$3 zpWvo#p^*+`0heenq&b(e1UZEgm{1UMko$9we2xXW8*&K|C2vD__GG3KL8J~+US;JD zk}Fy>&%-mlwKT*6HU~(olzLnS*&T)(_veZ{EUXRFj0-BtcaT+|Oymw8@N| zT!84OOsNMhVu0z>4}hQu!zD)yDWZb<0}H(`8oIryh$ELdIvR_-uEz@!6lk@dK9KZS zewfHp`FLsJ zZRA%c-#j=h=8q(l$Fhh3}9%UT+AF}M4_z#_0+y>!8?8(D3^q}ii>~HXXgdCQ9h-VW0^;!kXUeTa zc32Gzo(z${ecbnpb$3{P5i9QfBcw!s*5EkxCK>!28WWf0Yn>!bPXw(tpe75zp=-Sd zOKpQUl3PW|$af<=FO$eV7Wa60-EXJ+($n-Yr`~J66W{t0r+8&){M@h=d#uYZ?Zu*G zTIqKd3@3mv_=!w~cLnbGpV^7|aEMd~zXO!M`cD9?e9uyKd4A&?9@6wQN1vtV&qTRz zPw9#2vMqnPgx@cGM6D!*WB?cnjVu+kmu(jh9jI@2C2k?uu4vos)Kra>u750)%9G7| zzF5t#ckYGBWXRyB_5}<$W{e8p(di563KdRo#pVaFfwYQUYTO-N)Lf=&>tKrav1Qd7 z&(yf?m@7u$aA0g!VZGe0x*zIFi<|FI&Sg5TErPgGpgCE3!r1ypb%wA>2LcdDM)P1! zMeRj?WB6dz^7;a{mV1rB#C0zdeo{mm90GFUWuXAQfA*SM{h3Iw#+2Y8qY-mpWF*SZ zkt?$@vv_LEZ^x?IybWt@AapV`KgH+KLhtyt9~g`ip9I7E3N83mwfLr+f#af-+mcU# zGQR4VE%K`+^@%&tPG`94IywJ1y_&;Oo&%RlTgeGcyq3oWDgo)vLJnCuF{{vuccZG> z&7DJh(6fu+Lw{?wE;Idg^MSzIie=%m(EM#sBBk)O^qm8II!5anYXMHC2Q_ny%rIEw zxGt(;-iJ~hq#X;^U$sf@-TqTS(g%mG3KN6M{;U4$tz|2cwXZFp$0IHSFH^tG+niK%Jz~O3HiYN|Je1tN;XLe8Ph)vzs9C zE|$BS{X8KbrxU>nW*meap|380WpQjxTxqe_*}v)XP$?GL>;$!Gj$^xy{t0A-4K34U z^`>)&uQOt4Hb(%Vkn-;eCXugZh6jPtE6C5*4sPQDzT@dw9yj zvQ}$iKh2oOPqOEDM7bBXmL1}Tu%uqlR9Ye7Of290Sl!By?hO6=TK2E6jOw)P-=`YF zpFX#J(b>QqZH8+Ehw*NoDzkPlIH!T-@V~lVz$2L3A74<6N*}owmkUY?fwPmO)ifI;h?4pbeG9NZ&_HAhnG1>tZ*MAugI~Y2k5Q0AWhd`YS6+|MfKtspv-Hq*uz=i4B&;ZZJ zCR)owB%6Zu`8hv=o5{@YImTyE-cQ3I8rOG$*pYF`ZgnYMbJCH|K^ZB-<zr?}PvAWa}-v-|{@)jdDy@za>Tk;3GR6qn31$YHhw3!Aam8^Rl`l-#IQ4 zd3e=|qCQ6R5D9q!)J8Px(SOccI_+<~zQ*IcMaMfpyvZKCavk^_FjW+YlvVoS z?BM(n?Y(oWcVcr5T%~&9qTOf)idGgk%*go%*zv`nAr~o5@fC1j28lAxz@^cZ$T{}W zLLmj}>L}UoG9U)Pqm|1VYBK~4VjES7Da8d9u(z_lo7uGkUtSi+x^z!YS}y+Hc#l}V zO)Y?s=G$&8W}mp5R=yP@w$U$js@{T-wXwZ+oR-u#vjR6S}O$Ui^~XG3%=NYsbU8fSNMpzD;XH7 z=QkV#Z|Q7tZ^qo^eHp#-6?%Xt^AXnTDD0^Cx!k)w_nvtjh@_5v;-V<-7)m<2R7XIY zz-eKr(f}3x+iS7*&w-A@W7)E+R}?88GZ9*?MJhdzWs!VElB63(5{M(Zm8Omv?g`K|!b zO;RfXI#_au;!aoI)`PmBO?)6 z0#9;Oa8#FrrWUc5Au#G?tJ}-F%{n>8qJGG(xefHIq`w5Y7Bdx;vZ3WHnrA37_iekP zIcT_gz|}yL3I*^*2J^~r#9Gqqr=c(p*#f@mfh?^=_A`zv#f*GP&!bzSR@9nLT&{F^ zlOD6mvX?#g=4Si!AlmCN!DGPo%a`QP?1U{0O_-MnCnQh-oESD5B{1+w(WLUUP+yK9 zW3|1$Y{CQ_te*prN3)OmQHNo{6&_y?#I^c8-1n6Ir7V&Ivn)-c87BS;XiPR4bi27f zsp^$)w}t;}n=JdsHc8lr{5X8R`r#_NC<`3-*iqNx7ka7Y7!I(&_iZ{|uc!P0)KB>( zrXBytl-Eg2u^h)NnW>~AANW(fd%u|E+rhpGci^Yx8t4X6Sxk^Si}x-RXr9x(8q_YB zCA4v@3&q_++16sHXODP^g@(ICYefCxgKk~w**^}QAn{xPWh!{fCembMykV4Q?&xC8 zAy3~Ycy;(($NaoDAlby}|GaRYB|o3|6B}Q8R#W!Fe8E>$ijoS`XaZ`33Z&5GnR9rEny5@#7&Y;OG<;+O#P>T#~H*b@Tg$P*~UIj@g-YJnQaxC zQ$4NJpEDpVrv+*a{x@?@lavQkAPz(ZfV*=tyQ}7HXH#oR)bs`>34-$8ICccIX>C9~ z$&-;S4j6yO%v^Qr``4@*ORnR*?)vf(%2r7dW+RH$Sh-e<;;<6DW)CCdTHCHqe==NwLJ5- z8%Vp5lLK`eRK+$o_o_2~H;N>;zu3c2KypDkw*?=^=x>Zp6CA`n=xr0J#%d$ky@SmW z({^%xiXkfOS;=J}-H7`=h(;ZZ#&U*(>>fDI z@%Sdk-NDZ0M41U3;cz?uV)$XzfD_jDf67zSkbkD!L27?wAKdhvH%_GvQuQXP%Ze<& zlv7}2OhZ3UChUVbBs=-Y?R$+m4VeydquL827m9dmG^9lREH!Ak4T$dA?=pG3qZyEV zA8J+5*ZbnKHe(h%XSpC3pri!Z%V21gIAkcVi|O>vTKEPvHM~8*okuR5pObC=BOo`LDdI~d<_*6~K59x34dJazA0yFAQ# zV8}z<5E`@NU^|Y-tcjlB2QduJ3t#Z80u|`ACWbwitAlu^>D;IiIwEvHH$(ugbL1 zmgd5q>Bl%|Y_O}f64=Ls;F4_lgTg8i!oYxcog6j6s$$==hp~lV!`bu&m5S0*ghwcZ zfvoO%8ofri6*1cF9);V5biu$UoE?^qKx7i&3|7J*@-WL|TRX_+>Rp#Jf*Cf0* zDQe$}zsYj^h^(t0Sqf7|&=GuG519{~hQvL(Hn$KM8f|EtRs!6Ziq5kWr)+07oq>h#+SYVIO!1M3Gq% z6nYnu-~A1isu0Eo6|qx~h@B8xHWYj?h1tjXf{5C_SueEvb`zC+=%ErHO@I$ftTT|9YJn1?mwPsfx!E}ro|6k_6TOiO z3(LJV9TV=5w&3`~XdED$$t=mK`c2-;(9!Y7t@W7hZBJtE>ycl~P?g<>i%k8l2(Qz}`cJ@zMF zl{D$xH$rhBh|_1y1MN&pM}jD(i^_w>jycp3IWY(p2+;_Oa^Z_6=z)+V!TiOY;1HEG zgu)ObbE0i*MWCGU`C=`p@7Z16xY`JD4C7IWxt$7`R^6kS3>$JTOjS3&FbNL=dQyt~ul-rdohiwoE;TdzTji|;@gIKs?X;`3a_owmzeLORnN*Ubq5f78Z&pRaE z?hi20H_i&J(fKL;w;44bzXZFSOr2~Bk93oGO(+;5{rC!l)uCQ-E0xifTQ1jbXvr9L z0>LkTpaioxaY8Qfi5(*kONjYEcZ!HarBl zI<6Cq3b1k1KL@o#mlv=2^Isnv)^hx(1$am0LY|01alO0aJ@CK5E{KTeK|BqgQ@7QT?i6tFCm#uvS z!P%Fni~CT)`32)ny^Bm75+Ccu%l%}A9|CtwOHyFwDA)Muo=e=U-Nh~POc<103?F^F zLaKI}s~BGrti1m8KzIR+U%de>iq^V;G6zV+d)%;`#YKCvCpbADkc@ipdvoNK50eU& zOeGRZuhq>+nz{=A@@qc-3yj%81XXAM8XAmwnv9-v&D5I>U{crSQbW%O7{rKarquhf@L9n*;rol#S-Nu-}!gL)(wNfr&-ZEg_`l-E$lI61FyV?A3dXry_Z|>CZdVeic zcy+Z~>EFV?`@dPEFiZk)$+iUJ}V+iwuQR`e*wEQu?aW-zRVR#>90#&-{hSEirwgC!HR_k%RYtpg+7ZTq=4 z7di^Hx?@(Y5Ar6Wk!_jWopvng3Y~w!F~9U&n&}|0+ZU?^C25GcbAMyu**9o-f!z{% z|9%okxJ0k*MEwNKf@3B3viG2oY#qZ^;=&4 zxhMjhEbl6tZH$m@Cn6a5;73vLK~M)Gp%ix;F6aD)r?AH#2Vo_*b$g zX_^bnbU@uD__8~KeOI+)w+QH=KBxJ@0&hgOxcpm{;(T@LaEh1Mj$xkp(b`R+eV~*( zdnX)l-ovcR+i2%xw-Lo&|H(`%$clhV#8CGUj~F-zi-UM+TIhXSto6HKta11lFWVA| zxC3qBC}Mp`R~y%~ZileQQ2ok6`g4}{V|2xUM9vde#{(+4?bdTk+sCouTHc$3d3zg3ro*V%OCcvw`9V8Bl!n z?J>LS^lF3PXzemLj;RLeAG*npbEJGH0>Wmum&fa+)X%pQy{2N|JZQCPxXnZ84P;&P-M3khygv14zs(b;T(uXY zi&*Y;WkD()5CoExX7QJ#S;qPM_2d_f=^ww zl>S32D2fy5AT4tNV=-{%&-VDXRHa}Q21}+>W$s?KQE5o4y0#cA&sK6&8m1n3LDT{E zx{i45@4s@Kn~qx&?}V!Y9SD2eIlx^J{|>&xWcKpf(9ce^3_(y3(0;#tLK)T5nJiDT zG{sTVdEV(}8moD^dWhBw%}7KZvV=3MM(!5!)uQ&%3EkJ+2X}458GCRRtWy+L1)d&| zK++Zvqb~(mn)Wo%x)3%MzJQ0M2mvUXD?)8(gpNCn&6Wofj1_66WfnMz)9)iCC>4)=# z2pVO6xH@M*1U?of$GAOdYTS(4*97m3MBXpcr=!0!6^eYGi-|o4vYUVjQE!NL?at1g zZDq*T7obC&xS~lpY&@b_2KYW;k3=YLaLG5S5hqTlFRwAejA4oJJedAaP@D;D^0ft| z26%hqE;wytwU*FkD9n)`JD+Jmu4lE`N2PBg?aXkov$L`k>j8V8c1Ns?&wq&*ukc(7 zq9OKn4jb!bOt2X*9{t@4yFTCveaq%1wZ3z0(b_@Uk`Mj_YX19gXt7hkOWKn^uk z!%U);Rx`eJJRw5Hlb6EN(ABY)Q;^TAO#Z!A_M+R-@~73L;fG~e9Q$u{ujSu*^6as= z@nIFaRzjshlk~VbW*1f(1&>u=nNqtqFFz+%%Qd_$>NrVJ27DRL8q&V5T-8OT+~i8xXR1Vro0q|=__>|v8YUMWWDn)DM^tJafibK3!^mclzig|jY5XY zOpHIqPu9P}89lUGbavj26?(Hz*IR|P-5!ggs~hcE#;r+<{-M{0r~SM3HYE#kP;W>< zvbM^?ap8lnC_4DEJ*8a$PO)mgMm~7}A{Jl?dNVR+1B{V}g`AuAsIYTDN}>Nl`om`3 zOc^X@4J=>KOV0PK_O6RS9XPA9BMKqSWJ0+^Sfow-PR06pDi2^|C_i_tQjy@V@ zs)05Ic~xo%KvO@~n)i#6ex@NXce-Aj_icQXg(>_K`}J>99R5F6o3g4`2mObO?7R7o zE*HZgg_WNf^WG|w(3+-r>j$AItor6A5X~sNyy3);1ixBRq#Y?QwI{x&*v>8x*6IAw zohStm`napntNqnAQDONWTnFpCBweVQ|cq)l1OL*aUeP-E0%GK`7;ang8$r6baa8(m5G6z;}X zlfU^&vU3hNHQ<Mc?zo2T@<{8$# z=W8VNoy|cait|3gHsoWJ@ykO(9+$Me_zSJI>64S~yC%Velv-tuQI@Grl9sC9wHq#4 zOt_e_c1llOdjuw+PU)vR-19^(2uq;T01p$%}9{KXmIjpvt(je3il5Bz7tLF9Yv-P2tv#{t! zCCD!Kr~aK)&)`XI+ef*g%hx<^n-HniIBzg_xHSP^e_w zMJ}r&?-d-r{QG&E5B>9$yXq!#ekwm!C$<(tWruF(K|T)`SYTI2Y6+!6-y?aU>sBaPO&NZ_dF=P`oGUr0rvZOQ z0$lHv-y0|4wsH4ts98fyz5e2V3KrPs6w58_{^bWL`2{=TW{TzvuiuZZmO-d8n%&xd zny;CdF25|3%Qs~>ClchxFq|acZ_hs2f+NmVeH%|+E`1!C`Py2F_FztACU$hRkb2(} zW#2Ie{c%TKROS>|kBUT2CP;H*V@t^*1V}ugO_=zMM9On~kKefl5>3!9EP>qPFCiT$ z*tw8iQ-8h&k7-&0z^Q%tV|&GNif0EWzeiQ+`b_R?uR=RVen*6jJPKS+Mbca6D5gkm z88^4eaDWcbs6wJ$Xz{)-%(Km#d#`9NDNp6n?N^WN1WPi8qzJ76f>3){0~*QxSp5Qi zABVUyj1OgDgt#-bUfcZ>m#KD_tmis3oNtBczg=U6>Brn*^g3-bADg@&x=&bXQ9330 zL&rH>gwmbF@5S>^1KPgyyP(T>FU|J%?tGH9Q4+YADG8&{r|9A{yzuq(vV{1K=+w-&m~x! z4;*T|qa4dnQ8G2bDJck?TQavJRivhNX1t@9_pirNur6Ssl$2GDkO{d_Fr5_R*oVIt z@!ZudR4;4A>PA2O{MJOLlhs}W#8eSF^ z4hnq~K1H&~f{hOi0qR@kq~sog!%LbO=_P$o$Pj9O?*&HjgR6z*i_vQc)NwzRy79H_ zt$6J}iRisc-*}R6e<%($%;cL{FRyhfyJ;mo(}p!nXhP#Zch%d{%=sT1fS)EV6W#L+ zK0@c4U|&w9PQvY$5D?M4WWbLW-z?y@!&jWuCOB^F-lsT5`;kG+RFm2{7vxtotB7x8 zWmFhAJlNl<>TJMNArSp-J*s7V^VXXnn7?c|5|u?nDg9i;U_2%XL_ZE=Et^RL`x#_y z>t{Jth-Qws1d)f?w`TANVLUv%T;bTe^S=AMjYi!9hHPEve?uIe{$s+fRP4F-s5Fq@ zfbd+<;&YoqUPRBCr$jb?r`^tk#B4ieBX63Em`}~VWGRW?9Rw&?=mn0$&mBoz#}P^J zdLu6brN=?A()MVh*H1vqzVt)-B|YPvB%yi9VEg{Sh%tl$!!iNvY>$*ThgWuiFeOV@ z@3P#i+xubyVqoS!ALVC)l1b=4By&V;_`wd7!9W_|>ks?6} z&N3GE!^=8}MvxU31BNk;_+a9$^)kt~n8%Xsb_U%(!!?bwj=kyVzi*D=seBxm{?gZw9VpIHVUmnQj460xmD}k@$`4DLh6a7hT5i? zTdiYOBGFyJYUqJAe?&*jXf(-Rv?!&$q?q;r`?!P*Q-8LG#L+MmasL2HKqi~ z*=^VKKrF8+LL}Y-M>F-sPN>@6ktdb+$ zYk~gqF2Ar0nVSH#hiEa{v!+j6XFfw zm#UmP0qqwkfb2`HcMyrnRFzy9YBNf^NODhxo{&?#FexBk6NDfNym7JnPxocQ1FnC~ zrIAeleyGXp8Wcb}9*g)}Lo;s$2a?7j20aY$=`A|*CH4;UGrGvOOg8X&i*a423}Fn( z{8|gZ_jM=PKXdbvFLK;wgvDN+7u^9$juK9?=;d4DPAGjaPpSj1cph3$gesI?Q=#EI z@E!y?T2;=6vEIkyR-zMcscezNFji-6EdeozYmEq<0gw-o#|0-lg5>R!nXRt(aOr+n zxbZycqNYuuVP3BCk$v37p_!??Pd z-df-KFroS9t8q4{c8}g?ZORNVkiKjG)gUI!x96{TS#%<};;8sCcn8>*QKQEgRUxe? zGp`ZlKvB_Jp;9nf%Rm(eIo)HxN6ASJujK+sR%%A(*~Ny>EH%b!0DVC1&c@n8{T!nrt%k+e?N63V!xn#%vd4+?3V-4T@(g?#t6Y_PR;_j8}33B`x}Fkzejv z4V|yfEE3QM;ez$DEmIfQR7eJzRlPIE_ z`6v{?_S==Lj@}ZhV1I~v0B-qz(dn|dPp6dNR+cZ6P~5Q5q?g?tHv?TfN1lLNLERn7 zcz&$lKOi~9AxFV`ig5Q29%8!Pj+#IEnpUa|Dm0DP*c$bikSCQOy;Z)Qu&}^w=8UJ3ru=#${PY&T=jffV0IxvXxMCAg4W5sqpf)B*o!xEZu zGR^sp(~^<+m@mG!IwPZhSi#FHcopgBH(twXr*zl1a7-}N;}$+>@os71d=t5I6Q=e)2nC^pZH!s62?=JreAHCA=OZD z3xpK3@PYZy`aeAsl2Z zK!=Giyqoz^kYVH%oScGa4cqeRhsV5)_s79X>fT=FYYpt$RZwXA^Qxyo^t4s$I63#SN$-RZvYX!tFG;p+zn1Yw>d%Up@!$R{+DF?po-@ey0 z3BT{eha4dUFjxF&;r{57I;&OUYo!ro&(3<U+107{ueaCW+DLGk`}Wr+wxh0%gpRO*5m{!&z7tjW)6LedsMg`{ z65up!vgZua5yQ4iFlxLHTH2)+IB6qGM;IS3!6d+|w(VD0dZnHp1;sHPpy z_WS#6OD3LTXK*{S>I&AHsC+se6B=m6g5S!@Os@E`i#?$kRM=(Aq&N|`CSh`Dr0}dx z`!@ONaIKTKp@FdqT@N}qZ{yI}Rc3eY8-lJo-!Igm(mDc7jv6@zoz8jV{~x_7c^qs*nQ}nTQ0knVid;u!m9T zG$5xm+C<2ml``H;8>UQ;fy&5pc@FNAN27}t+w1$vvrap|QHj|D&X(T|U&>z4IQ@;> zc7ue#dwz|1a7vLvlUDUq{^8v{uK0+t7s(au%Eod;x$nYs$;UvZMk~|@RF&c~?B&wHkgns3|n>o;q!@Zv)zkGU0c!N5J?etJ43RN^?f)MWsfzGk?#)J|h zH`N^|F8h%-;_S3*(tUX;3$&>m*tI@CsMp9?N<4Xe&(v{6Vo!$O!7MBwpTqDl5^`Nx zaxUP^Ywk5Sb6BDvn}f;R^bP%7C1~qUsak+x!AgR7wjDd^zFwjp_H$ZBfhiJn{SJfN z@nU`M&DMb<-5mvYTd5Jss6pdA?pTnDh?G%`_+!9Z-jW%-8p`*HsgMr{qTQvDA zOX$La`F7xIS_cqf;uUq$YW&7=+CDdtk5yR=?JK;Gc#`nd?4b3x zZ%{5V^^K%7W{Xb`vk=5oSPgC>YmBYAD!6Wu_kj64=;v8&=~3a4ABZtM@+Mky{XS?~ z^4|6c80)}maNS!lXi0e!#|A}HW<5yRE6NuC9q4Nc%WMGozC1oePq!W-<)o{WBL+q- zV-d#nt(l|2n#=_pLGsH>uIXk#aE|n?eNV9p42S!M6k{b>^xv%4ZqV)g*~ggW#QOCL zvm9jCZ4kzXx5CAJ_O)zo<8HW^mP0d?3_Z_|xhw&SjF@47>$Y*92SzKPAqH@7I-{Jzcf#KDN(}IHvt>!#Zn-fk-&t^G-K0H6zv5V$_X zvI9x=*^lihoI8bj@Aupz{4E`zQjDHf{q2!n5>>D5&mdrA#@Ztjo0(riIK(G`oFc`A z_Y+?MZq}FI6k3D+A}p^`89__)y8=_x2k^pk^AC!3fo9cUeTC3R>ML`}bht;eYt{sO z_H>A!7K!O2j~^8lDQpVrW(htsIkLW^%+EB3I>{qhXjIGQ?EEDwi)7T21~Tk##V$`||H*B#b(O;%UhQRp(; z)VC_-BOks;pdSrE4~SL38!4cA6d*{Igx>g^&5*1D!{JS zw%89hx5ayuJooy|o+m$?0uE|nepnYF+rA}i_6{obIqO!tczE((g0R$Lc4({2O_dJC zIM)CoaMN%zDzruycW*)QbEXhxeasRO#nPEyI(dJmUQL~t7`k^e-A;X6>!S^x zUjN@4aF0fd05BuzbMX%Rc+Ha(=x+3WS#78jr=N@&%pW7;p?qoNUF;!?soO>$x( z;Yne32=f~(D}XhoL_5G0Q;9|~5QX~d!d(O2%lMpg67+`WnH~f}Ia+d~*WYhd-=0nv zCO`=2HaB~^#ZxC3dWg0vOO8G1QM{?<1T>285bq?-)Z!_wr&!HTXl{>D#Ko zFOwXKz32*Mq;L%NpfiC&Yz45v%|?gbNhdOvx@PGdp9a3->UU*@MeQiFUv}tz%$+{B z810_Wz}#WArbB*8?nvRrNGP6&+JrsL;rK9OSzpcm@h7Ol9ez;Y$e#^#Jn}%>aXQ~l z+HL7W-8BU9&xxCR2Oha3Qa}&!p z$vp5jFcr>Xd)GP0B|$Wb7h@6+O4&!2E@R?L-4Cy+puAq^Yjjv<2A#uz?in=*i5Q%5 zyK20%^~c#x)T%bwpoyXC;L;WG@r5D!vbTy>zU7WhXbfH#s; zoQbH-I~VC#R&m_b5?rjd=iYv<9@Y2SPg}OlWQ;mLS=OJ-cwkJ1u|h|{<(})<+>_rB zDDnZsTLlPWvUeXcR$pr4;o^UVFV#Tpl7h3}l|;b{ml)+Wrj(!zrp%}ym4b;`!2O*q zyHS(pKM|v7UcN%Fo}H3zvG2m+-gnk{Q;wJ1=zjLCD*czWr`aMInb@{%Of<1= z+qP|6n|r^#yT9S|>F%nosz=FTEuc%5AP!Aa%;BZla+`PQFLV(XCc?h+FUuHx0E*Ba zj@$(Z4uJAk6vaTXMZ!sWp@q@M-Au&EQTw)&Hg#=f|L~pXqr>Su#iy;T?q;x3Y=8-T zAY3~5&p|Y+`h;q-MgJ+b;Ze@ss)^!Hz!Msc{~QQfU%JC3(>{T~-V2OAFHV{+dY-&`&FWR_&>pO|b47IN}vADVvUHIh=T}21d$XG6Dbt z+u>OBPEsaDRkJ~L;P(B{+qRAOUUjnewIJW*bH_qaog+(2lgN zkCQ?)thHxspkS`RZ;A}KZp?=f3Z$?1PdQ3rezp*YO#5usGLvB3l>92CmX5^9;=aE8 z^|o-q{OlctvQ??$ZWYcpf6cqDIeuI=C1Sf0_5#{sJJfQ$dDKOgu^2@Km}@7ErWnAb z29zyBowB!hhYE4T;^jsm)xRB2n$^)mWrwQxOfzS2#rWX;oQ*|(NJx8Jd0Z&>vasMP zCGW6dar)?d395c|c7$(ZuFXj_H9FUXVyQ7OAo9Y3jsyjwYQAou#?W^>u`5^!3BUmz zKKe93?8y2Ipx7mx=&o(GB&L>{(R9E4whckeG-DHI^i`xk$_j#0EW}7_o^)z+LqRvs z+MUs4VR+qPL+R{nZ-31u=d#YE_Nd=U+-)I;C8&u7f%ZYwN1Db-C8dNnP}Zc{$`QJh z>rS*5h!Lb^2>nW5u)E3(gZUmN3=1amP34241@*}b0#ra;R}V=^G4F4Z%y z_kOu4d?_)14aL*ejDI#UL4?F4`x74^2*rvdrwq~;FKuk`9Iu6s_Myg(BpetJ6G^6` zoFNKLjXf(s!Qrt$Ge4H^y5F}p{P{cp`S9^_i~T13w&PyOqm#|%<}sLLjW z0(OCb#;IhO?;GJd!3=PpVDSt4UPjud&6xYs-v6iM)hg34^DE+L`i^5?}lK>#z~ZO&v(clcqgXq% zRsVv(h25gee%{a4pO>xH&aWai_1*N1H!5LZh;&C(1!PWlaUlvz+0Xp1i>M6YKLBT0 z-%oX;xKJ6;UAgL-ekTZA^p*`aNopuA7`;3d$Hve5K7bLlSZ)SYGqmpSPde={MB%Q~ z6!I+xY-RhqgH{cKeq;DkShOA%i)#rAE}8LLZqJp;Bx1${9q8(T_3}=wRw|tYIjdgE zVFrnt&HM26@bi>Nrwh8GzG6FC#^*uL%y2sxw?+9HMzj4;Kht(MiZd6->DRL_D;VV5 zUw+FIWBK9VqBwg;v{9i31mnB0oXAE|2{~%prosi@B?bnpB9}OU;0fP_FTy`I%pO^c z9j^)*>`W~}Robo5*jU-y+mtfTJGLJp6)D{-P%cgvqj_gn7u<7Z$S3U*8J8lG?k)mD z1v((g=Sg@a7+Jn{&}FViDkJroIJ7Ocz_-kT>c1h+=4$4WC>ZG&k3vj#TchgZLSUH7 zSz7?J$HUhWa!zLM>9YL#%*tnl#W8!&jvL2A3Q*mSgFTyH#m=9lti@;$w6ARf#L+dG zO7Y-OJsMd^rsDFKC>EiVLg0fExv&qvX>G;9K##szLV>F@#^uX>~U-}e6;0wkr)jE<(~zts}{Lkq|V zr>u;fd}<%PX!Wde>eh^?Kn=94{)C0FVj?^|76pdBh4urok|o|D?eIFce1qWFtLcIx z&}WsqCvS+YEG%}l4N7F(lV#Y%WG*fN$H#-$eJaLNbYa-43F>fv{V|k3X$f$+I^B-5Mrjz}LB=Ori^c z-D*nTy00x8C1U{o#%&i5tMlwp;Cl=+V46vBC42}s9=OJZJ$kir^*5wW|57Lob5pza zczU_GIcBNd=EY*N*d8&9?HCe2lzI5z6 zg~N9oEU&;CNnC9tlEiW;C5~f6;6AdIh7Zh=ncYZK+1@YtIss|~CF4D6qtgW}%evD_ zYbBHQjDHrs6WzzU`gb=2ixMai-@iPj@juXaqJGbXB7@)t zx8O3y0`4WDT}Fa?G!uPlP~Zre7zoFRmzvBlds;Y^dDN-mTACfjZ;DZYad>|TK&VAa z+k7y}ls)10v(r*JGU>)IJ95t6l53m(Tl!R(K#f84(EMAI=-=HLS}qqpUb1jXI>ymT zMAACs7hEHS8aOh9KsZ~Bf4+bX5l6M{DAG6cXC8vYqQ6?lkYqA`!zGP$1uuO2J%zS< zP(~d$U68X}S7`qlbn>R_+EzG}CiZxbf0Qwb4)JGlER&PJ2JR+k`X)3S_8IyHjuZ2y zGxh*XOW+24H`Jcw`R`noAkNH*)_w^|xw~~HC!gop?4|qBR_nsdn#Sm(-Ku6GckOZ@ z3FJ4YM_PK*j)J!8APqZ(bL9S?h9YCg)yWT?xYrVkiC#?b7i#F=1sGT@2vGrY2odp1 z)7{Xpk$WsXzX^RY8N*2~h14quPnP=jcE%PyHq!YDYU^cjL?UGXg2{7PAH-@+tKdwWvTiH}<}mT#=t}06&|i zGeR)nw4;^$boDxj`?+77Y`i4m3~NcvY&44oV9QXuh)l&N1)!`|C#;Lj5K^+*Zz$NbqH+*x!fM34E_OTi9hs`H6h^)T(ZC>aYsRS zlp_9O6hh85Qu<9~o5i9vf6TE`)TmH{1OKrs za`MFN>6_VH1|Gl7!{uX0?$;NM{Wd*4n@hgtHJ8q@ob?Ss$Qz|AsVu1`avjHV)+!=(N)sAkh% zRV&n`%R+u!;~iceMl)1~q;=D|7P55;8CBb!K8S3P7U6z=uvq}cROJGAc(0_^Tt{9# zuG%+DjlJ(>Piinn5j1T-QS!J}%XxYDR@$~N@T^VmuTJmAo!h*u@;h6-gJ-GjqdG#j zlqiF_$ck5xJQya-aY23`fZ6+xc}CGlGQ}+jbn%WnX#2Chymnngu(k#u=+1#5S7^H=wA(j)49Gh6j<+XYTdm*;8_s5zfFU5 z<*Yzvh1L$_*@jr4soMz_Ykdg{N<{Kd++5%}Nm zmlNG;6Zxn0H%?W4K;{i!T_W&4^>;^|FIpL32h*A5*nmbdGP!& zEsB{iR;OB2q;Iyu9gMub8o}qoN6qagZtydV4URQT{KXT5=W1&*j4myPNQrdM+9X(= zpL&NKW%Qa(#+hM|xGuw$yT1e#ID;@eB}7eAFSE1*#FqH%KC+IE-1h`z#CA~1zo8M` zzqMm!N#F#}{hWVD&#IV8t0F{4*Kc?{>b*yN_S$<)L|)ZYwt1Z`P0+n_Ym7V-vk+?g zu)-0tLB~g+m!s!9Gh9IL{-LbM~z9-u__y1YD!Lgre(V|>e#>0fz5ABvX?k=Z&Le= z4mP58^g)w9iT82ls3fQ}Mpt>@mF%eHK6MzK5~$G4W%>w%bAr)=Dqulsve$S>%FkdV z% zZ)u5edC8~)I0ixZlL;QVgD*Z`q9)o^aa4adkNUCIb@-8?D}~2S{?ShYG}Av*%QDDQ z{pNoX~YuCPvnpX8rDJ0mz+HmmZ!yxrD5Z!P^f z+B`BXiJZ=V6GrCMhZ`=-8XcDVo{vNk#11;%FB?nsEpRwbmZv$VY?LFjDC{ONDNQ5e zV@-Cw^D#4>iStcIV;vM4Bck&=;PI!c=+oXA zu038E>ud9mzBu`}4g-9uq{!-ejL06cP*NlSqb_MJJQUKyrwZd|-S}R_agn;Cft!JT zw~ABeZ3Ue@|Hy5{;iJdCWBlaV$?@tcC>E7p)wccjA=nZlz5rqHd3EruivVv@fX{>F zl@;LHBv75imD?Cyyn-c0SN;hHH9;C8 zlErjPk{$ryK!WmBAd~~mx+hH~%#HWQ>ThB1gr&L^yG5^XNr0!6r?Qvz&@aMEqt3H5 zpPH13L}=Z&J+ekf%KVJv!WccI{@?1S5%CYa56`npdUU9T{(CyrXtO!LWZ%AEK7DiK z<+2N>H2_d070vYiexv~NiN$>QhA{m6{f{v}Z5W|QR35J9UXc)$y8xR-dC6Y`Ehsm; z>nlMvX(!JkZiaQn@8cLcd@BpA{a>KZKNF^0#ny)h@iKA(@w`t2HH}U*B%F#hG6b#Q zk6+ceYY{4jc}b$Yi0{KL50zxT|6_?kX-Rv@du+3z_x*;A_ES`C26Fnrxw8&RoMQR( zY-`I)iGb={+-S_Ze3WK*Aba!MOlUHE&0AxkKS4QQN+#(bOV4{5o4oeZ21`Fofk(Ng zx@f69Nf4U}WY33ixogQjNx0EgnKBczbmdeOFB>BQ+@8v?7lHYS)6 z`NYVHFoP6Cv=5M&VqpH9oe;8f+|c5~T5dLGMW*Z^T-Kyo7X)cFCY}FmHMYeZyeOf9K51Y2`Hs+%Z1J3abI_z$a>E^F)vMY&n z=nz5kaVHErZ}OAbn*j?J2nHQ;Qb(xY#rSfL!kGY6LgHKfO%O2nt1W>zfl8qJ?x<2+ zdTqHmG|o{&&&X!baI~2$CF2%}*yX_{e+QY-=-mc+3!?h4vlad$bM$)uY4BD}M9L?I z7@O~f+$ZiE@#NNgFsZm2X3i6Dx>yf0F29iA9JXKMF=&L1WAjKV(Qgg`pW#nQF3Iu& z;pbL)nN&dK%A+~5#JMajo((0Ddl3RJkI$g%zK`A0&Q&?Z#9jB6M@Lhc;4ucv6pA4v z1oO7)h}?{uB`gEW*r4elfe27CEwjR^ND~#TI%hMM924R!Uk$RZI29>{C8a`&16i+! zc7zMh!*3b6r8nZvIxN#2DTnc^E0C7W)Jg)?BCvjH>O&4m(@4*}5&oF!lAY1}7+l>F z*i>y#LAN&LUrkYvHoI7exf!uV);j|7z+cUD%*@Uv3Q+mBD% z)8lTvUr)uNFU~NR49o7r16jUZ-%VX%c(F}m6JJd3g>fT)_QYVBp6oXl?VxbZ(6UooU zSasi@{7X=OKv})0)Mc7Mm)lF(U7@qKeS;B(4#AgK&EBjquE>le0TJmiMzEpGko--6 zq6-2MN>GoV2r!)tQABG6RR$s6xw)Mlo5Zf?uJ7G=jW(?^#f?h9#9MuBmYMR0D^56) zb`n;WmR{Rq*0VLO6f^2(=09TpYqUd({`2LlLa(_oyx$+J&tEqb6s<*ACjSgln1|!q z1=J2bkbVr`9?>|c9)Afd_0#WE57?C$HHPzxbj5Umeqd5L?M$JSDrH!j9KT!QO2G&` zZl>-;zteDy3?AU)@lUxI6{@>5D$#0KqL{$L!V7^*7Yb$|6oVkJQjO;pJX)rhi~q#* zlnA2*P$4GJNnHh*M^a_XT1&8z7PFDqi*?>~1k<;8<@Nop&)!YK@vrY4OmezED{={* zpl`9xFJxq&1940O7xJf#8PjHZK9J8Ab2EC^fTayt1~Ru0-}etN$IFPjTreZMpDmYm>+v{PkJiTmrHc>Z9KR$%1B=ENLcK#H2 zDxnMA+6>boP-0fI63&H34KgGfh;OG$IR&~%pZHJYW)aLu%9-keuyCc-aEV#`2!A8SdDuqVd9jOzefFtr(e!=Ca!_}z zNEQF2*zxtbEJX5cBHZlMgiQfP;hwvNn>vI4=9;?g0 zh#2(~&^Q9xDz)dSUetQUQpVohP0I=%Z&u!75z5EK&U<~(D!967w+T!!(2Ma<2=E1^>=zOPd@ z>$OOCbjgMsMEnjW6D~1%bh2jFb@4I*0Na!RTh8vqH~a)cc{gx>^mL@lUPo9V7{i+L z9A;l?zNoFXJPtn-Kf7Y}U}AiT?6YS+KKkPFv{@J;o?+j$#8L3Km$2qTuG$Zx-(0|k zbq)b45sh! zZ%7}aeQ0EAlnlp#jSOYTpdbCDJ+*&Fp$V3G1n>2cGMan@%`<_L!A*c>bP4@@q^;gI zyVOZ5W!1Z+ASYSbNLkZxFWup1{?=>yP3^Bk?!WgNQw-l4fUVQDCd=hVC&S0fp;Nu? z)$W3-)`49)N;;{`>zbgP4zf~p7y36ha0W3Xu7>=5TYFJh#LKuOO12r{XiZ7zSg07e zk)f00{rEzda{5oxO-|N26HvP(cDqrw==wvL;iHEOPdxS5)-uX1tp2*JXoeAa_=cOY z2dw~HlAM4n)DJYf+p83k94#%hsrV>!V%}j!<_bzd-s0!{-1F<^JlXb78+aX$q{dG3 zjgQDua_#|*TM`Vb^~LQlLDSr>@gyFPhN8j{ua8Hr4~4BAh6IJ;A|}M%NqG@4#2_!J zdwmOuC0G>EE>{%)23FnZ5+9!r2?Fv^7ZES`a!aOxxt1&XI&-{}`?(0I=xi>wR>!Ts zgN_a6->CSGOm(@L9j3dseqBmUf7zX41T;!%ny^fa1WTfB!lvU;KevK-#-X{T(*O{mU!?@+g|kkhZ~)Pq2T?`;Ub`wY*)?TkU=p*Uw;48lYRqku+x zo^xUmL(pz~*9>!UNoB!*U@qHb9hB!SS8y|3UydL}3!I4{7f7l64pQad6JN0{zZf@a8b##vZ%+o;qcVW?hsc6xDAe1fbv`kgSVn)*iglXG1-6OJhkLne3inP?jDb_~3b^cZ z92SH<1FeSOBDIhq^6kB(r37pk=aj_fmj8;|R#c=gb$@Y69{#kb+^X&Tz|diF^Hgnr zf1rzmuU#5@LL-a$Va8{xf>kAE5XA7uYsZ6eb!v_x6`c1qBsBa-#T)V+AR2MXf0?ZE z4-m?X8ufGc6-CKiL_%L1nl6FZc!V=Pq>9Jzp~Hb0|;4N`|H zK}sKm0LM(iQPGOJs#r5^0oeig0Hp-3LM_v3&5*?DuN{CV95u7}2V zmqZi&XjH!N^zi}nb{v@GIDw@K;h)ZD5D<$(7v)plM8qW@Yw%Yv&zznrmtjijTl3r$ z%}8+!=?E|#8rE3REDyyPxpcr(hUaA#;j+^T4c}Zy=cb{NPfzSgY*-HBQ?^q6*saRp>s>{M)d(K<JWg=u*_w??Z_gQw3o6AgPrORO3TJ9MSY{mI+%Cx$+Vl_@sDU{CDc3cqO= z=`{lD$Hc~vEp0#pFr$8>L}SL}K*O*^T}t~iR!sLj(GjzQz-2VM`M~V)rY(D8y;h8Y z-sI?QwNE2cQ7|GAp(}72A$OBS0kO`*Tu4UX_jSCW=2v>Nh^em^3^5G_euRL$U$udP zZmkMJHum#{thedsPj*RM)mP3Ahj%qYbH41Q#roQgvkZ%4!CdKkNecVSptA?)H4G}l(8VN@aDFn6BCHvo>#-N`<56Wmlv0#9R5>mL z_1nuMey;W2SEMwONj=IEvbR8cSCN6Bsz@wr&H#>-^iGrc5zHTX8dmZ)-7eX?r0 zqRi^xvc!YR+icyYuE^QWQ|0u(BK zvD*pgm@3T6=5A*352H0 zMmeGcs#y?7kYjTmh#Lq`OL=@ONehKDD}dT|3~XcLogaD8imE5TcV(O92gR73S|%#V zvJEP4CvASfL~D7ya>1?{^pmt==NzVXM}6Eml=k;#|5$+3oyx==m(X~;22fH+ zg}V^Vl5p_YiKt6-+bj!^zhV{>F=zGRD0j(DyUv@Gx{A5S@i93)9cR1lb#+G~56*kR z+YXzi_13z#I@Mw5ph-9Nk`?uCZOM4jACD?d&M0tZ74y2^$uDVU>0oo9bxoj}rBrqtwi=NhcmnJlCiS`Zu zopTn%{`uu!Fex_3^gczH-K|s)JId*jIh8RPhb0vAQ>#HR>9}9 z2%wjDZp5tW{BSWQx9lW)y0JV=ES3E32Gti;WPAH&T+B-P8$V}y89sKnpjQ#vmAi&1 z$lvZhD#A|+lQ2A|nw4X&qP6Ts1S=_d2Jax40pbkLLfaHsT-S|FS!VRI$JtOq=bf51(A?^A0fZXAL7D3##{{aiy;>>peqDf!oT%F6a z=(TLaC|0+aDN#F_J?!Ujc4?_q@Ck5`anV?ZbQTl1Z-VOxCx~~=@DI!_Mjt=O8A8L zHTv04yPlU_>t*~g!gV+*((t^S8*awwsP_EH8TJ2}9=-B^n4Yrv*k|s?dolORvT>!- zK_^Fi%%L!2eO|?70yqyYyzXgOZIdvx95_KIF5IJKvEJfu9Q{SN6hq#}>NU!unScY% zO;s)c7~x!;(qx^oGjVv}e*AU0YGv{1^FC?D*5G|Q5w)K*%5q5bXxfjTiEOcwP8sw4i0$UFPc8u* zg3az}`OuUt@ZC-BIcHBM4Q@%?>Z}$D;3LCm&Y1IOoSO-h@&&4q>Llxlv`EgUOxjH5 zN@sDFbk3?usMgTA{r=+I`0&$%;o|V>Iooyp>Hd7+>rK%kmhoIb3$(7)+&yAGLa2HQ zvgi>#P8cW-y zvQ#6up+s5iKt_s|O%u|_&GxhS@Jem}=l?7{|7yBjL%*nf4hDU1F9NsG$i}7J=(Dtq zbw_>E+Gb3LGytR}*)>%@glJEVs?&bV7#QL!S^`geZIo{Z|IJ)Tl*p`*PT)mw&U38G zhk&?xDrwzu3!k!7aH_#WJ%Q(*@#FSHX#G26+{xji(c$I%=ehEvgNgd--bPmV%1s+( z((Ra5$AyXv36!bvd9K097Y^1|?bxj>$j1HMN#MV9&& z`9bnv8IP;jaHp(J3SHf7*iwELySFD_I_^e#ljcc0YfM{a(|Hk-S~az)OikMT<-7in zn1P>}Fj16nUCYF%aIRcW&%~54*$mfj$i$4r;5#xHl|5*p2vAJHhOux*zFV2Hk@EKz z7QuyLg&QB^cwnbABc|Ssh;QyMd!ftWuMLg$HFC6^l_=u$F-~~PvkqgZ53KoskQgLx zxO}zhC1PW7RMPL)6taXp=1rt*D{q+W{_6<+%}`^$4J1w+N(gGp0k-K?tq}NuezNWs zQ=kij2Vyp_*OljQ)wSL?H_)YczN;13?vC{e&4rB%=gDD$eb3#q*K)6gAf$2xsOmY5 zaG~$SI}{?Mb6t4lM9B2sE6uj)(f!2G3Sk+A5RBq|;I^Tt@oF7t1$JGapwdFyfE_Sc zF{`6;%=@Lc>QI#-hSuW?u=rBE_?wsi-xap3i2t$okh>jjwI997&QnvsJK&w9YVs7s zN$zZglO=p64v?}Cb}u1sLdhA9xh<`Tnfyeub|_9{$N!}<#V@8AxtQm}n*#bRY=z?x zGnrg@eoSUL)!?nX^W}<$9ro-qzS1LikzxO9Q*V`;G#Y1}w6j*E^dqScP+607^k2Cy z3x|r=MMU0F#jo{xAR$T0xFu?l*#-a3`%-p=rU_Fo8Hbzg?sf#W+3v8(neqc?hotK{ zTbskn<1tCwKh-ACnDqwA49S{Z;+?XMOfU)YAf7)JKExo|D1#J&V+vwu8DopXlGk+T zP!JQ**tU3kN;PU-FgLu}ug^7VH@e`!A4EWn?S!0@hl%I5;`Qav`LgU_4hN>Bf%2%gM9nmA&ua>f>wCc8I87A((hbI9B(qih&lR^ z8hKk3H4s9(p~uCTcN8nsbAs<3-@ip6PBf|_7yVg@4J_t&TU~5;Kf875yD2!N0&VOx zd67&n;%;bo%!hFe@=FK4$DVPk7|OS^6hUnLjHO1Vu|ig_qg4%`gjBKx0qt7EhX$d= z3e$&K8K^**cj4KY4c9UgX}wd8&}8VWuvWDD#4gI54q1m0ZEI z9y!(Zrz)G3+m%nWhGfmVMBiN;4J=)T4G0B>+!OVef-WvTSEo2gaeUZen%#RRxzl~w zbmD!sa)RRHdfyPP@Q|GTf>$kDTgQP;J4J~s1`5o=yC7WHj6ZaL2unmDV<(h@nVc{X z1q1l_`1BJA^YA%70R|t>v7fXDl`0{Y(mkxt(5pVo5$0^M0T?xD%V!z zAfetNqLH3(n*tiR*BQHRD2(ax7EV;)4BiTLi!ZX@51^D zXY=AXyK;?5i|hiXv{kR{Ce|$YcWQIYs{9NS+#g=*d7|V0s9(R_u-dJ|&wi|}@QI85%L z50Od@ZxS`B%#{$EFFy(*J2;r8z|xNCTcm+wt7SBSVR7CS!*AJPSTGC8dbD1*8y0>w zaCF-PJ_eqLmkY9oC;dPRimC}szz-KTKo=6e^b}duJsb2nEtJIaMA4TI@;?l_G8n^| z4yAWsBc(3RKj~XLswH@u$O1df{Bhj|=7nzabejxEcXqluZx-0I-z*l7VByiHA2a$z zN1MEF#38`uc9wym8XY+fZ!rEeSvzpa8uYGa1q+2O^A#S$?M4)1iWt(37BSA@D)eQ1 z@GbNr4oa3=A5OiF729aBpB!gbMADmm`y*akMDh308O|X~$0^WX5?h-g zM!P$aXiqX2n!5&@*c@C}u9c`HUQ#9c9>sHMc;yeAzmfLzOu(DxQJi0&aGR466H+Y+ zGCVG3M0}S^ewupIS9U@1cdo@jbn?%wY!Br11XK$|oOUua4bW=M~3LsSts_7|9(x~7W!I0=~QG;Ja~NAd$u+VsUEMGNzU zAD(63G@VEMSRhBMZR06L@7YWDK5ni2xWR^?0%JMaPk2bvDHv1Fu58W4)Ge&~qw)!MP zF*{W%`&|pziTJ@$rx?UzX+8t&!D9Q2bZDjFfIs4v`a9?;KsL5`N4~Q%UJnTD;t7dB zila(0iLc;f25+^Nm|wNX_15lYyS%bHeax6(=iR@QWxKzJveyT`oUgVt1k8?0aiW_@ z_h4sOQJ;In|VJ5T+-i3$yCxzz`bjDOm&?q1bH!^F$ckYkU$Fq@P^cnG~2 z$0_lL7=D5&LchY2EhFfrIP=4RLNl>Si@w`&S_k8x&H2m5YZ4OZwyc=k!G!$F8omP_&`0OXteusr0 z6CmdKy=(c(g-Gl`-(r4;xk*L?b7cw?O6>mz)5Nj_vQ>Bk(6}~fZkI3TXL&!no8XC4B_F9}-@aI(W; zBTsjg)fw!K%h8D%gb`_LjCX~D2{b=JVkGG#4pD2u9ioxjKq>%0qE^H3F|9cMpdOyO zdpcUZ#92%5i)w8De71A^ zc~0kS^UCiv=c?XuxhAr;U^%Fhhw8vmda{fn@-*?ES`;iKHXgYK-Xe&lbHO8Q+O+q9ZXwKPUJ}NriaS_ zLai&{1|5^aA(x{Ja2FV%j2t_9HPWa1Rs={-{T85@AGBgDqZ&^-XUXR2aB?+_z-wpw zng%z9-BvBzZeiBYXlN;RG6P!pD(Ig7xzhnL*CEXF@SN+~IP{D*2C;LD_bzziuRmNx zBFze!-xJ%V_~^%Xn^khxM1K!@jX{sP12QF-kHRqm4e>OXN@Hb^aA`hwKD~^>V$*4Q z_@w~qt$Tj6l#RN)SmTO8!w<3o8ibg9dq*-a$H#g`#3O54sr?L7HZA5GF(6w&`9Mld zXKW^!@)w8ef&_P#7J{2|3z_kY0vx zGLJV&A2Q&-JkN(j7Ur_oR+e@!G$D8_Mb3g&JaWo=*tW<_Sb2iFjp5?(4MiBQJdeBnbtEkb^AHXzGR9_sAE42KkmT>4yVBx^}_jX{Iz56^x@MW%1vu z-M=wVLjFHCcm%3!@gJox8?&9%M<#boc}M3g#3k+uj?txS*{|fba{ZdAQZ9XJ; z<}t!~LmoDAM&z`5?{O*wm=kmA`<3iR*Ik?0&l9)mlv?}cCI0tA*|vkuirAQpd+WDp zG%?8Jbj4nbhYnJ6V|7Xom~A0%%v2)x7kq;dCF=MCySsQXgjlL@UjjcPOr?b6UrZoi zJ2)cBW~{+&C1k~=b8#b6eY3mg!SLrz$BG)TyCd1&te}l;FH6Pek>_}ULhO}#0uX|l z-OM7UJ7O$9u>m1zpmyzMfGCM*9;8n~C114Z3NyHnULN%iqZb7TA?#tsA=|FEyy&4- zQUaqu79ko&kBZxF@)7IeMe0El(ZNR3RcD(YOZ?PP+07PTXfx{rZ#3{xJ?fiR5B7I4 zp*JBC%xvaJrA4`c>Q3s99Py>2tqG5;eH_8$A~>KXdT`|tIcZVTizE;>UNFpD!0L0H)MXjdl2Z6cjQD7XT@<0i`Sx8xM`4?*k7 zE$oBIBx^d7fR(JfZ)T7ZDlE}a{y1))-mcz{ha8>wbj@bohx>mXZ}oqz9U@mcMU7r> zJvL70=1g2_o4StbIk;{*W_jBl+S~f%_;LMgnsx-+bhoU1I-McuM7m@ODhZ$crBS9v zA^0Hk5%y>=rQ+FT7AY;+&hf)%H+RAOFMFk*qcHbUhp${!FEr@uLs=bQ^~T8<>NG#g z*aDgN+$x6fk%eSPA@19Edwkgg2|b=sQtDWlu8pW28A zyU_XEMq(b~G%$1CIBx9&s$uh0hje4cXf%ADz*18EwE4)(4Uj@Sz|_g-&K;n%Bf~hE zxcOes-5c+PRCmNY5AI(&Iz7g#Q^Vbyt<>qYsac3m>i+p4l_qe)D6&T zw7B1F87M&rQL!1$HPo$Y)RBxxz~BV=<2iyIk6f&Qf`AX5ZPbuxz~65cfQ`Tn(Rqeu zV!Sl0_a|8|`^n0RMKznhuOB^Zd7g>Hc*_e%KTT`U`>kVg?#n)9wHjVOg; zpZA>}FDIKklQ}Nkd>`=)UKV1b#=!{iItB;HNwX@k#o)Mh513R4?YYSP;@{i-#=T1t zdPQ*Oky3HFN5oR(ygeM}uiXR%%7gxX7#tNjIaNr#0ux&DqxFIB%)vNp07i zyN-@-QI=cR3s2B0R!fo~7wDOqykaf`8T5MCP3SibaKNq`D4CB*ghCnWGV*e}aKG>x zzR|lp02$G`Dn~fXKPr>exbPcf2|(tao!TpBSu$!O`u(A`v-wfCLk~yNHAgwT%eoS4 zv(nGiYB8x}PP(NS%N+>^!!cTX<-9v&S;sR1n+=WNp`z3VZ4 z@w@mb2nNzv45dWYbEh7fqYea~FdiZ{yjfwJ1?DNvKnaCuFvUT-Jx0>-XNh#+P0xSi zk1hPa$u9a;pBG~9UyrmmlxjG2$-j5*D25%_uc1W!3sybFw#cb?J3it?G5h6SNKS ze#GDLcICs~KMmtfUm{CCMzch&AwC6=ZHzK}RG=rA%>grR&YWy$s2-6Q z=tE8GP@u@j~fSP)|j%iKc%rz3?XOqk#!3(6#;E~RKQ(zl?{Fe=Ly{AmQS0#Va$ z{y!}MZ1IA~6(2|;?t*U*rJAp22(w;(Eeo2QtFtDVr^!p7V70y2qe-6QA8{7Vh^UL> zJ{dOP~?@%5WC9#&P z4y)E+nVQu0uvmATK~E1-US>wXYR=Toz{#Rtv{wS34E%1PFPx5$(EbfHct zQg035kxs#ocAy$NZutew_wS$#Qvee&3tr>GTpdo1#)>5y9uF^?pYB$oEnI{DtA{T! zC_(gBGk6*_tfQ`cI>+FxDlLgGY{>WbDdIlYr-AS3^wDphKJx9^4r{hRZO+}QcUXy; zhm0QR5c&;l3kx_vIO*IIKl8ld-zzH<+Ex-P7)9fg9Z1UhI1?l2JjpD*@j2Yi^?48O zi8{01G(AlF`K``c5oApOHsd?LQ#abm5%lv;Eb;_7nU{;2*0gHEMSKQcKV__sVRr@w zbxN3wP$`+8%kD?Yyax+iJeNX~L$(idyN&%1up+|)p_JU~FXT=WOU z$**4MS96?3*glyErtlpN^q{;7Jofqsbuc;MeCIL>ct)~c4~Xg*+N#q8 z57a`NVHg0w7LY|#c`_e{&CA9qf3CCU*?SJDaZ0~r`nz}2%T-+L1ZD8S;Q{_DXvOZ! z_gDgL8WHFaG}5k2^^RI}gP}ZUSS__uQi*({o2{B8Adt@d>@qn{+%hF1*fY0OeoXC~># z#bvRxDtc{_;smxO7%N{u1qvs(D3;$-APeX)U|(mVNt^>@ zq#Osfr%Qz!66BsnD}byMB_8h@ML15nIhJhnM@_jWWc5zW zGOvG_fs?_fOV1t~EsxEO22#(G$%~S@IXikQ17w}pvAkEn-xj%fI1Lj6bNWbzIZ&e| z;WaHwvy|B7%WI9}H?dgjt`(AqBy7JDi_^bQscBaD&vRdu@bdTe>Z$Z5w)3+@tYM+{ zgzTnf=u~{zjal3?kzt{4m!ids%4UAuEulRB1oLp%rY5{ z?qQ4tF!bA}fyg=ysb2|LVc-LQ~J6!sss znHz+5ItKij(#D;dq8}^(GgLxPBn5kYQQy!CvlDV-{yi!$69 z2cq}kPvGspQ`FkbVSgzrqv>R2jPsmsir<|lEl2z)@c;gg9J2by1vv5Ew%b1by#E#5 zAN6axOiFgx5^l#5H)11jv_0`B9L|FW$~rdl8)QbflW(e9r4uC&oN|PKgGmSk`VNb6 zVDHipmCKxMB!U_Ff2}kEwPa{x0MEK!ABX&2(fD4Ad{@u9Pa}t{^I!5lPVE+GGFgAB zQHq-!ysGqh&BUwnoOO%p(beL7m`ktnaS+p#Gln*!QG6n9j|y6*077ge z250K~ixVNSK?1cP1RTED5enfqBm<<@(?a6!R*F65U&Tmh>`vc7lvu;^QB!Y0{y=XF%wAne>9^%1V2OQUTcM)FUr!#m_M!B z=Twe_knU2Gy{FeD*WP)g`wL{X&S&xnAd@vTL41Z7wQ?g6WkG@j(h2cayYqChGM3=O z6n5977Z2JP5++f6zNT|p_DZI94Xe+@fS+<27goNmU?x194m77oe`Y~3W6B%Vbihr% z75d4A+g)HoH{iHOz#|pIqc)3S?T z4*uoafoSE}IS@8ESctBwd{nV!c&DD5LGkC1_y~-x7JnC`w|+?A3>Eq$d*%-Jn*V@9 z)4{-xdxID74818*hCR8KiQo_ACJRI!fM){l4rLH?6YQ^;Dp#i7UMx*BEsmz$eCgR5 zf`A4%c*BwaVBD-MJWO>9ewPxtOo2oZ_8P!ss}b8%5!vXbzoL?p)@bMi7h5_AEI~J>7dg#>a42 zHRm2QnV!c6>E$A6n0}ti&DP~zpiZD#lm0j96o_X++|#sTuDDo@E&8S!$GfNCsjOlx zi+9K=m^0M->s}}^t3AE|Pj3TKyUHAJ4S&xw${q(NOs9worSFMQJVjC4c^MZIGaqkK z_L{2w*YvtFCIA1M{pS2fDwG7;VgIVz{qnU1UmoI7?y;^hXn!S~O_jxE70yhwwihJo zPwn7t^101iYPya>a5NH#=FB1|e&QBx*y>nKh%&+ju(8K@9T8u;49tVFb_fKeo;ZLn7~w!XM_$g?p_|7U&8h{ zq*jixrUsOg5>mO$H&egL^UJrgvs1a-^eilf->>-@y3Y_=f%$-J??#JumAKa-&(l=- zAiH%6(d@Mk>17)fCu+Q(BJ{nAHZ`D5Nmo!^u8ZR zMVYbj<95O4)gvhI#Wfd3=v*)1vu+-pirh@BJlgl)N4Bz@`-Lhv#Etjs9>{}R?<3j<#mqf;j$Ha039ONu{$1n9Ji2Ow4 z=(Qn`9&3w<%!f)Q701!O%cPn*EF|8+atG!8w9P?dgpL@+&ow zjkp~+=41b9>g8g(899NYd-s=7K3s^mS^w7^f4$?yL3DIh=eXO|kL**4dLVJ)Su_ai zi7P|w*l6!w@D(j_O5Zx5tppqaC;r~sy84jQDdsMUk+6$Q=w40*VAL!z8W^Lv1sLHu z$!-QC3^QVrsh>`ojE(6E!b;>CQC&;PlVmS0_mjOHFVS(Uo0scc_d>0(LO!LgNIoQn z!WP~vADVU+D2m7}01S32qkMWPZ2mKAqVj?d|I#PWQCcWcdNP~11wx+$|4gF*ArykF zfMRQd%N1?859RxYEUi?x{`Tu_k7I|7pu_ShQ!u{Iu{a&8xm(;z-nKMIgx@hgDfm3@ z4Gf8?aOBPN1W8COlbYG&A;dttt7$F3X3tc>At}QV)g*})LRhuptiTyXf9$13DF}*% zOR!828h5}1Ezi``xPKQ1cMG*jQh9DW0SGwhd^xvN{O$ijfp2oq`5!PqCHBHj*X|+J zXokP7fG;g)D&x!~evMvf-v%cp;0`uB#|=~AeP$wR(J=BQ`etRB{zEHE#>zZ6SM~C@ z5E5`h<)0?J7H)3&G6B9Q!L; zUU2Zi-<7+dFrV za+h2akjBk4YWg~40VigwCAGj*011@W3+60u6u>?;#q6N|(;i*ux90Xz&FJjt?%;4& zWAnXyX?9zJzjjAXFZIEwhK0Cg$xLz5ZHFQOte6HV59OsYe2lE08 zd>ESiW>#UVL=h-*xLOMK_uZ97D_Zuo5G`FXTE-r-G!Sq z+4pnn9L(Jt1OLrl!69eCr}nDYBPf+Imyb;Ln#eaSQVDFQD3n-c{{&i2-F0?6Q8~Vo zf0zq#`b}I%L5jnWAZE=kq1hvAMe=*|@8N3NKJB8;dHZRVUl1$DrRrs}b*G5|1M2Q= z?^JeGMUkVQBdiuQp|Y?#FGNOJhoy)?;fY|8;1M0FH)dXGJ#X>G$1+M7LG1A z&Strts-|hg7u$2mF&B8=K4H21U4x<1@m1Y(TR-9=vYRVXln_%UJ*0WjpNQ~z=1^if-lD(s#X_>Z_ z#x98cFY;-@g7`u&mvP*tdK`gaWZs7ul>G(2BxKr;AA+PKOf%%k1;jxpycZZ*{&k+; zOR8d~VvOBv*Z;GAek%T)=9=$Vc~RRvE8A=PInIP6%E&EXrzW+=a}?cMfnk7yiR}&~ zdViqTSAq-%Y@G%l0z1%Z!hl-@;?9qM0?QKguGs4rGqE|#yYw_)=ds9eJiq^miQs z)pXfMc|#%2x(^^Vg4@`T2tBeJAq>cFbWb1lJi%W;OmwpNL%xKV9ZEDua36;-*p~h$ zaRv@Vw7<}!l3_j)*uUZ$@0r0Ns%($b_uGZ~8pm3WusisY|4dUQ|U~{fmuj)~J$Z zTT%_76{v-OObz0=akX;()ZSm608Tz`l9FJ;+4&;=&&ti&wVUm0%JB~AtW$er6F`I2 z!$h#nOrN>QD^en!Y-xVQuh{|?z+gJ?D0k}=EY+P0ESm;YScrc}{vy^8BZ8ekGz29l z$ruxh%pdQ2*GLIp&7`RZiUVxRHLey86^eCDU2O5LxDOwVgCCZ3k-W^?;_SK&_zJW> z|2l0$Q9@9IrT`|*1rrxQ0b<_~dFi==Oc_e%uKQbOk?SWYJVd?FZSoS(s}@J!PYhQA zvH~LDWmOExWsJ0s0tw7@vQ#@o@2>$bc|<0HsYjpTu-kTNMO&6nV=56HEM&3=GeI!I|zfTk#^S!kX#TNeb`N*TsOC*e*=Z>nGO#uQ+zqMlj`+2CnTwbjY#CDoZ}lJ!Q~&2C=(NxPPh6RN6k z6)T$qa1auhkrxh_6tz>e9vi77n!z7w6Kdiqu=^D|{!YMqn1_=QJ!zS+q1}bkIb%R8 zvYe}g7O+HcJ<|d=KDa2HbdQ4BtDc^lun1SK$ETR^B(O)(Qj1#s;=@n3~Ai^6bf0jvG^knKE z)ki=4ikeo;v>Ku5T_!HkDWC0bixwV(YoWi^e*HR!W%Zr|dYp8Xk1P)& zDF8iz11tkHfg@#o`pM1T%yaN`GPX7O*oVr6Sq$87ictiHH1)U?YDU$OXH4Nf6W51G5yORJ*oug*Wlg<$(P@oRi_(4MXC&JYKoF z-nko2=ppu5!aJ!}nbPT8!MV_8yGfs3ud28Im?)p(YwPe?wQ}PgU>kcy#+RsO&3Md7 z&_S<+*l-;S{?Vjz58gp+_aoQw?YtraR%-{VAQ&H+584>tmeM|q5=b1zWrKwB-V6ky z&CPFUfvn5IifxY`eF;6I>#;YfL}$XS?Aps5ir{4$`{>}VE6*KCdn6!0RCg>Q4J&h@ zcCfWS6G`LQ3vrXL?D#%p(q`@vp;a785rYMR!ykDRH>Y50^2 ztD8iH3?lX8(kaQc%1+;CB5Lcc4YC=bo5kosDbe@;S{)B09VoT8@#XZ=r*`D$=;&`R zO^^GNf@jgyk;vE}kR#)JP7ec8PeH3d_~8b@JqN^bVJ^`nsA z7yqdQ^bS7&-)g9SHDB{MyY26!=Q+oPB(ody$_e!<7DsFzOmk{Vp0k5UHQdxW@$95^ zfAF&Ih+xOE!70inO9L5qsZ|$?6G(TSUN0NY9~v~K|#V?uP~x73?_f? z;=1D%L{B1{1LCXbMx^vvK=UGDF)SZi7V>mHrlPhG;)?MV320Rq^ zhTvG#O6kX&oMa~tZ{~1Dpeln@&pV$wnILBEx%Z%V&~DUM(Q)f4Fd6$GW^h@NlP~an z$TR5#;gv>>D8bD*`fC>6UK}dRQ%;4wKS3-< zqJM9F`}QJviS_EK!W+}%E8u>buDrUsDZI)2m^OpRpE_hn;fS{L;1E;6qA$@aJ{~!p zxX@&vO(~5gHh_*6XvS{}81_e8kf;~T-2DD<`h4Y5^gI0J+~0lHY{HzD{na=)m}LZ( z^;YwY{EMDgKG-nCv<)h$r;Ga}F!bzIg_L`75OFkVcMc-~wMe?LI3H+IL=;zO04JwT z`dc2^pNr@j8eMbT`w!DeU9_Dsi6e+E1K`)9k{*JN-| zx$fdb)U;RhZ^51*GB_=Pu7~>sMv{i76zKXXBmM#oX*$wa4b(~4tz401kt_WNQ#}DP zK@D;N>Z~n}Sfwe^w4Mx%PR=Ok89K(!MV8i;mO1O-Q*FRA`Z8V?`_1vKpXbvRHRz<# z+eqEoT+t<>J`Cu5DpE6ZHSmu*ti$e&y2QS8mk-(eCz18(X78u{4sLBcv=22 zxi7M-&iAKx)e)HnfPewmrse3WE2`i7IIn-KAqnr`E4_(>inn)DJckAIhwCw6Z@T#w zUv!J7I5pHW97P|SrJ=_HkIbpTusIQu1yQ>HVfAo6I~Y0Bl&fkvo_`Lle8T~}&S*oX zsIFff?0qFAIqL-Gk66W%P#m`05h~nKTGE(K_9`*`r4`0jjbg4f zkLKZU=9e#!F9a4im#6_tXJ@0U{=>$L|JI^ZTkR#FyL)f7qzSfaT}9*=+X{asJPz`J zqD(*|`bnxV*c0}=_J!T>zHZ1SULYrEE=J^z9?9Brh>(_81HOZ*NH>gkt?08j#3JM* z)%K_x2j#?-@MfCkTzeZG#&C_0T>=D}8J@A=zSi=pWTTd+PrRQYK%MHMcIgzT7nI`f5HuG_Oef+tb^{we0d+d^1>P0mWYhE>O^ zk;-a2I%$C8C3=<|kKlzTbjet_%q|jb+D$>^#e59b1NAbrM(F@4^ zcmx`3p=nj-O%#o;!!|MZ^duHWy{#CRiqA^pcH90b%sRTY1{c#NYLT~?*e^qd$MaK+ zVR(BsUGdIrXkfH^DgBx`M0o9P=@o_tdhkygPukk>3&i2UeyKp!$bLu(UEoh929?bG z+{kJDORN%@Ip8IPg+EF2#N=YvZbtJnG$QW8E~gILsPp5KD5lE@73ybRiY&s|&V{yc ze)!x0EK7z`;Be;RebrJG$cIwXd|=C`gLLTljci3@^xZ~NzE|E%Py}558;I6ncw2tb z_=&|CTeC@(XNuhNa+9OA5tqg0)ry-@*zS@OD?&4!%3Oftq>3lyGKO0gyYf94q z%Pbd$yZ!1SBv~3B0_gaN`5YPIAb>4?41BuZiy>ym_==nuc8J71)36Ax}mll>E z!*w8Y3L68OZjK$n_{HdLy*`n-mCt7i8$Y)pWHq+pKTz)*>@$c#n}O_z(%@8l`IlvC zY_41jf8s}owz&zYdjiH0~i8(e_LsNLb==)k=Fl7x0Vww=H z+gE_tRH&Ppb1x--yzsn#xs8+G?7E(*dWzuqb^#SIz|@*>`TMfUkcLvPM}MnT&7J`J zFdrusHMi@(T7ddUNZwir5ip{kB;&U>vf81HsGfx+sYWF&;*4MxAT`hULw-^oZx>;; zN>d@DGB!Ll?<*C4jN>i`C?0Z|TPzLsk7fnxYRB>tO~8`gB+*~C>DRR!T5tCf(J*ax zuJI`=@>tB0-LLk@!IU7dU-ghX-}u28PM+$$=nS0*3WYcynk`RBcmwmChm}aHyj!CZ zuiM@I%_*kpI_pr}X5{MgYLmRmQmw^ZFgs+0Lf(f}^v zqqh85Gl*8@&yA0;Yyjr=3AwJ9eJOo4uJ&xVs~OKU=BX4zv~r7tTYe%Zk@DU6JWv?X zU-vy5_|K0x4Z3)4WMgrX_f#UP<0e-HVUc(%FyJ$wIY!_7?gB>==F`3RaBpHWAP5`C zx`|9Y?5PrIyXEulw%ahwkX&r#e_a6b9~S_pTZ3fxX#dl zsicw3bU2r4?PI2&sUr7DEz_jyoBIw8iSqFiI|os7*@M7F7e4vvp+`;MMJlIGEO^OP zl5=}Kj{aiB)_8joZYR^-(VzI+{JEAr?DD;1yC?i@ZON6o`apkeO;Qr-f@w$@e5`|Q z*N|8{A7w4YZlR+=Q?DKjMpW@U&#{n{upB+S6Q4lVZSj*AhMV4&*cH2h}eD!7{wEYgk zb_l{r+-g4Vj#ih~I_GsX+6WS83jpq9TrA`{713fy2J96*gG{VmcH?JY0jn`J+xpY$ zZOn>{WZU6PeB`|CL}|lrUbgXgx?6N8-t+MtH_W!Hguk*=p`mHXu5i~^EPNs~%@{C| zl=yzkUDKqEB#Ygs3z-=305eEk{@&t`AOUCZU9bF0iw-+NBx2|ouma84-xpz{yUxrk zS1(;5O*okfKy8fw%?0ux4ynFmKsr5ppA$p;_$+kX?H#5{Hr!2R;lH2m3O26gCv&Ix z#qeG!w4ZqS$TgNQlaeI6#WTmR)p!D=kT>I3SZRY0ThavwI}FaMOz5SvQva?WCBMV_ z?01-{0vdT^Y%1H%@&;GaIkM#p4pxjAgMf*MP>?81e(f>S@%rJ8s za-8u`AVO(PRszlcV;wFZNV-boQS3%0O&+ECc|+XZbq69FROLDTUG#am$^iB*-SE7| zhr#yTnKr1kX+~OeAI8Pwz>UY~Yv^JIV-@l7?$q^?+v-L*Lr>YKKB0(RU&h1aT!&w( zBbB9aVX6EDK@+?kD@cj19*6>J>zapooL(nJ_Zu7zyQr1djtE}kV_ZoRFThl zR#{To6erGy{HTGh12N)U&zZ!4cb=*q1y!Isdf0d4a<)3R-C1$Qo2#4e?s?u z9g9T**AVa^4Al79lNH(cGMnhLGK&qwsl1PS^d8>Phw9LQ7*La zIZ3VhZ5JKeelBk(Y)K|oc zuWXGOm)QM-C`2LIrsyMFJgXNy9<;@*-^~td{2`=(b~RH7_G;hnuaj z54Tzlww#9so5P4vGT3PmDHttTTBB&XoQW4l=S>1~8+r1S@;&az%0;ynDu@ZF$)vA5BZ?m!>mDhL zptLvFBPmiUg=)cY7+~2VpCa*+$l+xCd`K*NqV;+H;#FF%J+Hqi!X<2t*{Jog!FtET zn?O69cLPm}A+f_uA5H@bGkYYF!+ah_DK>PZ@KhkNUjkq7ot4#))fhQvh{7wwyGH6o z`8}PDt6;OaK#x$CS3?5b^}Xm zAsaJ-+vuQZCfKC8JDC1ncm0YjYQ&`jF_nXkw3KXcwY*9m{t@vfV4*kwuEFO)JPfk| zLF`YbL+??L%Oc5bXHxdT!NF5_jB+OCV7t%j5_gH9`3=P6*d_!337x-^OM3W0PwbIPtlNJB8oc z2JZS<)ICIB-kxn3l)1kWo;I{na))=~UN|KKoMAUMYz3pDyliLihRYZD%sNYfes0%!{8&{}&9{GDCfX0jx5> z2X&X)w3y~`@&X$iT36P4tZC`*U>;!bg_g>)-xi}~Vg0IFovl}!@4DP20RVXWi4WC;E%J9NDX(%f zA#5rMw*})CLUSZ@`Jw94bunnBU@~~zehTTfewATG`aRysc;APHOH|L>aF}GUhT7mi~0%m>;*N# z6utqq4!}&Zn@U&B3`%N0VYeOAG}>6H5gA2x>u#_Q^#}Soj4E)0wuBfE%jE$#xJfHp z`Lc-5Oy%)Qxio1zR z`ceXg`;Ay87ll*f+6?H|F*AVxBc)629Z=1O5Rv;O)4^tlpZ&d{j+Z7n29?IDi#24S zSEK=ksFfzvVoaDT|Y4m)9<;ou^#)NvfHzqtoP1NXTDm1LmWxUo4!3W*#>fWqNIak0Oa)FS+WHeJL^{ zBak&EseKWL{l=#}!5oZ1P+I=5;gg*RNvSK~kG-yqZiCvl$6bdZ&)c{BrmVT@4G#F5 zUN;9rc`|=IYecEhN?+;+=>KYGcE_G-leBR;bR2#ut$|bzxz`#NEL;e!^LsI!uKPv= zRoSm-__1In^`K07t4*al?0^z?M?+*VSxyQ1^SsD6|5U0ZzuH)YNdc@HmZwOn*X!+NQ; z2`~p1X9^_Z8E~P6B8rAoGl+R$S5lnL-zFl08fft`0kM;_h1D?1XsEh{ zHgnf6n-HBPvU)^{Xz5J&zqEt}Ocmve-@?R~Z!D8vNVrWi(ShcU8+v#=RU1U|tBV+Rn8blZJe zU>sS&G1z-e`W%z6zIQR~$rgc74RnV49BFnr+Dl!o1#3(tJ&h1I757a&u0S;0#9L~} zZIK6vZT}1YJRp)Oglreo`vjrxQs8h&x-Faz$K)CxU!TAPI#}maQ{?J!CS0>ww4KiS ztsv3P-!7gX%HBm7o%=Ef*KYRKv5TGmmVCDN|3vv~eE@L=zos@;z2P}zb7iLWu^ZAo ztie#xF@Cm$o)hWg=^T8-{aEr}wMmtp8NzftCrFMw|#Au zutyddIk~Jh3@;BDD!@JA3t*>8?D?Uvv6`l;Ql?~03CuKWI04V_SZZND*V&cfxJA|5N&H)Wk_-Z*N``<_fW~{g!(dx|0;CHtQq;joH2W0EOZCKNk`f;g zbdM&V5ijR*01S?49EJOctHi2|(5>q2Pe&x)U#aouC$a}G?vn-Db4tv^!?HgM?9QIp zc{`K>0M?wGj~q$=Y4_}i{`1?n1FpBL0sA4#2p22sef4r?1=^f7oS1&m6GO=mgEV0b zAO?^m4nRa81VMlJsF_3KRLmpBpx1#0D1ja=XT|&Oe=k11X{e0KM&YH7X-~J|`$TeR|BI=X8nQTaVP?2$Xu!BV}@b$$`LntIrLIw2wmEFZK6B6}* zpn%qB=4yf9POOUh6-w%NIEgZVK7zlhIk2=ZaD5K>IGq?{edzAD-rRC$yIIWdoLNQn zy{W`yEEG|!p~qJCuyD3{6`9~555&b<9Dw(R<}QFH_-1Wl*}lRst^@+mQebaKDZKW( zy4s}ir7>?~%L8ATRX}S;5Z7sHRuP4hNv}QzUa6I;nx4AERAhDccPBGacx&$UicELo z@5kNI=rGJ1L^tuj%p|1!Yg-){izFmYFNZzQQKHmn8gx^N)YE|^BZeH}i?YaYnjTXQ zwcqNcNQT#ObT-2lO2-KHxmsYF0vLbpfB3sE^0@7mQJ)7lReMh!TP>5C9qo-iX|_*4 zKP`Wr=Dqdh^|kL5h!sH9t+KfL0*8b##;_GC&Z7gzD9!{t+kejv*qf$nD*hTl?BfVQ zA&*}xiU@glPP8B@@`LY!ja4&h#%(4b1I<1IvXzpDRl zuaCknL-4!9?*sV=sCkV6oUd7)dkCEgQM2erCjCl5t?(yUEBwh=vM>vnI8vRxPBViAT~`h?_^A$-yQ)GZYIvZcrkii8&iRKfPkfbo%vsT#idM8Z)&PlG zKqYNJpy#2)%cKE=(eQQAW7}Kr*=FK_adP?c^9@(*xf)-~`HzB8UQ)A_KOZ{RUC@r( z*B(pLO|!@!zQbLnxM$?fp=gqgkr2!)O-Kc)H{LA43 zVC@aiVpxz6SXf>jdlX?>r>5-UqmjkkdNDlpdRc0K!#?ci3p;N0BgkSPD|s&lD%Vyx!GYY3(Rq0W|*%NF}b|E;hM7jaAgZpC!w`D zO3vo3ymSLkajm1XMLZwpw~>_tHK)n0UG8I1(R)*y*9aTjv8At7fhH0bDbVj<9(+I0=aeCIN z&SNV`mu?$pE$ez8dOo-JIMd|k>a95n`Hc`KkbEAwO8Ta~Wa}HpT0Kt0Dm36t#wHZX ze4&tMecDNL!@^13L;#{&_zP$vK`l1$t|@G-iMGP^ zXdMc1ziQX@>|=x}{~~sMNRI33?Dd+m?mgrXsdWP{E=ru~fsA;131c=1+*{EFml6|E z6@fO^#A>J{|B3?zG$_m5HeuM<)bHUrK8KuqAaWar)>~k69#dXaX~PlWnn%eaYPv#l zGFiNjZ`N&VEvgNc-DJ_`LWSS%InJ-56s{UiZEYQ%;+7lR3oj15P(6eNcxTU~UPV#m zYkbM;)je@F&VE8pmo6B|e3>t9kp=_WsN-V!a4_)9U#Gt_6q|~z`T5yC zEmwi7w&YUAIXcJBtw++7!s&Bh!=OxJa&b|Wx9ShOIYKKP$zoiWGho*pFOlh+WWell zg9zeSoXe!?+W?dS!F6{vwC_;U+J|18{oC+J%ia3zjyU2xM0e}k?%)6Y4RC!3>JbC~ zD)vN`%lkv^0gMN9+g)zj8cUiM#JMmukdhD|N6>wWBB$3&)^rvDnA zJ>x%y|MBtAM31BWd%M<|raN6RF|^jaq)86n@QG=kQPaScM|u7dBS>C>=B$CHzzOZA zEZ>bQTC>6xn&5sNDOc)F1QBj2on?*{AG5;BP}6t1*E|}W0MLJ*k!_bA^fWQ|lA4_; zL8KMpk^MW0j59P~ucA(~M?zC5mLC=CA#{;KlF5<`B#g-P6Eu`>0OYd3j8_I3JKRLB zh@O#dIv87w@1_4;_p93a37k!p4^Ys|>*l$+Q~c=0?S35&c5KejR6|#MG9b#RW7I_w zE8;r^ul=Huf4^?WqpIA6Von<)k;a}tk%PO>@{nJcG64u*CT=_`MW%3Io?bP!eL}zf zF*Ixu|I#JN%gtzS|1lP*`xb55JP~o5if$kMv37~zHC+YX?!(SbCyNG`K&lu4ekfZ9 zD|KQ!*b^*LXKuM3%nxcU%p?FBA<$Zs2Q2oZTAB0^F1nr*DII2S1IYMSgKcg(aqp|| z2W}8S0)|j~X-Q*haT5~{_FgSBoN)fOOqVs`fZ!)Ld))(_ zl(^JX#1}!5YWrCKC}!Zwd2hBdIz-!)OkkxfpJ5~g#B{E_fP~F|>zV|g3v@ij zL>`pU{ftCu*qmFfh=b3)jbjyM_1-(&gwXwv_<-`ic#rT;yl->*K;Xt#YbR@cH?mF_ z#k&U+Zg73-U7^lS;T=bNAthguYR(tQtSRzfGkC$DMZj<2w4m;fR8;b)k`6C6W#ibx zEUTXT6Ou6HnCkRQI9G9*#d3#q@92#vk~XrPNNg{gvcLN0z&r0Kssm8;FJ~}g@cYv=NCUi1<}uT z(HUJ>JO;=fwYjvD4zrLH+M~Hqh3F!eHINs(kF1E+X)E5c0S#Kal4{r(AHY5~gzgWz z`d4PYuIF!1iEgRu^OqBeP?AW;dxC#2xIdrL>gS-j;KZU4VGy-NZP8@9_)~3rhB{^; z6LAObtoy72`(aWz41Hb@rAm4|=!~&214e#zvBX7L+r!DqDrDDn9vr}v?S9_t9n0_i znJ*KrEid96VhL871x6vPA)1O+d1K&vx(+vOIo=%JbFp{404F-3p;>Ue3-+C%G=!Hf zxcSBt%ofm)kua#8oV(0vuGo^X%@NqNT7)g?4#s98KIMo~8 zlOymxa2fhV^3E4^vw;vKayulnOgxydI#^tcl$ISdcOTC*XS?r8ae8dM-gAH5S;1J8 z^C?TAhY(3)qA-I28f=*uZzG8cVKQs+f5fETAGtEZ6?GSrnrcA-*CU5=j(;hkCSi;u z(gG7s_v>Bf(h6ncVDrE9v?KTgK;SRCa(Nmpf0eMqS#7S-`P&Su?8UPXqz&F@D18u} zPd#auJ@BGS)Me!~rOLO(D%jh#3$RI}?w-QatLSeeu8gRQq_{HvnR0zxHaAH}9BkfO?r(s7e||*!;pVJo8)vpyPR&nM5dOG&Ford1`I-HE>UYG0w&uEiWEolU`(&AsOB=!^CqIU;Hs@sod&48ei zbXqoZN^Eas>K(83@q|Ycgcs-Ez)sKkP1}3o-aT7+l-4q6t7Nf#-+rJyjE*4>y{3$h zYNG%jlf<^FEkS=ZK%y*0$`a3F4!uO>EwVsXSQ6Mc50FSG)GlA7HXV+ZTV?3_6wCRH zW~P5V+xA8HXub4t*p~MitzJq1zifIg#aEasBL*H6`2qg5DkfP52Caf&Cpn9mdkRHS z{9}%5HcU%IN+CE)M$r`;P37Hdp0$pcIOc0NhX2zF@B)Fe)Uj&B>CtEU5VPg|atjA~ z{ye~gHcyyHOpj4f(-A8i{I3>(P$I6S?}XlPWNsN@@_0%T z;Ityuv2Wz8ep}Zb3OL_D5p94pIK*3`YBc7)RL}$6YO?ThOM}$o=`Ku#7#FwYI*$9^ ztsZ09jo|(3`4dd??RmQikYjne`QY(W6MA1FgDHGsQa&lpK2?4d%v42*yMFD3J{uIh z2oy*L4fm3nvS&%+U`8%L8-FgS9|xon*!EsXmtz@Uvc&D`Q%|M7%4DM+J;rBGm#^#a zaoJ7n?Jh%P$1e-=J^k^`PX&J&tL3Zep*V*2m!ZZ{xV*s%Mn=)44G|)CE4E%&Xkq07L+49@(JKmSj z%x>=)_??e8GD1c8<^L$TzLmRrI^RXTKx^i^ig&T8S#NNe&Uym7uyEoeVbCS)M|7Fp zkRF6~t)Gx6TV5PKez3Z}VF*n>vEeOCK$9PVaN?Y`lqhrPqUoTX7XxETOu$)LS2JO)2ip~D*&XQ5P>)J4mYkU9BLRRh2gwZq zHIz{2Un21p+pum6}Mqy)SswXgkZMuQcyKwKRD*-SAoe7K)y& z-}WZc3voX#5GIjXS$rj>V)iqi_HDo{t|OIlMpGv-;TZQec{bBDOqh-Ljpg#AvKfQ} zF_7Ut`aFbr;QvuiE>TzHwSOG(@tHT|?sjRapa1+0@sL+MUk7tvhwWH1A0j!)qPpyzR`fb<5P+x8mZeJ0|L={@I{XUPA7xnPXn%TfIP)x}@a#F}} z|9ZNhQ>aIgG!5U4?sJK=qt)7d6gRWWuIF1^a>vX67vIwezuUI)`!QA@S(W2|n)wz@ zJwBB)8Ky}0Tu2m866uPCaX*g~TBJcB*0|-E_J9atwuyWPipBVT5hwb6bjAcZutj+# zfnQx5F!!p^SGYcZ`EY&Xcb}`J>Fz%xw4bf+O$|xSSW~8nZt>1jiZ_+07!FWyq$?<( zcF9z6H2Up8Spx^7$gZoLbYFXOiF92sq3q=&=tfgGt?nn)K6@klFGFdqBu4-hzNY6i z@}-B0waqCj3QIpo$5LCvkG^1E!cPq5hx$-CVpHaoA4*3}lKem_S4EFG<&mX+y=3Kn z6YjI%IOWvKQ3H=f89mEsU^k6w&ZjkgXd%sj$!htk7jnhy3_X~KoYZ0^o+jY&`VX zvtKMRELjxZXl#s>xSXkD)K;V<%>$VP$8H1ttopxV>-p#S{0ETIPhM=uh8YJ*!)&R} zci2`#DUV8^u+wjZUdskp56~VDs^&5ijYZb_Tsrx1F z@s$tZz@|joHjsK){F?u%qqR2>T5;(muBHjkCS1hTWH>uNZ_0!+w_iyPxYLHz`J80` zKka>WRFvP>?=ZA<2q-Zk2na}rNDM70APPtel9JL5f^>&;mk6TN(A}NVozg=OG0YwG z>+k*gF8{h~-L>vr_np6Ho_U`Aoc-DR?6c3=`|N{wo>cD!J+VZ9s&^Nm7d{m%njano z|4>yaWFnWs&^A4K@R=--fduwqXb1S{d>QrLNAl;hPDW5Bbm40}#WhlorV}FHgQC-6 z>N+|(M!9eW>U(vfPf>C}+>*CnDNLi*S8N?bkr)n&G`yyro;P8ii}xH9ah@^UZ}{T1 zaY?x=UE9SWk5c*EY>%AmiRTkh!Drq)yDEUSBQ1 z6J^MqpZ1LQbeuU-${|0g6MTWHH+3$-E{qOuBbQs8IHPfKS4H#)TY@|S@$=l)zGI(& zs~ZGb4PO@puTmenQ-@LJRUF)^PG_tdl-QMae{evixx#_k6z)aeLlQ!I-wPTROh6PA z)ush<*0o(_`ZCFLno}UW@Yw4l1*D72UUYiaIJ7c$_ABN3#%d!>ZhZ46YzVU%klytC zWsgsYstIv?*CNJC|^5UqGeXuNWP^lJ3s(v*uW&jyrrEryUS$b;Dbp2|f8BpRbg(_V`bdY_0xv-OK|FT5ff=k_RdDT^g6!J}`**K_lTvMk z5@+W(_uY4J-wshL>$YGMT0}zt)SQ4f{zJyB>83d8wZXl?nXcBZ_QyZsCcq6Y2lCBE z#U35(3)gEC*4tC(S_NNWn>oAMl5rJOE$dLCY|a8i$U|(HJchT^C$7NO$!PL8={NX4 zs&%JRf=RM@ANq?H`RoDx0K_1PonBYNKo{2*j)8PFgOW3K>Z0N3lmLNv>Sa{QnZjP8kjXV2w`QGbxkob=)WILVuT?( zX8mA&pZ=~HXE!G)Cuty8j6qpKTZ7zkxKdiRD=f-Ep66hq-{dn*?XT`UEAvynHQ?%V z0aChP$<)=)4-9UJp|G4NKNB5R;mK{YQXuU%qgLuS5mCNV_^J`pXVUdXU{P=cydw_Tt<>qy5QeqO#Cu_NGa z(7NdF5C*)Zt++CR_L@4M6-%4JzMWN9EfC+cz@g*ls?!%d%4;7Nk7B#YZo{I`d&Xld>}f9;uEr(-&dN(V}C{nYpT+f$)Sdb{e*h_ zSA~fbjWkv}_2m~GHlN4dM27FR1??IXbcQ>`FxRkV2$5|6URk>s_^)D+89SP;LvL#NW@Zf&CB zd`CTofu`Gru5(wQS5NY-D&JNas-Kh9Y+j6hQGN&Bz=KU%;u!*0^bwWk>+X_X;(ZcQ z&r9uUmtqO`$%3Tl#CeU2^OvsUPA4mz z%kOTk=PDjOfO#zpfG^jt-*oG-e*O{`mbK}7VB=3*FX10#;lWYvV~m%;OXv73);Ngl zyChnId-O3I+P0+ui9a(>e5Y@&5(TS2p+W!mR?#nv!dwTFnNHNMYsGu@7GL+fz@?W> znPE7PorIqHvl2X8jHE}wkoU1G=Y$09Y*D-H{ntGk4y&ee*fzsvk(ykBE>rB`Pu*g1 zaLnCQBFyiHgMre3o7%7P&n0j%u;!+R&d9O!D5@mPQSD z^KP84!la8mA@b|?7v-hDv@znLD4NmZugF2N(gM3Uz zjFKE&cI^8qTN(a%sUVIX1E&}kj)%d=^EI)}>N{qqMQ*CfM#%;~&M#W*Zc_n66~*;5ZR52zG-Gy4 zz9?TJE?jmf%k%i%%fN2i8;}Vdgim|7f6g7sPlKutOHzJFQ}~RC+&#bZEQ>@txOXI< z^)T2uBnJnWgJ7c5>*){K7E;WOx{W0@Gj6Fw^<&dXDMHBcR7a`tT|XYU%jFQI(YAL~ zPqR$Fzc=3wG&6Mk4qNk;@$p~5?`Q7kH6k;K)o z$D=ysOjsZL0b`g<@w!xigqMUN3Y^Zdh~T%)8^h``tmALZTn%=nLe4}Q-cAXLc}d`B z+aX}kw5MLjkOpcn_ipJBbhJ501{a+aj|mz+6UO-Y^(Ua{SU;YqxYxD<{C zwImA)dL$%57n1JUd>oNFsWcTPcQ@uw@}4@GBNLp}&N?iQ4V{GH>tE`(JuOS27}I)@0W;>xobG=80 z3}`P@9_bs2QGm@j* z^?Ycl;+MeS%%ddu zuQI{1sNPs+fqKHinBp9tuFhVTttR$%vYl_WF~7WQgj9Gernl!jcC~o-5;48yoc7ev z4q^b!qfwMed5!9!);&3+kboZF_ z?W<%(lhD18U!!N=Pwxm^wCBcueeGoVY3}e2rR&NIS9GP`WrKwS-k4W_g7>lG>mx9> z_olovA!wqmB?k(%sXXI|wk7Lyem-f?;PUzmb67tVF&%GTH(^jaopqC#~x?Mqx_DB-_G)SOtIdFX4c}fEsWRh zrC8<~GxIG~02i>fV1g@=*GsQ}b7WCpZtttzo#IMlY)MPquN$z(FIB|0A`$XwV*$iJ zLB>N!#c1vwaW-MJ-JuV8P2rtc9o(__ z-8gZfa@Sf7-$-8RrKJ)pbd|zjagp&>m~uoGu24q#!RG2O?kP1%FOwYzE5r=-T*yU4@W@FSXmUdAkNC*Z@agei}A zf$JWTuxu!8;#8m3y&gsPRinYHj*gzs{5i7i zp%wY`3WjQUcSCTOo_vvCv*7^t*-j?uU9^aAYOhk9zv=_&6*A))m_n6UxM3;S#L`v!bC& zxSeE*guAbB}4)@*?kUxGI`ID`!hFKUFk(~vjLO+vYcNk(*2a_7`dt^Wnm1Ng$A6sQE`Jub=$mV^3DJG&7*YlT z9>-Y!{k|u972oOk!&du_KY-m@NwoAXpVNeRj`xwkQq@zQl+tQdxa62-CI@7Cxn!?@ zMg|4@!X~p;C>|yXVZmNkfnEzU?Yw zMhpjV*?wwk+;qV!Xb-RX3IEad7p>oET~PNM$5hDA)8e}|+q<|1M0H)wYm@ZtK`!-n zRAoxKy`bLb`?t7uh4u^Fx$xqL4)ATg;mf0#8b%Ld5y)h5CxLZ%6s;x!pk2q5gJtL1y2snJ* zm@is41jXycklPcScSx9YntNICx31l>JvMS{sn#Tq?{L)n5-3f=i4JFL8#HepNH|*T zZ00YxE<$!E9N%1g-k^iZ*EU?K7j7r()qmqk=oIqn9^#sZjTw(E#I_qdsIlJpv|?r8 zH?9?0BBQvqRlF5kF2nXrudg#?*z4*e-0gbxTtpoqa)~VjJyccQW`1j~KBLO9ZG&Pa zKopp9__-B8ehYE@_O%J1<~sHEC1n+?S@}H)xUOjG#bF8|{-wtxD?nCu^(rNdV*CB9 z%(9d`q1WL15cqf3!yQ<+`~m{zT5+BN&kx_r{Kq7!B28jd$A`Jfa}v}Ko0pKW7xR&m zZ{!V1Gfn5#j5!YwA11G%O4Nw7n2D+af%8%Vt80sjPpmXLdUGn(cfXR3SIe=6+CFTN zFqi6Fdm39r3v0^k&4ai?RJtQ%7^immmlyC#p(0K)(f)Sy<}X_={^c~Nt) za*&v~R58KK#RDT>8=vT<|SGt+BDoab^`0;)U7wBA&_09V&FytDP{Gl~k1r9@3xg&prLX-mn%Vs4JW<9lyT&UT?+6<6_MOzID;#oGU=27|mc0&G@lpmllPh zmdc@T#7!nBb8jfv4PD1(63dUJQS>^4^=u{QA)`Ni%hM-O(O&i_&B_`I**NoW{`+O;D z)9Qe(#d_?7g?Fj9+KW?8zyHpC=nfw*7k;?dEliJxD=Limz)@~z`*=dpJTD^;pb_E? zMaFRh6E)y5O_Q;Hm5_c|_3gVtfe8Q$ngoreO;ITT(o4jvW;f7N3)?MEM6Ns>HdhL{ z%oL`dc^{**Cp59oAAGmoKR}*cwD@thrrhLp>Yg{>k<3A?1P$$zbchFs;wT5nmy!dP zFdPCk%tNn(Jofs-Eu3q8kCh^NXm{;12>;l`bQA}h>Lux3K01Bw1z{H*dEh!g#Z1aS zf)+gc#QE*wnw71Ss4NH>xb*zsBacPleEaOm8BGo3!7fzwECRtHFTByNsIZN3hlcmXRX_x=l$p2%Zi#yX zz&q@(P~%L>vD6GgV+7_gW*MVG5 z>#U3LT?!WdR|?Fnl2^l8-Ls?Fode^1V8~LM(t_`6RJh7N>k`OaJok}f_N*k#ZZ{jT zeL5&yQ+x)Qp={Ua6<4@R_B=1Lzy=MGyT+aNV3;vUV?)Sa%#xVoV+_6M8&%g{+dg4t^QV%zG(%i2N7$}0$J_6+&#w4JSiU$OQ;<}MA`-oU0DW|$^@ zmwEBDKhfrjjfk701dU?|B|l8h<~Tf_4T$sfDUnSRbBHS{9s0SO+1Vr~VcQ$ghj@hB zajwTDHjJI&@XeXD*`rTgQn^S1lFb?XB zV^Ph$@Fg!2@;wv90}!aRj`!@p$~p z$!=pxyJiAz7`=}*zdt8c@Ch5kaQ1XO+Vs0JN^+I@=9)~|^w*x7>j855o0hN4VWWhh zsP(xYPz^9HFf-f|Qc+ClMES&$-dEQYq4>|9=JJkyv$V5ap8Yy|wpv`Nvk@A;AJRa=P}qiY_u$u(3uSEHyZhVh7Y#MZSnmMF7@FeHK-4_vv{(nA(i08 z$E3N9OBPi4Xr|foWVy9~HRIrRTWGrK`g7$x{TG|qT_*D6B=%`{`{0Jm8DIHgQxb(7 zc?a2wJ&;?nl&Yc_J29=j4#x)46j}1`x-gXj4_MCho7Xx@iT|gvbX1(Rk+2N!>5E$EJ$FQ707syH>O)>b|xk+#)=A~k}qL=H= ztaT%D2iV!X3`UJ5G8}$w1w7mLs4_7!ntStTlUG$Lx#s#^RBsMJ5`b#N@7+s@8sAZI znf(%L84RCDG;fT+m5T5w6b2OAiN$)1GL$PNW3J>x(*Xqn0z}hFvf8$DP+IEm&2h%$ zH=q=sB%HV%aSL8wwNlQeRX#~xjfYDmj~5P*J}hd##d1rvh%vj5o*6I(Xd}6=n9Hd_ zqsSDU@W4SGIg3_$c42IH;uh|)Jy`^Xy{hs>MsH!0r50;V_9KA>L3x26#4e?74cH^f zhAAn$f{sCb^=)G~!3#yuc!|c?{SGvru}j|e(oSC#Ar}4_5AFNx@rwPc>7~bx}3fU$R z#a}Fh${`JPUd~&kg;jDbt2!+%o@Tqvc4Ym-1nr_Z@1g{A{BW#j(mc>; zHTuvJ%lAId*9r*;daN%}rcc`Tx~~^>=KT)GvymX}jJ9rla``@J_G;?061leKL|Hxv zVx#I=6xck<^~4X@Zn%mn^dOg(p4z}b(Vy?I?&I-GE#uiR*r*Bb(jjgQeNEz-4WGiQPr}}i<`7u?_pE11ga#v~oyzGdoo+gbr!4AzPaiQ^S z$OQ7e($l2uyOq+#CjFNelPkWs=_?gsg8VNeacle+37B3rhF=9}G>m{r5j_Uxr98H( z=dl7aA_w64>$X&c{eaIfwc77+ff17VAtM}-(SXbJO86$mMvM}H5sTHU=UT7&qd!x$ zE)5Ugoe0JoHC0MFSS|&b>B7p27V0;jf-WITiSrRwbMsFfL3O3Y5W8lXDg`&I;~#P{ z;EGGb_j%WWV>mDCVMPRwz|#(|6TqTv1jJr6N0%1UqA~2tULx0YCf*Fd7Z^H$v`zYe z8EG>@Vbm{m{JV;wW(j71$CbXl^HWXt6Te`v{UZ|0X6QW z|C-|{MsUYic$Lb zg4+M~!`xKAZl?A+CxRZ3r7$ z=#~cS`840c!^~euVB)c7 zTkU$~>{(Z8zE#)2mh><42S8hOQSgdw!oBe*aDB%5Z#Gse##_OA2CcX1clDH3W28_7 zF=8ZL*+=|{9E3ETGZCQWIldqf*ZnNT;YWl93eSb+lXEzTgsJ&e+{$Lmk~bJr6=S5c_!m$3fFo^S(0QdBmIU<`M=1HNWQs#h$cgavE|ylko@pX z9ZrS2hWCmK&6?-ct;VwsKfn9$$@V!r#J36HsIl;~VN(1!aT9-LRNFd-2PtQ*XLTz< z^^J)8_)-g>k+sGY>1_~}m?GLCQM+== z(HHAOXnneu;?V`9=RKE7$XZTw z@IHmzTC1nnOg>X$fKg1Iy^c?Y2|!zJK)7yxkt*HQE1 z4@v`-;hxv-pBfw&GIe&R9A|&Z{%0}N-=4AdjXDg5#Scdg2&t9r$uPG9LP7->DymOKj09=uCqSdP@90iM+W}Y*mTE` zMF};f`V43M6OsutF-;}PIfI&|HV6+ex9`_C;2EQsn;9T$YPuEJ?v1>l@@j8%JAC=n zWFu-z_xB3N&mx~l&TNEl^hLNFnSrg3PXwEy_Bv7eY_+%|&)kb)ZpO}9pA|7K9e!3e z3#xZu($he(cti!YN#`9dX7vcrgQJRNCsh()hz5I^?{H!RIftD74jJh3@ssN7? zng$z}Lr%tJw)c`0_Z2NqC_h6Q%FPSw=Ou9w$@C@0(+cFHE=TVbTRPnoVCR+8F#jl* zP)P#C|5&G=t+juI1jK%KWZTDy&c{QLK#h5q#aDK@)coVc?D^vgL|pv$y+X=C3}NGc zH^s+_4rv~&v}}cS|9$y4;BrXM^+%7r;HA(8XYM(tqm8{=u<5>J2xo%-fv5&ECJT6rDg$m_#Si zfw%rKBY$vQ<8#B!>$nq&|32;^Nn~szE0|?$E1{tOt118Vb7kP1D3Y7GZ#}M|e*@xw zORP3_Soh`w`8TppRFTbr%^<$ZZv~U#Iwj!`5Pj!{Vmx4)o`bc_CPd3w>S!Vr@m zznudY{qpM*F*(AFNHPwm{d~@bHy{)s8W!O_QEve93j`g_+frqL?$@v1mkDlR0SH8b zS&06*>(_N3OB57+t+BJ;cm1N$8g`9>_u{TI=j8}zkA0h44W!vA^~@-j5T8vtF* zt|I<-8?Xo`$q11Luz5t>wCKO63o+dT2*H?&C4aW`KiB_kavrd8fi8U$B%t)`u0Aw0 z@5K1gf002pW--Sbd4A87cG90O_@8^dks%H2tLvv1SDP~;@)^wc058tNNdA<1s5c +``` + +**Database Connection Error**: +```bash +# Check PostgreSQL status +sudo systemctl status postgresql + +# Restart PostgreSQL +sudo systemctl restart postgresql +``` + +**Redis Connection Error**: +```bash +# Check Redis status +redis-cli ping + +# Start Redis +redis-server +``` + +**Permission Errors**: +```bash +# Fix Docker permissions +sudo usermod -aG docker $USER +# Log out and back in +``` + +### Docker Issues + +**Clean Reset**: +```bash +# Stop all containers +docker compose down + +# Remove volumes (โš ๏ธ deletes data) +docker compose down -v + +# Rebuild images +docker compose build --no-cache + +# Start fresh +docker compose up +``` + +## Next Steps + +After successful installation: + +1. **[Configuration Guide](configuration.md)** - Set up your environment +2. **[First Run](first-run.md)** - Test your installation +3. **[Project Structure](../user-guide/project-structure.md)** - Understand the codebase + +## Need Help? + +If you encounter issues: + +- Check the [GitHub Issues](https://github.com/benavlabs/fastapi-boilerplate/issues) for common problems +- Search [existing issues](https://github.com/benavlabs/fastapi-boilerplate/issues) +- Create a [new issue](https://github.com/benavlabs/fastapi-boilerplate/issues/new) with details \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..a0c624c --- /dev/null +++ b/docs/index.md @@ -0,0 +1,126 @@ +# Benav Labs FastAPI Boilerplate + +

+ Purple Rocket with FastAPI Logo as its window. +

+ +

+ A production-ready FastAPI boilerplate to speed up your development. +

+ +!!! warning "Documentation Status" + This is our first version of the documentation. While functional, we acknowledge it's rough around the edges - there's a huge amount to document and we needed to start somewhere! We built this foundation (with a lot of AI assistance) so we can improve upon it. + + Better documentation, examples, and guides are actively being developed. Contributions and feedback are greatly appreciated! + +

+ + FastAPI + + + Pydantic + + + PostgreSQL + + + Redis + + + Docker + +

+ +## What is FastAPI Boilerplate? + +FastAPI Boilerplate is a comprehensive, production-ready template that provides everything you need to build scalable, async APIs using modern Python technologies. It combines the power of FastAPI with industry best practices to give you a solid foundation for your next project. + +## Core Technologies + +This boilerplate leverages cutting-edge Python technologies: + +- **[FastAPI](https://fastapi.tiangolo.com)** - Modern, fast web framework for building APIs with Python 3.7+ +- **[Pydantic V2](https://docs.pydantic.dev/2.4/)** - Data validation library rewritten in Rust (5x-50x faster) +- **[SQLAlchemy 2.0](https://docs.sqlalchemy.org/en/20/)** - Python SQL toolkit and Object Relational Mapper +- **[PostgreSQL](https://www.postgresql.org)** - Advanced open source relational database +- **[Redis](https://redis.io)** - In-memory data store for caching and message brokering +- **[ARQ](https://arq-docs.helpmanual.io)** - Job queues and RPC with asyncio and Redis +- **[Docker](https://docs.docker.com/compose/)** - Containerization for easy deployment +- **[NGINX](https://nginx.org/en/)** - High-performance web server for reverse proxy and load balancing + +## Key Features + +### Performance & Scalability +- Fully async architecture +- Pydantic V2 for ultra-fast data validation +- SQLAlchemy 2.0 with efficient query patterns +- Built-in caching with Redis +- Horizontal scaling with NGINX load balancing + +### Security & Authentication +- JWT-based authentication with refresh tokens +- Cookie-based secure token storage +- Role-based access control with user tiers +- Rate limiting to prevent abuse +- Production-ready security configurations + +### Developer Experience +- Comprehensive CRUD operations with [FastCRUD](https://github.com/igorbenav/fastcrud) +- Automatic API documentation +- Database migrations with Alembic +- Background task processing +- Extensive test coverage +- Docker Compose for easy development + +### Production Ready +- Environment-based configuration +- Structured logging +- Health checks and monitoring +- NGINX reverse proxy setup +- Gunicorn with Uvicorn workers +- Database connection pooling + +## Quick Start + +Get up and running in less than 5 minutes: + +```bash +# Clone the repository +git clone https://github.com/benavlabs/fastapi-boilerplate +cd fastapi-boilerplate + +# Start with Docker Compose +docker compose up +``` + +That's it! Your API will be available at `http://localhost:8000/docs` + +**[Continue with the Getting Started Guide โ†’](getting-started/index.md)** + +## Documentation Structure + +### For New Users +- **[Getting Started](getting-started/index.md)** - Quick setup and first steps +- **[User Guide](user-guide/index.md)** - Comprehensive feature documentation + +### For Developers +- **[Development](user-guide/development.md)** - Extending and customizing the boilerplate +- **[Testing](user-guide/testing.md)** - Testing strategies and best practices +- **[Production](user-guide/production.md)** - Production deployment guides + +## Perfect For + +- **REST APIs** - Build robust, scalable REST APIs +- **Microservices** - Create microservice architectures +- **SaaS Applications** - Multi-tenant applications with user tiers +- **Data APIs** - APIs for data processing and analytics + +## Community & Support + +- **[Discord Community](community.md)** - Join our Discord server to connect with other developers +- **[GitHub Issues](https://github.com/benavlabs/fastapi-boilerplate/issues)** - Bug reports and feature requests + +
+ + Powered by Benav Labs - benav.io + \ No newline at end of file diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..578f14c --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,20 @@ +/* Make only the header/favicon logo white, keep other instances purple */ +.md-header__button.md-logo img, +.md-nav__button.md-logo img { + filter: brightness(0) invert(1); +} + +/* Ensure header logo is white in both light and dark modes */ +[data-md-color-scheme="default"] .md-header__button.md-logo img, +[data-md-color-scheme="default"] .md-nav__button.md-logo img { + filter: brightness(0) invert(1); +} + +[data-md-color-scheme="slate"] .md-header__button.md-logo img, +[data-md-color-scheme="slate"] .md-nav__button.md-logo img { + filter: brightness(0) invert(1); +} + +:root { + --md-primary-fg-color: #cd4bfb; +} \ No newline at end of file diff --git a/docs/user-guide/admin-panel/adding-models.md b/docs/user-guide/admin-panel/adding-models.md new file mode 100644 index 0000000..fd36852 --- /dev/null +++ b/docs/user-guide/admin-panel/adding-models.md @@ -0,0 +1,480 @@ +# Adding Models + +Learn how to extend the admin interface with your new models by following the patterns established in the FastAPI boilerplate. The boilerplate already includes User, Tier, and Post models - we'll show you how to add your own models using these working examples. + +> **CRUDAdmin Features**: This guide shows boilerplate-specific patterns. For advanced model configuration options and features, see the [CRUDAdmin documentation](https://benavlabs.github.io/crudadmin/). + +## Understanding the Existing Setup + +The boilerplate comes with three models already registered in the admin interface. Understanding how they're implemented will help you add your own models successfully. + +### Current Model Registration + +The admin interface is configured in `src/app/admin/views.py`: + +```python +def register_admin_views(admin: CRUDAdmin) -> None: + """Register all models and their schemas with the admin interface.""" + + # User model with password handling + password_transformer = PasswordTransformer( + password_field="password", + hashed_field="hashed_password", + hash_function=get_password_hash, + required_fields=["name", "username", "email"], + ) + + admin.add_view( + model=User, + create_schema=UserCreate, + update_schema=UserUpdate, + allowed_actions={"view", "create", "update"}, + password_transformer=password_transformer, + ) + + admin.add_view( + model=Tier, + create_schema=TierCreate, + update_schema=TierUpdate, + allowed_actions={"view", "create", "update", "delete"} + ) + + admin.add_view( + model=Post, + create_schema=PostCreateAdmin, # Special admin-only schema + update_schema=PostUpdate, + allowed_actions={"view", "create", "update", "delete"} + ) +``` + +Each model registration follows the same pattern: specify the SQLAlchemy model, appropriate Pydantic schemas for create/update operations, and define which actions are allowed. + +## Step-by-Step Model Addition + +Let's walk through adding a new model to your admin interface using a product catalog example. + +### Step 1: Create Your Model + +First, create your SQLAlchemy model following the boilerplate's patterns: + +```python +# src/app/models/product.py +from decimal import Decimal +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, Numeric, ForeignKey, Text, Boolean +from sqlalchemy.types import DateTime +from datetime import datetime + +from ..core.db.database import Base + +class Product(Base): + __tablename__ = "products" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + # Foreign key relationship (similar to Post.created_by_user_id) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id")) +``` + +### Step 2: Create Pydantic Schemas + +Create schemas for the admin interface following the boilerplate's pattern: + +```python +# src/app/schemas/product.py +from decimal import Decimal +from pydantic import BaseModel, Field +from typing import Annotated + +class ProductCreate(BaseModel): + name: Annotated[str, Field(min_length=2, max_length=100)] + description: Annotated[str | None, Field(max_length=1000, default=None)] + price: Annotated[Decimal, Field(gt=0, le=999999.99)] + is_active: Annotated[bool, Field(default=True)] + category_id: Annotated[int, Field(gt=0)] + +class ProductUpdate(BaseModel): + name: Annotated[str | None, Field(min_length=2, max_length=100, default=None)] + description: Annotated[str | None, Field(max_length=1000, default=None)] + price: Annotated[Decimal | None, Field(gt=0, le=999999.99, default=None)] + is_active: Annotated[bool | None, Field(default=None)] + category_id: Annotated[int | None, Field(gt=0, default=None)] +``` + +### Step 3: Register with Admin Interface + +Add your model to `src/app/admin/views.py`: + +```python +# Add import at the top +from ..models.product import Product +from ..schemas.product import ProductCreate, ProductUpdate + +def register_admin_views(admin: CRUDAdmin) -> None: + """Register all models and their schemas with the admin interface.""" + + # ... existing model registrations ... + + # Add your new model + admin.add_view( + model=Product, + create_schema=ProductCreate, + update_schema=ProductUpdate, + allowed_actions={"view", "create", "update", "delete"} + ) +``` + +### Step 4: Create and Run Migration + +Generate the database migration for your new model: + +```bash +# Generate migration +uv run alembic revision --autogenerate -m "Add product model" + +# Apply migration +uv run alembic upgrade head +``` + +### Step 5: Test Your New Model + +Start your application and test the new model in the admin interface: + +```bash +# Start the application +uv run fastapi dev + +# Visit http://localhost:8000/admin +# Login with your admin credentials +# You should see "Products" in the admin navigation +``` + +## Learning from Existing Models + +Each model in the boilerplate demonstrates different admin interface patterns you can follow. + +### User Model - Password Handling + +The User model shows how to handle sensitive fields like passwords: + +```python +# Password transformer for secure password handling +password_transformer = PasswordTransformer( + password_field="password", # Field in the schema + hashed_field="hashed_password", # Field in the database model + hash_function=get_password_hash, # Your app's hash function + required_fields=["name", "username", "email"], # Fields required for user creation +) + +admin.add_view( + model=User, + create_schema=UserCreate, + update_schema=UserUpdate, + allowed_actions={"view", "create", "update"}, # No delete for users + password_transformer=password_transformer, +) +``` + +**When to use this pattern:** + +- Models with password fields +- Any field that needs transformation before storage +- Fields requiring special security handling + +### Tier Model - Simple CRUD + +The Tier model demonstrates straightforward CRUD operations: + +```python +admin.add_view( + model=Tier, + create_schema=TierCreate, + update_schema=TierUpdate, + allowed_actions={"view", "create", "update", "delete"} # Full CRUD +) +``` + +**When to use this pattern:** + +- Reference data (categories, types, statuses) +- Configuration models +- Simple data without complex relationships + +### Post Model - Admin-Specific Schemas + +The Post model shows how to create admin-specific schemas when the regular API schemas don't work for admin purposes: + +```python +# Special admin schema (different from regular PostCreate) +class PostCreateAdmin(BaseModel): + title: Annotated[str, Field(min_length=2, max_length=30)] + text: Annotated[str, Field(min_length=1, max_length=63206)] + created_by_user_id: int # Required in admin, but not in API + media_url: Annotated[str | None, Field(pattern=r"^(https?|ftp)://[^\s/$.?#].[^\s]*$", default=None)] + +admin.add_view( + model=Post, + create_schema=PostCreateAdmin, # Admin-specific schema + update_schema=PostUpdate, # Regular update schema works fine + allowed_actions={"view", "create", "update", "delete"} +) +``` + +**When to use this pattern:** + +- Models where admins need to set fields that users can't +- Models requiring additional validation for admin operations +- Cases where API schemas are too restrictive or too permissive for admin use + +## Advanced Model Configuration + +### Customizing Field Display + +You can control how fields appear in the admin interface by modifying your schemas: + +```python +class ProductCreateAdmin(BaseModel): + name: Annotated[str, Field( + min_length=2, + max_length=100, + description="Product name as shown to customers" + )] + description: Annotated[str | None, Field( + max_length=1000, + description="Detailed product description (supports HTML)" + )] + price: Annotated[Decimal, Field( + gt=0, + le=999999.99, + description="Price in USD (up to 2 decimal places)" + )] + category_id: Annotated[int, Field( + gt=0, + description="Product category (creates dropdown automatically)" + )] +``` + +### Restricting Actions + +Control what operations are available for each model: + +```python +# Read-only model (reports, logs, etc.) +admin.add_view( + model=AuditLog, + create_schema=None, # No creation allowed + update_schema=None, # No updates allowed + allowed_actions={"view"} # Only viewing +) + +# No deletion allowed (users, critical data) +admin.add_view( + model=User, + create_schema=UserCreate, + update_schema=UserUpdate, + allowed_actions={"view", "create", "update"} # No delete +) +``` + +### Handling Complex Fields + +Some models may have fields that don't work well in the admin interface. Use select schemas to exclude problematic fields: + +```python +from pydantic import BaseModel + +# Create a simplified view schema +class ProductAdminView(BaseModel): + id: int + name: str + price: Decimal + is_active: bool + # Exclude complex fields like large text or binary data + +admin.add_view( + model=Product, + create_schema=ProductCreate, + update_schema=ProductUpdate, + select_schema=ProductAdminView, # Controls what's shown in lists + allowed_actions={"view", "create", "update", "delete"} +) +``` + +## Common Model Patterns + +### Reference Data Models + +For categories, types, and other reference data: + +```python +# Simple reference model +class Category(Base): + __tablename__ = "categories" + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50), unique=True) + description: Mapped[str | None] = mapped_column(Text) + +# Simple schemas +class CategoryCreate(BaseModel): + name: str = Field(..., min_length=2, max_length=50) + description: str | None = None + +# Registration +admin.add_view( + model=Category, + create_schema=CategoryCreate, + update_schema=CategoryCreate, # Same schema for create and update + allowed_actions={"view", "create", "update", "delete"} +) +``` + +### User-Generated Content + +For content models with user associations: + +```python +class BlogPost(Base): + __tablename__ = "blog_posts" + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(200)) + content: Mapped[str] = mapped_column(Text) + author_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + published_at: Mapped[datetime | None] = mapped_column(DateTime) + +# Admin schema with required author +class BlogPostCreateAdmin(BaseModel): + title: str = Field(..., min_length=5, max_length=200) + content: str = Field(..., min_length=10) + author_id: int = Field(..., gt=0) # Admin must specify author + published_at: datetime | None = None + +admin.add_view( + model=BlogPost, + create_schema=BlogPostCreateAdmin, + update_schema=BlogPostUpdate, + allowed_actions={"view", "create", "update", "delete"} +) +``` + +### Configuration Models + +For application settings and configuration: + +```python +class SystemSetting(Base): + __tablename__ = "system_settings" + id: Mapped[int] = mapped_column(primary_key=True) + key: Mapped[str] = mapped_column(String(100), unique=True) + value: Mapped[str] = mapped_column(Text) + description: Mapped[str | None] = mapped_column(Text) + +# Restricted actions - settings shouldn't be deleted +admin.add_view( + model=SystemSetting, + create_schema=SystemSettingCreate, + update_schema=SystemSettingUpdate, + allowed_actions={"view", "create", "update"} # No delete +) +``` + +## Testing Your Models + +After adding models to the admin interface, test them thoroughly: + +### Manual Testing + +1. **Access**: Navigate to `/admin` and log in +2. **Create**: Try creating new records with valid and invalid data +3. **Edit**: Test updating existing records +4. **Validation**: Verify that your schema validation works correctly +5. **Relationships**: Test foreign key relationships (dropdowns should populate) + +### Development Testing + +```python +# Test your admin configuration +# src/scripts/test_admin.py +from app.admin.initialize import create_admin_interface + +def test_admin_setup(): + admin = create_admin_interface() + if admin: + print("Admin interface created successfully") + print(f"Models registered: {len(admin._views)}") + for model_name in admin._views: + print(f" - {model_name}") + else: + print("Admin interface disabled") + +if __name__ == "__main__": + test_admin_setup() +``` + +```bash +# Run the test +uv run python src/scripts/test_admin.py +``` + +## Updating Model Registration + +When you need to modify how existing models appear in the admin interface: + +### Adding Actions + +```python +# Enable deletion for a model that previously didn't allow it +admin.add_view( + model=Product, + create_schema=ProductCreate, + update_schema=ProductUpdate, + allowed_actions={"view", "create", "update", "delete"} # Added delete +) +``` + +### Changing Schemas + +```python +# Switch to admin-specific schemas +admin.add_view( + model=User, + create_schema=UserCreateAdmin, # New admin schema + update_schema=UserUpdateAdmin, # New admin schema + allowed_actions={"view", "create", "update"}, + password_transformer=password_transformer, +) +``` + +### Performance Optimization + +For models with many records, consider using select schemas to limit data: + +```python +# Only show essential fields in lists +class UserListView(BaseModel): + id: int + username: str + email: str + is_active: bool + +admin.add_view( + model=User, + create_schema=UserCreate, + update_schema=UserUpdate, + select_schema=UserListView, # Faster list loading + allowed_actions={"view", "create", "update"}, + password_transformer=password_transformer, +) +``` + +## What's Next + +With your models successfully added to the admin interface, you're ready to: + +1. **[User Management](user-management.md)** - Learn how to manage admin users and implement security best practices + +Your models are now fully integrated into the admin interface and ready for production use. The admin panel will automatically handle form generation, validation, and database operations based on your model and schema definitions. \ No newline at end of file diff --git a/docs/user-guide/admin-panel/configuration.md b/docs/user-guide/admin-panel/configuration.md new file mode 100644 index 0000000..32ca406 --- /dev/null +++ b/docs/user-guide/admin-panel/configuration.md @@ -0,0 +1,378 @@ +# Configuration + +Learn how to configure the admin panel (powered by [CRUDAdmin](https://github.com/benavlabs/crudadmin)) using the FastAPI boilerplate's built-in environment variable system. The admin panel is fully integrated with your application's configuration and requires no additional setup files or complex initialization. + +> **About CRUDAdmin**: For complete configuration options and advanced features, see the [CRUDAdmin documentation](https://benavlabs.github.io/crudadmin/). + +## Environment-Based Configuration + +The FastAPI boilerplate handles all admin panel configuration through environment variables defined in your `.env` file. This approach provides consistent configuration across development, staging, and production environments. + +```bash +# Basic admin panel configuration in .env +CRUD_ADMIN_ENABLED=true +ADMIN_USERNAME="admin" +ADMIN_PASSWORD="SecurePassword123!" +CRUD_ADMIN_MOUNT_PATH="/admin" +``` + +The configuration system automatically: + +- Validates all environment variables at startup +- Provides sensible defaults for optional settings +- Adapts security settings based on your environment (local/staging/production) +- Integrates with your application's existing security and database systems + +## Core Configuration Settings + +### Enable/Disable Admin Panel + +Control whether the admin panel is available: + +```bash +# Enable admin panel (default: true) +CRUD_ADMIN_ENABLED=true + +# Disable admin panel completely +CRUD_ADMIN_ENABLED=false +``` + +When disabled, the admin interface is not mounted and consumes no resources. + +### Admin Access Credentials + +Configure the initial admin user that's created automatically: + +```bash +# Required: Admin user credentials +ADMIN_USERNAME="your-admin-username" # Admin login username +ADMIN_PASSWORD="YourSecurePassword123!" # Admin login password + +# Optional: Additional admin user details (uses existing settings) +ADMIN_NAME="Administrator" # Display name (from FirstUserSettings) +ADMIN_EMAIL="admin@yourcompany.com" # Admin email (from FirstUserSettings) +``` + +**How this works:** + +- The admin user is created automatically when the application starts +- Only created if no admin users exist (safe for restarts) +- Uses your application's existing password hashing system +- Credentials are validated according to CRUDAdmin requirements + +### Interface Configuration + +Customize where and how the admin panel appears: + +```bash +# Admin panel URL path (default: "/admin") +CRUD_ADMIN_MOUNT_PATH="/admin" # Access at http://localhost:8000/admin +CRUD_ADMIN_MOUNT_PATH="/management" # Access at http://localhost:8000/management +CRUD_ADMIN_MOUNT_PATH="/internal" # Access at http://localhost:8000/internal +``` + +The admin panel is mounted as a sub-application at your specified path. + +## Session Management Configuration + +Control how admin users stay logged in and how sessions are managed. + +### Basic Session Settings + +```bash +# Session limits and timeouts +CRUD_ADMIN_MAX_SESSIONS=10 # Max concurrent sessions per user +CRUD_ADMIN_SESSION_TIMEOUT=1440 # Session timeout in minutes (24 hours) + +# Cookie security +SESSION_SECURE_COOKIES=true # Require HTTPS for cookies (production) +``` + +**Session behavior:** + +- Each admin login creates a new session +- Sessions expire after the timeout period of inactivity +- When max sessions are exceeded, oldest sessions are removed +- Session cookies are HTTP-only and secure (when HTTPS is enabled) + +### Memory Sessions (Development) + +For local development, sessions are stored in memory by default: + +```bash +# Development configuration +ENVIRONMENT="local" # Enables memory sessions +CRUD_ADMIN_REDIS_ENABLED=false # Explicitly disable Redis (default) +``` + +**Memory session characteristics:** + +- Fast performance with no external dependencies +- Sessions lost when application restarts +- Suitable for single-developer environments +- Not suitable for load-balanced deployments + +### Redis Sessions (Production) + +For production deployments, enable Redis session storage: + +```bash +# Enable Redis sessions +CRUD_ADMIN_REDIS_ENABLED=true + +# Redis connection settings +CRUD_ADMIN_REDIS_HOST="localhost" # Redis server hostname +CRUD_ADMIN_REDIS_PORT=6379 # Redis server port +CRUD_ADMIN_REDIS_DB=0 # Redis database number +CRUD_ADMIN_REDIS_PASSWORD="secure-pass" # Redis authentication +CRUD_ADMIN_REDIS_SSL=false # Enable SSL/TLS connection +``` + +**Redis session benefits:** + +- Sessions persist across application restarts +- Supports multiple application instances (load balancing) +- Configurable expiration and cleanup +- Production-ready scalability + +**Redis URL construction:** + +The boilerplate automatically constructs the Redis URL from your environment variables: + +```python +# Automatic URL generation in src/app/admin/initialize.py +redis_url = f"redis{'s' if settings.CRUD_ADMIN_REDIS_SSL else ''}://" +if settings.CRUD_ADMIN_REDIS_PASSWORD: + redis_url += f":{settings.CRUD_ADMIN_REDIS_PASSWORD}@" +redis_url += f"{settings.CRUD_ADMIN_REDIS_HOST}:{settings.CRUD_ADMIN_REDIS_PORT}/{settings.CRUD_ADMIN_REDIS_DB}" +``` + +## Security Configuration + +The admin panel automatically adapts its security settings based on your deployment environment. + +### Environment-Based Security + +```bash +# Environment setting affects security behavior +ENVIRONMENT="local" # Development mode +ENVIRONMENT="staging" # Staging mode +ENVIRONMENT="production" # Production mode with enhanced security +``` + +**Security changes by environment:** + +| Setting | Local | Staging | Production | +|---------|-------|---------|------------| +| **HTTPS Enforcement** | Disabled | Optional | Enabled | +| **Secure Cookies** | Optional | Recommended | Required | +| **Session Tracking** | Optional | Recommended | Enabled | +| **Event Logging** | Optional | Recommended | Enabled | + +### Audit and Tracking + +Enable comprehensive logging for compliance and security monitoring: + +```bash +# Event and session tracking +CRUD_ADMIN_TRACK_EVENTS=true # Log all admin actions +CRUD_ADMIN_TRACK_SESSIONS=true # Track session lifecycle + +# Available in admin interface +# - View all admin actions with timestamps +# - Monitor active sessions +# - Track user activity patterns +``` + +### Access Restrictions + +The boilerplate supports IP and network-based access restrictions (configured in code): + +```python +# In src/app/admin/initialize.py - customize as needed +admin = CRUDAdmin( + # ... other settings ... + allowed_ips=settings.CRUD_ADMIN_ALLOWED_IPS_LIST, # Specific IP addresses + allowed_networks=settings.CRUD_ADMIN_ALLOWED_NETWORKS_LIST, # CIDR network ranges +) +``` + +To implement IP restrictions, extend the `CRUDAdminSettings` class in `src/app/core/config.py`. + +## Integration with Application Settings + +The admin panel leverages your existing application configuration for seamless integration. + +### Shared Security Settings + +```bash +# Uses your application's main secret key +SECRET_KEY="your-application-secret-key" # Shared with admin panel + +# Inherits database settings +POSTGRES_USER="dbuser" # Admin uses same database +POSTGRES_PASSWORD="dbpass" +POSTGRES_SERVER="localhost" +POSTGRES_DB="yourapp" +``` + +### Automatic Configuration Loading + +The admin panel automatically inherits settings from your application: + +```python +# In src/app/admin/initialize.py +admin = CRUDAdmin( + session=async_get_db, # Your app's database session + SECRET_KEY=settings.SECRET_KEY.get_secret_value(), # Your app's secret key + enforce_https=settings.ENVIRONMENT == EnvironmentOption.PRODUCTION, + # ... other settings from your app configuration +) +``` + +## Deployment Examples + +### Development Environment + +Perfect for local development with minimal setup: + +```bash +# .env.development +ENVIRONMENT="local" +CRUD_ADMIN_ENABLED=true +ADMIN_USERNAME="dev-admin" +ADMIN_PASSWORD="dev123" +CRUD_ADMIN_MOUNT_PATH="/admin" + +# Memory sessions - no external dependencies +CRUD_ADMIN_REDIS_ENABLED=false + +# Optional tracking for testing +CRUD_ADMIN_TRACK_EVENTS=false +CRUD_ADMIN_TRACK_SESSIONS=false +``` + +### Staging Environment + +Staging environment with Redis but relaxed security: + +```bash +# .env.staging +ENVIRONMENT="staging" +CRUD_ADMIN_ENABLED=true +ADMIN_USERNAME="staging-admin" +ADMIN_PASSWORD="StagingPassword123!" + +# Redis sessions for testing production behavior +CRUD_ADMIN_REDIS_ENABLED=true +CRUD_ADMIN_REDIS_HOST="staging-redis.example.com" +CRUD_ADMIN_REDIS_PASSWORD="staging-redis-pass" + +# Enable tracking for testing +CRUD_ADMIN_TRACK_EVENTS=true +CRUD_ADMIN_TRACK_SESSIONS=true +SESSION_SECURE_COOKIES=true +``` + +### Production Environment + +Production-ready configuration with full security: + +```bash +# .env.production +ENVIRONMENT="production" +CRUD_ADMIN_ENABLED=true +ADMIN_USERNAME="prod-admin" +ADMIN_PASSWORD="VerySecureProductionPassword123!" + +# Redis sessions for scalability +CRUD_ADMIN_REDIS_ENABLED=true +CRUD_ADMIN_REDIS_HOST="redis.internal.company.com" +CRUD_ADMIN_REDIS_PORT=6379 +CRUD_ADMIN_REDIS_PASSWORD="ultra-secure-redis-password" +CRUD_ADMIN_REDIS_SSL=true + +# Full security and tracking +SESSION_SECURE_COOKIES=true +CRUD_ADMIN_TRACK_EVENTS=true +CRUD_ADMIN_TRACK_SESSIONS=true +CRUD_ADMIN_MAX_SESSIONS=5 +CRUD_ADMIN_SESSION_TIMEOUT=480 # 8 hours for security +``` + +### Docker Deployment + +Configure for containerized deployments: + +```yaml +# docker-compose.yml +version: '3.8' +services: + web: + build: . + environment: + - ENVIRONMENT=production + - ADMIN_USERNAME=${ADMIN_USERNAME} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + + # Redis connection + - CRUD_ADMIN_REDIS_ENABLED=true + - CRUD_ADMIN_REDIS_HOST=redis + - CRUD_ADMIN_REDIS_PORT=6379 + - CRUD_ADMIN_REDIS_PASSWORD=${REDIS_PASSWORD} + + depends_on: + - redis + - postgres + + redis: + image: redis:7-alpine + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data +``` + +```bash +# .env file for Docker +ADMIN_USERNAME="docker-admin" +ADMIN_PASSWORD="DockerSecurePassword123!" +REDIS_PASSWORD="docker-redis-password" +``` + +## Configuration Validation + +The boilerplate automatically validates your configuration at startup and provides helpful error messages. + +### Common Configuration Issues + +**Missing Required Variables:** +```bash +# Error: Admin credentials not provided +# Solution: Add to .env +ADMIN_USERNAME="your-admin" +ADMIN_PASSWORD="your-password" +``` + +**Invalid Redis Configuration:** +```bash +# Error: Redis connection failed +# Check Redis server and credentials +CRUD_ADMIN_REDIS_HOST="correct-redis-host" +CRUD_ADMIN_REDIS_PASSWORD="correct-password" +``` + +**Security Warnings:** +```bash +# Warning: Weak admin password +# Use stronger password with mixed case, numbers, symbols +ADMIN_PASSWORD="StrongerPassword123!" +``` + +## What's Next + +With your admin panel configured, you're ready to: + +1. **[Adding Models](adding-models.md)** - Register your application models with the admin interface +2. **[User Management](user-management.md)** - Manage admin users and implement security best practices + +The configuration system provides flexibility for any deployment scenario while maintaining consistency across environments. \ No newline at end of file diff --git a/docs/user-guide/admin-panel/index.md b/docs/user-guide/admin-panel/index.md new file mode 100644 index 0000000..39a6442 --- /dev/null +++ b/docs/user-guide/admin-panel/index.md @@ -0,0 +1,295 @@ +# Admin Panel + +The FastAPI boilerplate comes with a pre-configured web-based admin interface powered by [CRUDAdmin](https://github.com/benavlabs/crudadmin) that provides instant database management capabilities. Learn how to access, configure, and customize the admin panel for your development and production needs. + +> **Powered by CRUDAdmin**: This admin panel is built with [CRUDAdmin](https://github.com/benavlabs/crudadmin), a modern admin interface generator for FastAPI applications. +> +> - **๐Ÿ“š CRUDAdmin Documentation**: [benavlabs.github.io/crudadmin](https://benavlabs.github.io/crudadmin/) +> - **๐Ÿ’ป CRUDAdmin GitHub**: [github.com/benavlabs/crudadmin](https://github.com/benavlabs/crudadmin) + +## What You'll Learn + +- **[Configuration](configuration.md)** - Environment variables and deployment settings +- **[Adding Models](adding-models.md)** - Register your new models with the admin interface +- **[User Management](user-management.md)** - Manage admin users and security + +## Admin Panel Overview + +Your FastAPI boilerplate includes a fully configured admin interface that's ready to use out of the box. The admin panel automatically provides web-based management for your database models without requiring any additional setup. + +**What's Already Configured:** + +- Complete admin interface mounted at `/admin` +- User, Tier, and Post models already registered +- Automatic form generation and validation +- Session management with configurable backends +- Security features and access controls + +**Accessing the Admin Panel:** + +1. Start your application: `uv run fastapi dev` +2. Navigate to: `http://localhost:8000/admin` +3. Login with default credentials (configured via environment variables) + +## Pre-Registered Models + +The boilerplate comes with three models already set up in the admin interface: + +### User Management +```python +# Already registered in your admin +admin.add_view( + model=User, + create_schema=UserCreate, + update_schema=UserUpdate, + allowed_actions={"view", "create", "update"}, + password_transformer=password_transformer, # Automatic password hashing +) +``` + +**Features:** + +- Create and manage application users +- Automatic password hashing with bcrypt +- User profile management (name, username, email) +- Tier assignment for subscription management + +### Tier Management +```python +# Subscription tiers for your application +admin.add_view( + model=Tier, + create_schema=TierCreate, + update_schema=TierUpdate, + allowed_actions={"view", "create", "update", "delete"} +) +``` + +**Features:** + +- Manage subscription tiers and pricing +- Configure rate limits per tier +- Full CRUD operations available + +### Content Management +```python +# Post/content management +admin.add_view( + model=Post, + create_schema=PostCreateAdmin, # Special admin schema + update_schema=PostUpdate, + allowed_actions={"view", "create", "update", "delete"} +) +``` + +**Features:** + +- Manage user-generated content +- Handle media URLs and content validation +- Associate posts with users + +## Quick Start + +### 1. Set Up Admin Credentials + +Configure your admin login in your `.env` file: + +```bash +# Admin Panel Access +ADMIN_USERNAME="your-admin-username" +ADMIN_PASSWORD="YourSecurePassword123!" + +# Basic Configuration +CRUD_ADMIN_ENABLED=true +CRUD_ADMIN_MOUNT_PATH="/admin" +``` + +### 2. Start the Application + +```bash +# Development +uv run fastapi dev + +# The admin panel will be available at: +# http://localhost:8000/admin +``` + +### 3. Login and Explore + +1. **Access**: Navigate to `/admin` in your browser +2. **Login**: Use the credentials from your environment variables +3. **Explore**: Browse the pre-configured models (Users, Tiers, Posts) + +## Environment Configuration + +The admin panel is configured entirely through environment variables, making it easy to adapt for different deployment environments. + +### Basic Settings + +```bash +# Enable/disable admin panel +CRUD_ADMIN_ENABLED=true # Set to false to disable completely + +# Admin interface path +CRUD_ADMIN_MOUNT_PATH="/admin" # Change the URL path + +# Admin user credentials (created automatically) +ADMIN_USERNAME="admin" # Your admin username +ADMIN_PASSWORD="SecurePassword123!" # Your admin password +``` + +### Session Management + +```bash +# Session configuration +CRUD_ADMIN_MAX_SESSIONS=10 # Max concurrent sessions per user +CRUD_ADMIN_SESSION_TIMEOUT=1440 # Session timeout (24 hours) +SESSION_SECURE_COOKIES=true # HTTPS-only cookies +``` + +### Production Security + +```bash +# Security settings for production +ENVIRONMENT="production" # Enables HTTPS enforcement +CRUD_ADMIN_TRACK_EVENTS=true # Log admin actions +CRUD_ADMIN_TRACK_SESSIONS=true # Track session activity +``` + +### Redis Session Storage + +For production deployments with multiple server instances: + +```bash +# Enable Redis sessions +CRUD_ADMIN_REDIS_ENABLED=true +CRUD_ADMIN_REDIS_HOST="localhost" +CRUD_ADMIN_REDIS_PORT=6379 +CRUD_ADMIN_REDIS_DB=0 +CRUD_ADMIN_REDIS_PASSWORD="your-redis-password" +CRUD_ADMIN_REDIS_SSL=false +``` + +## How It Works + +The admin panel integrates seamlessly with your FastAPI application through several key components: + +### Automatic Initialization + +```python +# In src/app/main.py - already configured +admin = create_admin_interface() + +@asynccontextmanager +async def lifespan_with_admin(app: FastAPI): + async with default_lifespan(app): + if admin: + await admin.initialize() # Sets up admin database + yield + +# Admin is mounted automatically at your configured path +if admin: + app.mount(settings.CRUD_ADMIN_MOUNT_PATH, admin.app) +``` + +### Configuration Integration + +```python +# In src/app/admin/initialize.py - uses your existing settings +admin = CRUDAdmin( + session=async_get_db, # Your database session + SECRET_KEY=settings.SECRET_KEY, # Your app's secret key + mount_path=settings.CRUD_ADMIN_MOUNT_PATH, # Configurable path + secure_cookies=settings.SESSION_SECURE_COOKIES, + enforce_https=settings.ENVIRONMENT == EnvironmentOption.PRODUCTION, + # ... all configured via environment variables +) +``` + +### Model Registration + +```python +# In src/app/admin/views.py - pre-configured models +def register_admin_views(admin: CRUDAdmin): + # Password handling for User model + password_transformer = PasswordTransformer( + password_field="password", + hashed_field="hashed_password", + hash_function=get_password_hash, # Uses your app's password hashing + ) + + # Register your models with appropriate schemas + admin.add_view(model=User, create_schema=UserCreate, ...) + admin.add_view(model=Tier, create_schema=TierCreate, ...) + admin.add_view(model=Post, create_schema=PostCreateAdmin, ...) +``` + +## Development vs Production + +### Development Setup + +For local development, minimal configuration is needed: + +```bash +# .env for development +CRUD_ADMIN_ENABLED=true +ADMIN_USERNAME="admin" +ADMIN_PASSWORD="admin123" +ENVIRONMENT="local" + +# Uses memory sessions (fast, no external dependencies) +CRUD_ADMIN_REDIS_ENABLED=false +``` + +### Production Setup + +For production deployments, enable additional security features: + +```bash +# .env for production +CRUD_ADMIN_ENABLED=true +ADMIN_USERNAME="production-admin" +ADMIN_PASSWORD="VerySecureProductionPassword123!" +ENVIRONMENT="production" + +# Redis sessions for scalability +CRUD_ADMIN_REDIS_ENABLED=true +CRUD_ADMIN_REDIS_HOST="your-redis-host" +CRUD_ADMIN_REDIS_PASSWORD="secure-redis-password" +CRUD_ADMIN_REDIS_SSL=true + +# Enhanced security +SESSION_SECURE_COOKIES=true +CRUD_ADMIN_TRACK_EVENTS=true +CRUD_ADMIN_TRACK_SESSIONS=true +``` + +## Getting Started Guide + +### 1. **[Configuration](configuration.md)** - Environment Setup + +Learn about all available environment variables and how to configure the admin panel for different deployment scenarios. Understand session backends and security settings. + +Perfect for setting up development environments and preparing for production deployment. + +### 2. **[Adding Models](adding-models.md)** - Extend the Admin Interface + +Discover how to register your new models with the admin interface. Learn from the existing User, Tier, and Post implementations to add your own models. + +Essential when you create new database models and want them managed through the admin interface. + +### 3. **[User Management](user-management.md)** - Admin Security + +Understand how admin authentication works, how to create additional admin users, and implement security best practices for production environments. + +Critical for production deployments where multiple team members need admin access. + +## What's Next + +Ready to start using your admin panel? Follow this path: + +1. **[Configuration](configuration.md)** - Set up your environment variables and understand deployment options +2. **[Adding Models](adding-models.md)** - Add your new models to the admin interface +3. **[User Management](user-management.md)** - Implement secure admin authentication + +The admin panel is ready to use immediately with sensible defaults, and each guide shows you how to customize it for your specific needs. \ No newline at end of file diff --git a/docs/user-guide/admin-panel/user-management.md b/docs/user-guide/admin-panel/user-management.md new file mode 100644 index 0000000..53d84f9 --- /dev/null +++ b/docs/user-guide/admin-panel/user-management.md @@ -0,0 +1,213 @@ +# User Management + +Learn how to manage admin users in your FastAPI boilerplate's admin panel. The boilerplate automatically creates admin users from environment variables and provides a separate authentication system (powered by [CRUDAdmin](https://github.com/benavlabs/crudadmin)) from your application users. + +> **CRUDAdmin Authentication**: For advanced authentication features and session management, see the [CRUDAdmin documentation](https://benavlabs.github.io/crudadmin/). + +## Initial Admin Setup + +### Configure Admin Credentials + +Set your admin credentials in your `.env` file: + +```bash +# Required admin credentials +ADMIN_USERNAME="admin" +ADMIN_PASSWORD="SecurePassword123!" + +# Optional details +ADMIN_NAME="Administrator" +ADMIN_EMAIL="admin@yourcompany.com" +``` + +### Access the Admin Panel + +Start your application and access the admin panel: + +```bash +# Start application +uv run fastapi dev + +# Visit: http://localhost:8000/admin +# Login with your ADMIN_USERNAME and ADMIN_PASSWORD +``` + +The boilerplate automatically creates the initial admin user from your environment variables when the application starts. + +## Managing Admin Users + +### Creating Additional Admin Users + +Once logged in, you can create more admin users through the admin interface: + +1. Navigate to the admin users section in the admin panel +2. Click "Create" or "Add New" +3. Fill in the required fields: + - Username (must be unique) + - Password (will be hashed automatically) + - Email (optional) + +### Admin User Requirements + +- **Username**: 3-50 characters, letters/numbers/underscores/hyphens +- **Password**: Minimum 8 characters with mixed case, numbers, and symbols +- **Email**: Valid email format (optional) + +### Updating and Removing Users + +- **Update**: Find the user in the admin panel and click "Edit" +- **Remove**: Click "Delete" (ensure you have alternative admin access first) + +## Security Configuration + +### Environment-Specific Settings + +Configure different security levels for each environment: + +```bash +# Development +ADMIN_USERNAME="dev-admin" +ADMIN_PASSWORD="DevPass123!" +ENVIRONMENT="local" + +# Production +ADMIN_USERNAME="prod-admin" +ADMIN_PASSWORD="VerySecurePassword123!" +ENVIRONMENT="production" +CRUD_ADMIN_TRACK_EVENTS=true +CRUD_ADMIN_TRACK_SESSIONS=true +SESSION_SECURE_COOKIES=true +``` + +### Session Management + +Control admin sessions with these settings: + +```bash +# Session limits and timeouts +CRUD_ADMIN_MAX_SESSIONS=10 # Max concurrent sessions per user +CRUD_ADMIN_SESSION_TIMEOUT=1440 # Timeout in minutes (24 hours) +SESSION_SECURE_COOKIES=true # HTTPS-only cookies +``` + +### Enable Tracking + +Monitor admin activity by enabling event tracking: + +```bash +# Track admin actions and sessions +CRUD_ADMIN_TRACK_EVENTS=true # Log all admin actions +CRUD_ADMIN_TRACK_SESSIONS=true # Track session lifecycle +``` + +## Production Deployment + +### Secure Credential Management + +For production, use Docker secrets or Kubernetes secrets instead of plain text: + +```yaml +# docker-compose.yml +services: + web: + secrets: + - admin_username + - admin_password + environment: + - ADMIN_USERNAME_FILE=/run/secrets/admin_username + - ADMIN_PASSWORD_FILE=/run/secrets/admin_password + +secrets: + admin_username: + file: ./secrets/admin_username.txt + admin_password: + file: ./secrets/admin_password.txt +``` + +### Production Security Settings + +```bash +# Production .env +ENVIRONMENT="production" +ADMIN_USERNAME="prod-admin" +ADMIN_PASSWORD="UltraSecurePassword123!" + +# Enhanced security +CRUD_ADMIN_REDIS_ENABLED=true +CRUD_ADMIN_REDIS_HOST="redis.internal.company.com" +CRUD_ADMIN_REDIS_PASSWORD="secure-redis-password" +CRUD_ADMIN_REDIS_SSL=true + +# Monitoring +CRUD_ADMIN_TRACK_EVENTS=true +CRUD_ADMIN_TRACK_SESSIONS=true +SESSION_SECURE_COOKIES=true +CRUD_ADMIN_MAX_SESSIONS=5 +CRUD_ADMIN_SESSION_TIMEOUT=480 # 8 hours +``` + +## Application User Management + +### Admin vs Application Users + +Your boilerplate maintains two separate user systems: + +- **Admin Users**: Access the admin panel (stored by CRUDAdmin) +- **Application Users**: Use your application (stored in your User model) + +### Managing Application Users + +Through the admin panel, you can manage your application's users: + +1. Navigate to "Users" section (your application users) +2. View, create, update user profiles +3. Manage user tiers and subscriptions +4. View user-generated content (posts) + +The User model is already registered with password hashing and proper permissions. + +## Emergency Recovery + +### Lost Admin Password + +If you lose admin access, update your environment variables: + +```bash +# Update .env file +ADMIN_USERNAME="emergency-admin" +ADMIN_PASSWORD="EmergencyPassword123!" + +# Restart application +uv run fastapi dev +``` + +### Database Recovery (Advanced) + +For direct database password reset: + +```python +# Generate bcrypt hash +import bcrypt +password = "NewPassword123!" +hashed = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()) +print(hashed.decode('utf-8')) +``` + +```sql +-- Update in database +UPDATE admin_users +SET password_hash = '' +WHERE username = 'admin'; +``` + +## What's Next + +Your admin user management is now configured with: + +- Automatic admin user creation from environment variables +- Secure authentication separate from application users +- Environment-specific security settings +- Production-ready credential management +- Emergency recovery procedures + +You can now securely manage both admin users and your application users through the admin panel. diff --git a/docs/user-guide/api/endpoints.md b/docs/user-guide/api/endpoints.md new file mode 100644 index 0000000..313c152 --- /dev/null +++ b/docs/user-guide/api/endpoints.md @@ -0,0 +1,327 @@ +# API Endpoints + +This guide shows you how to create API endpoints using the boilerplate's established patterns. You'll learn the common patterns you need for building CRUD APIs. + +## Quick Start + +Here's how to create a typical endpoint using the boilerplate's patterns: + +```python +from fastapi import APIRouter, Depends, HTTPException +from typing import Annotated + +from app.core.db.database import async_get_db +from app.crud.crud_users import crud_users +from app.schemas.user import UserRead, UserCreate +from app.api.dependencies import get_current_user + +router = APIRouter(prefix="/users", tags=["users"]) + +@router.get("/{user_id}", response_model=UserRead) +async def get_user( + user_id: int, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + """Get a user by ID.""" + user = await crud_users.get(db=db, id=user_id, schema_to_select=UserRead) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user +``` + +That's it! The boilerplate handles the rest. + +## Common Endpoint Patterns + +### 1. Get Single Item + +```python +@router.get("/{user_id}", response_model=UserRead) +async def get_user( + user_id: int, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + user = await crud_users.get(db=db, id=user_id, schema_to_select=UserRead) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user +``` + +### 2. Get Multiple Items (with Pagination) + +```python +from fastcrud.paginated import PaginatedListResponse, paginated_response + +@router.get("/", response_model=PaginatedListResponse[UserRead]) +async def get_users( + page: int = 1, + items_per_page: int = 10, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + users = await crud_users.get_multi( + db=db, + offset=(page - 1) * items_per_page, + limit=items_per_page, + schema_to_select=UserRead, + return_as_model=True, + return_total_count=True + ) + return paginated_response( + crud_data=users, + page=page, + items_per_page=items_per_page + ) +``` + +### 3. Create Item + +```python +@router.post("/", response_model=UserRead, status_code=201) +async def create_user( + user_data: UserCreate, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + # Check if user already exists + if await crud_users.exists(db=db, email=user_data.email): + raise HTTPException(status_code=409, detail="Email already exists") + + # Create user + new_user = await crud_users.create(db=db, object=user_data) + return new_user +``` + +### 4. Update Item + +```python +@router.patch("/{user_id}", response_model=UserRead) +async def update_user( + user_id: int, + user_data: UserUpdate, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + # Check if user exists + if not await crud_users.exists(db=db, id=user_id): + raise HTTPException(status_code=404, detail="User not found") + + # Update user + updated_user = await crud_users.update(db=db, object=user_data, id=user_id) + return updated_user +``` + +### 5. Delete Item (Soft Delete) + +```python +@router.delete("/{user_id}") +async def delete_user( + user_id: int, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + if not await crud_users.exists(db=db, id=user_id): + raise HTTPException(status_code=404, detail="User not found") + + await crud_users.delete(db=db, id=user_id) + return {"message": "User deleted"} +``` + +## Adding Authentication + +To require login, add the `get_current_user` dependency: + +```python +@router.get("/me", response_model=UserRead) +async def get_my_profile( + current_user: Annotated[dict, Depends(get_current_user)] +): + """Get current user's profile.""" + return current_user + +@router.post("/", response_model=UserRead) +async def create_user( + user_data: UserCreate, + current_user: Annotated[dict, Depends(get_current_user)], # Requires login + db: Annotated[AsyncSession, Depends(async_get_db)] +): + # Only logged-in users can create users + new_user = await crud_users.create(db=db, object=user_data) + return new_user +``` + +## Adding Admin-Only Endpoints + +For admin-only endpoints, use `get_current_superuser`: + +```python +from app.api.dependencies import get_current_superuser + +@router.delete("/{user_id}/permanent", dependencies=[Depends(get_current_superuser)]) +async def permanently_delete_user( + user_id: int, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + """Admin-only: Permanently delete user from database.""" + await crud_users.db_delete(db=db, id=user_id) + return {"message": "User permanently deleted"} +``` + +## Query Parameters + +### Simple Parameters + +```python +@router.get("/search") +async def search_users( + name: str | None = None, # Optional string + age: int | None = None, # Optional integer + is_active: bool = True, # Boolean with default + db: Annotated[AsyncSession, Depends(async_get_db)] +): + filters = {"is_active": is_active} + if name: + filters["name"] = name + if age: + filters["age"] = age + + users = await crud_users.get_multi(db=db, **filters) + return users["data"] +``` + +### Parameters with Validation + +```python +from fastapi import Query + +@router.get("/") +async def get_users( + page: Annotated[int, Query(ge=1)] = 1, # Must be >= 1 + limit: Annotated[int, Query(ge=1, le=100)] = 10, # Between 1-100 + search: Annotated[str | None, Query(max_length=50)] = None, # Max 50 chars + db: Annotated[AsyncSession, Depends(async_get_db)] +): + # Use the validated parameters + users = await crud_users.get_multi( + db=db, + offset=(page - 1) * limit, + limit=limit + ) + return users["data"] +``` + +## Error Handling + +The boilerplate includes custom exceptions you can use: + +```python +from app.core.exceptions.http_exceptions import ( + NotFoundException, + DuplicateValueException, + ForbiddenException +) + +@router.get("/{user_id}") +async def get_user(user_id: int, db: AsyncSession): + user = await crud_users.get(db=db, id=user_id) + if not user: + raise NotFoundException("User not found") # Returns 404 + return user + +@router.post("/") +async def create_user(user_data: UserCreate, db: AsyncSession): + if await crud_users.exists(db=db, email=user_data.email): + raise DuplicateValueException("Email already exists") # Returns 409 + + return await crud_users.create(db=db, object=user_data) +``` + +## File Uploads + +```python +from fastapi import UploadFile, File + +@router.post("/{user_id}/avatar") +async def upload_avatar( + user_id: int, + file: UploadFile = File(...), + current_user: Annotated[dict, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(async_get_db)] +): + # Check file type + if not file.content_type.startswith('image/'): + raise HTTPException(status_code=400, detail="File must be an image") + + # Save file and update user + # ... file handling logic ... + + return {"message": "Avatar uploaded successfully"} +``` + +## Creating New Endpoints + +### Step 1: Create the Router File + +Create `src/app/api/v1/posts.py`: + +```python +from fastapi import APIRouter, Depends, HTTPException +from typing import Annotated + +from app.core.db.database import async_get_db +from app.crud.crud_posts import crud_posts # You'll create this +from app.schemas.post import PostRead, PostCreate, PostUpdate # You'll create these +from app.api.dependencies import get_current_user + +router = APIRouter(prefix="/posts", tags=["posts"]) + +@router.get("/", response_model=list[PostRead]) +async def get_posts(db: Annotated[AsyncSession, Depends(async_get_db)]): + posts = await crud_posts.get_multi(db=db, schema_to_select=PostRead) + return posts["data"] + +@router.post("/", response_model=PostRead, status_code=201) +async def create_post( + post_data: PostCreate, + current_user: Annotated[dict, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(async_get_db)] +): + # Add current user as post author + post_dict = post_data.model_dump() + post_dict["author_id"] = current_user["id"] + + new_post = await crud_posts.create(db=db, object=post_dict) + return new_post +``` + +### Step 2: Register the Router + +In `src/app/api/v1/__init__.py`, add: + +```python +from .posts import router as posts_router + +api_router.include_router(posts_router) +``` + +### Step 3: Test Your Endpoints + +Your new endpoints will be available at: +- `GET /api/v1/posts/` - Get all posts +- `POST /api/v1/posts/` - Create new post (requires login) + +## Best Practices + +1. **Always use the database dependency**: `Depends(async_get_db)` +2. **Use existing CRUD methods**: `crud_users.get()`, `crud_users.create()`, etc. +3. **Check if items exist before operations**: Use `crud_users.exists()` +4. **Use proper HTTP status codes**: `status_code=201` for creation +5. **Add authentication when needed**: `Depends(get_current_user)` +6. **Use response models**: `response_model=UserRead` +7. **Handle errors with custom exceptions**: `NotFoundException`, `DuplicateValueException` + +## What's Next + +Now that you understand basic endpoints: + +- **[Pagination](pagination.md)** - Add pagination to your endpoints
+- **[Exceptions](exceptions.md)** - Custom error handling and HTTP exceptions
+- **[CRUD Operations](../database/crud.md)** - Understand the CRUD layer
+ +The boilerplate provides everything you need - just follow these patterns! \ No newline at end of file diff --git a/docs/user-guide/api/exceptions.md b/docs/user-guide/api/exceptions.md new file mode 100644 index 0000000..9186ff9 --- /dev/null +++ b/docs/user-guide/api/exceptions.md @@ -0,0 +1,465 @@ +# API Exception Handling + +Learn how to handle errors properly in your API endpoints using the boilerplate's built-in exceptions and patterns. + +## Quick Start + +The boilerplate provides ready-to-use exceptions that return proper HTTP status codes: + +```python +from app.core.exceptions.http_exceptions import NotFoundException + +@router.get("/{user_id}") +async def get_user(user_id: int, db: AsyncSession): + user = await crud_users.get(db=db, id=user_id) + if not user: + raise NotFoundException("User not found") # Returns 404 + return user +``` + +That's it! The exception automatically becomes a proper JSON error response. + +## Built-in Exceptions + +The boilerplate includes common HTTP exceptions you'll need: + +### NotFoundException (404) +```python +from app.core.exceptions.http_exceptions import NotFoundException + +@router.get("/{user_id}") +async def get_user(user_id: int): + user = await crud_users.get(db=db, id=user_id) + if not user: + raise NotFoundException("User not found") + return user + +# Returns: +# Status: 404 +# {"detail": "User not found"} +``` + +### DuplicateValueException (409) +```python +from app.core.exceptions.http_exceptions import DuplicateValueException + +@router.post("/") +async def create_user(user_data: UserCreate): + if await crud_users.exists(db=db, email=user_data.email): + raise DuplicateValueException("Email already exists") + + return await crud_users.create(db=db, object=user_data) + +# Returns: +# Status: 409 +# {"detail": "Email already exists"} +``` + +### ForbiddenException (403) +```python +from app.core.exceptions.http_exceptions import ForbiddenException + +@router.delete("/{user_id}") +async def delete_user( + user_id: int, + current_user: Annotated[dict, Depends(get_current_user)] +): + if current_user["id"] != user_id and not current_user["is_superuser"]: + raise ForbiddenException("You can only delete your own account") + + await crud_users.delete(db=db, id=user_id) + return {"message": "User deleted"} + +# Returns: +# Status: 403 +# {"detail": "You can only delete your own account"} +``` + +### UnauthorizedException (401) +```python +from app.core.exceptions.http_exceptions import UnauthorizedException + +# This is typically used in the auth system, but you can use it too: +@router.get("/admin-only") +async def admin_endpoint(): + # Some validation logic + if not user_is_admin: + raise UnauthorizedException("Admin access required") + + return {"data": "secret admin data"} + +# Returns: +# Status: 401 +# {"detail": "Admin access required"} +``` + +## Common Patterns + +### Check Before Create +```python +@router.post("/", response_model=UserRead) +async def create_user(user_data: UserCreate, db: AsyncSession): + # Check email + if await crud_users.exists(db=db, email=user_data.email): + raise DuplicateValueException("Email already exists") + + # Check username + if await crud_users.exists(db=db, username=user_data.username): + raise DuplicateValueException("Username already taken") + + # Create user + return await crud_users.create(db=db, object=user_data) + +# For public registration endpoints, consider rate limiting +# to prevent email enumeration attacks +``` + +### Check Before Update +```python +@router.patch("/{user_id}", response_model=UserRead) +async def update_user( + user_id: int, + user_data: UserUpdate, + db: AsyncSession +): + # Check if user exists + if not await crud_users.exists(db=db, id=user_id): + raise NotFoundException("User not found") + + # Check for email conflicts (if email is being updated) + if user_data.email: + existing = await crud_users.get(db=db, email=user_data.email) + if existing and existing.id != user_id: + raise DuplicateValueException("Email already taken") + + # Update user + return await crud_users.update(db=db, object=user_data, id=user_id) +``` + +### Check Ownership +```python +@router.get("/{post_id}") +async def get_post( + post_id: int, + current_user: Annotated[dict, Depends(get_current_user)], + db: AsyncSession +): + post = await crud_posts.get(db=db, id=post_id) + if not post: + raise NotFoundException("Post not found") + + # Check if user owns the post or is admin + if post.author_id != current_user["id"] and not current_user["is_superuser"]: + raise ForbiddenException("You can only view your own posts") + + return post +``` + +## Validation Errors + +FastAPI automatically handles Pydantic validation errors, but you can catch and customize them: + +```python +from fastapi import HTTPException +from pydantic import ValidationError + +@router.post("/") +async def create_user(user_data: UserCreate): + try: + # If user_data fails validation, Pydantic raises ValidationError + # FastAPI automatically converts this to a 422 response + return await crud_users.create(db=db, object=user_data) + except ValidationError as e: + # You can catch and customize if needed + raise HTTPException( + status_code=400, + detail=f"Invalid data: {e.errors()}" + ) +``` + +## Standard HTTP Exceptions + +For other status codes, use FastAPI's HTTPException: + +```python +from fastapi import HTTPException + +# Bad Request (400) +@router.post("/") +async def create_something(data: dict): + if not data.get("required_field"): + raise HTTPException( + status_code=400, + detail="required_field is missing" + ) + +# Too Many Requests (429) +@router.post("/") +async def rate_limited_endpoint(): + if rate_limit_exceeded(): + raise HTTPException( + status_code=429, + detail="Rate limit exceeded. Try again later." + ) + +# Internal Server Error (500) +@router.get("/") +async def risky_endpoint(): + try: + # Some operation that might fail + result = risky_operation() + return result + except Exception as e: + # Log the error + logger.error(f"Unexpected error: {e}") + raise HTTPException( + status_code=500, + detail="An unexpected error occurred" + ) +``` + +## Creating Custom Exceptions + +If you need custom exceptions, follow the boilerplate's pattern: + +```python +# In app/core/exceptions/http_exceptions.py (add to existing file) +from fastapi import HTTPException + +class PaymentRequiredException(HTTPException): + """402 Payment Required""" + def __init__(self, detail: str = "Payment required"): + super().__init__(status_code=402, detail=detail) + +class TooManyRequestsException(HTTPException): + """429 Too Many Requests""" + def __init__(self, detail: str = "Too many requests"): + super().__init__(status_code=429, detail=detail) + +# Use them in your endpoints +from app.core.exceptions.http_exceptions import PaymentRequiredException + +@router.get("/premium-feature") +async def premium_feature(current_user: dict): + if current_user["tier"] == "free": + raise PaymentRequiredException("Upgrade to access this feature") + + return {"data": "premium content"} +``` + +## Error Response Format + +All exceptions return consistent JSON responses: + +```json +{ + "detail": "Error message here" +} +``` + +For validation errors (422), you get more detail: + +```json +{ + "detail": [ + { + "type": "missing", + "loc": ["body", "email"], + "msg": "Field required", + "input": null + } + ] +} +``` + +## Global Exception Handling + +The boilerplate includes global exception handlers. You can add your own in `main.py`: + +```python +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +app = FastAPI() + +@app.exception_handler(ValueError) +async def value_error_handler(request: Request, exc: ValueError): + """Handle ValueError exceptions globally""" + return JSONResponse( + status_code=400, + content={"detail": f"Invalid value: {str(exc)}"} + ) + +@app.exception_handler(Exception) +async def general_exception_handler(request: Request, exc: Exception): + """Catch-all exception handler""" + # Log the error + logger.error(f"Unhandled exception: {exc}") + + return JSONResponse( + status_code=500, + content={"detail": "An unexpected error occurred"} + ) +``` + +## Security Considerations + +### Authentication Endpoints - Use Generic Messages + +For security, authentication endpoints should use generic error messages to prevent information disclosure: + +```python +# SECURITY: Don't reveal if username exists +@router.post("/login") +async def login(credentials: LoginCredentials): + user = await crud_users.get(db=db, username=credentials.username) + + # Don't do this - reveals if username exists + # if not user: + # raise NotFoundException("User not found") + # if not verify_password(credentials.password, user.hashed_password): + # raise UnauthorizedException("Invalid password") + + # Do this - generic message for all auth failures + if not user or not verify_password(credentials.password, user.hashed_password): + raise UnauthorizedException("Invalid username or password") + + return create_access_token(user.id) + +# SECURITY: Don't reveal if email is registered during password reset +@router.post("/forgot-password") +async def forgot_password(email: str): + user = await crud_users.get(db=db, email=email) + + # Don't do this - reveals if email exists + # if not user: + # raise NotFoundException("Email not found") + + # Do this - always return success message + if user: + await send_password_reset_email(user.email) + + # Always return the same message + return {"message": "If the email exists, a reset link has been sent"} +``` + +### Resource Access - Be Specific When Safe + +For non-auth operations, specific messages help developers: + +```python +# Safe to be specific for resource operations +@router.get("/{post_id}") +async def get_post( + post_id: int, + current_user: Annotated[dict, Depends(get_current_user)] +): + post = await crud_posts.get(db=db, id=post_id) + if not post: + raise NotFoundException("Post not found") # Safe to be specific + + if post.author_id != current_user["id"]: + # Don't reveal post exists if user can't access it + raise NotFoundException("Post not found") # Generic, not "Access denied" + + return post +``` + +## Best Practices + +### 1. Use Specific Exceptions (When Safe) +```python +# Good for non-sensitive operations +if not user: + raise NotFoundException("User not found") + +# Good for validation errors +raise DuplicateValueException("Username already taken") +``` + +### 2. Use Generic Messages for Security +```python +# Good for authentication +raise UnauthorizedException("Invalid username or password") + +# Good for authorization (don't reveal resource exists) +raise NotFoundException("Resource not found") # Instead of "Access denied" +``` + +### 3. Check Permissions Early +```python +@router.delete("/{user_id}") +async def delete_user( + user_id: int, + current_user: Annotated[dict, Depends(get_current_user)] +): + # Check permission first + if current_user["id"] != user_id: + raise ForbiddenException("Cannot delete other users") + + # Then check if user exists + if not await crud_users.exists(db=db, id=user_id): + raise NotFoundException("User not found") + + await crud_users.delete(db=db, id=user_id) +``` + +### 4. Log Important Errors +```python +import logging + +logger = logging.getLogger(__name__) + +@router.post("/") +async def create_user(user_data: UserCreate): + try: + return await crud_users.create(db=db, object=user_data) + except Exception as e: + logger.error(f"Failed to create user: {e}") + raise HTTPException(status_code=500, detail="User creation failed") +``` + +## Testing Exceptions + +Test that your endpoints raise the right exceptions: + +```python +import pytest +from httpx import AsyncClient + +@pytest.mark.asyncio +async def test_user_not_found(client: AsyncClient): + response = await client.get("/api/v1/users/99999") + assert response.status_code == 404 + assert "User not found" in response.json()["detail"] + +@pytest.mark.asyncio +async def test_duplicate_email(client: AsyncClient): + # Create a user + await client.post("/api/v1/users/", json={ + "name": "Test User", + "username": "test1", + "email": "test@example.com", + "password": "Password123!" + }) + + # Try to create another with same email + response = await client.post("/api/v1/users/", json={ + "name": "Test User 2", + "username": "test2", + "email": "test@example.com", # Same email + "password": "Password123!" + }) + + assert response.status_code == 409 + assert "Email already exists" in response.json()["detail"] +``` + +## What's Next + +Now that you understand error handling: +- **[Versioning](versioning.md)** - Learn how to version your APIs
+- **[Database CRUD](../database/crud.md)** - Understand the database operations
+- **[Authentication](../authentication/index.md)** - Add user authentication to your APIs + +Proper error handling makes your API much more user-friendly and easier to debug! \ No newline at end of file diff --git a/docs/user-guide/api/index.md b/docs/user-guide/api/index.md new file mode 100644 index 0000000..e76860e --- /dev/null +++ b/docs/user-guide/api/index.md @@ -0,0 +1,125 @@ +# API Development + +Learn how to build REST APIs with the FastAPI Boilerplate. This section covers everything you need to create robust, production-ready APIs. + +## What You'll Learn + +- **[Endpoints](endpoints.md)** - Create CRUD endpoints with authentication and validation +- **[Pagination](pagination.md)** - Add pagination to handle large datasets +- **[Exception Handling](exceptions.md)** - Handle errors properly with built-in exceptions +- **[API Versioning](versioning.md)** - Version your APIs and maintain backward compatibility +- **Database Integration** - Use the boilerplate's CRUD layer and schemas + +## Quick Overview + +The boilerplate provides everything you need for API development: + +```python +from fastapi import APIRouter, Depends +from app.crud.crud_users import crud_users +from app.schemas.user import UserRead, UserCreate +from app.core.db.database import async_get_db + +router = APIRouter(prefix="/users", tags=["users"]) + +@router.get("/", response_model=list[UserRead]) +async def get_users(db: Annotated[AsyncSession, Depends(async_get_db)]): + users = await crud_users.get_multi(db=db, schema_to_select=UserRead) + return users["data"] + +@router.post("/", response_model=UserRead, status_code=201) +async def create_user( + user_data: UserCreate, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + return await crud_users.create(db=db, object=user_data) +``` + +## Key Features + +### ๐Ÿ” **Built-in Authentication** +Add authentication to any endpoint: +```python +from app.api.dependencies import get_current_user + +@router.get("/me", response_model=UserRead) +async def get_profile(current_user: Annotated[dict, Depends(get_current_user)]): + return current_user +``` + +### ๐Ÿ“Š **Easy Pagination** +Paginate any endpoint with one line: +```python +from fastcrud.paginated import PaginatedListResponse + +@router.get("/", response_model=PaginatedListResponse[UserRead]) +async def get_users(page: int = 1, items_per_page: int = 10): + # Add pagination to any endpoint +``` + +### โœ… **Automatic Validation** +Request and response validation is handled automatically: +```python +@router.post("/", response_model=UserRead) +async def create_user(user_data: UserCreate): # โ† Validates input + return await crud_users.create(object=user_data) # โ† Validates output +``` + +### ๐Ÿ›ก๏ธ **Error Handling** +Use built-in exceptions for consistent error responses: +```python +from app.core.exceptions.http_exceptions import NotFoundException + +@router.get("/{user_id}") +async def get_user(user_id: int): + user = await crud_users.get(id=user_id) + if not user: + raise NotFoundException("User not found") # Returns proper 404 + return user +``` + +## Architecture + +The boilerplate follows a layered architecture: + +``` +API Endpoint + โ†“ +Pydantic Schema (validation) + โ†“ +CRUD Layer (database operations) + โ†“ +SQLAlchemy Model (database) +``` + +This separation makes your code: +- **Testable** - Mock any layer easily +- **Maintainable** - Clear separation of concerns +- **Scalable** - Add features without breaking existing code + +## Directory Structure + +```text +src/app/api/ +โ”œโ”€โ”€ dependencies.py # Shared dependencies (auth, rate limiting) +โ””โ”€โ”€ v1/ # API version 1 + โ”œโ”€โ”€ users.py # User endpoints + โ”œโ”€โ”€ posts.py # Post endpoints + โ”œโ”€โ”€ login.py # Authentication + โ””โ”€โ”€ ... # Other endpoints +``` + +## What's Next + +Start with the basics: + +1. **[Endpoints](endpoints.md)** - Learn the common patterns for creating API endpoints +2. **[Pagination](pagination.md)** - Add pagination to handle large datasets +3. **[Exception Handling](exceptions.md)** - Handle errors properly with built-in exceptions +4. **[API Versioning](versioning.md)** - Version your APIs and maintain backward compatibility + +Then dive deeper into the foundation: +5. **[Database Schemas](../database/schemas.md)** - Create schemas for your data +6. **[CRUD Operations](../database/crud.md)** - Understand the database layer + +Each guide builds on the previous one with practical examples you can use immediately. \ No newline at end of file diff --git a/docs/user-guide/api/pagination.md b/docs/user-guide/api/pagination.md new file mode 100644 index 0000000..26ffb5b --- /dev/null +++ b/docs/user-guide/api/pagination.md @@ -0,0 +1,316 @@ +# API Pagination + +This guide shows you how to add pagination to your API endpoints using the boilerplate's built-in utilities. Pagination helps you handle large datasets efficiently. + +## Quick Start + +Here's how to add basic pagination to any endpoint: + +```python +from fastcrud.paginated import PaginatedListResponse + +@router.get("/", response_model=PaginatedListResponse[UserRead]) +async def get_users( + page: int = 1, + items_per_page: int = 10, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + users = await crud_users.get_multi( + db=db, + offset=(page - 1) * items_per_page, + limit=items_per_page, + schema_to_select=UserRead, + return_as_model=True, + return_total_count=True + ) + + return paginated_response( + crud_data=users, + page=page, + items_per_page=items_per_page + ) +``` + +That's it! Your endpoint now returns paginated results with metadata. + +## What You Get + +The response includes everything frontends need: + +```json +{ + "data": [ + { + "id": 1, + "name": "John Doe", + "username": "johndoe", + "email": "john@example.com" + } + // ... more users + ], + "total_count": 150, + "has_more": true, + "page": 1, + "items_per_page": 10, + "total_pages": 15 +} +``` + +## Adding Filters + +You can easily add filtering to paginated endpoints: + +```python +@router.get("/", response_model=PaginatedListResponse[UserRead]) +async def get_users( + page: int = 1, + items_per_page: int = 10, + # Add filter parameters + search: str | None = None, + is_active: bool | None = None, + tier_id: int | None = None, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + # Build filters + filters = {} + if search: + filters["name__icontains"] = search # Search by name + if is_active is not None: + filters["is_active"] = is_active + if tier_id: + filters["tier_id"] = tier_id + + users = await crud_users.get_multi( + db=db, + offset=(page - 1) * items_per_page, + limit=items_per_page, + schema_to_select=UserRead, + return_as_model=True, + return_total_count=True, + **filters + ) + + return paginated_response( + crud_data=users, + page=page, + items_per_page=items_per_page + ) +``` + +Now you can call: + +- `/users/?search=john` - Find users with "john" in their name +- `/users/?is_active=true` - Only active users +- `/users/?tier_id=1&page=2` - Users in tier 1, page 2 + +## Adding Sorting + +Add sorting options to your paginated endpoints: + +```python +@router.get("/", response_model=PaginatedListResponse[UserRead]) +async def get_users( + page: int = 1, + items_per_page: int = 10, + # Add sorting parameters + sort_by: str = "created_at", + sort_order: str = "desc", + db: Annotated[AsyncSession, Depends(async_get_db)] +): + users = await crud_users.get_multi( + db=db, + offset=(page - 1) * items_per_page, + limit=items_per_page, + schema_to_select=UserRead, + return_as_model=True, + return_total_count=True, + sort_columns=sort_by, + sort_orders=sort_order + ) + + return paginated_response( + crud_data=users, + page=page, + items_per_page=items_per_page + ) +``` + +Usage: + +- `/users/?sort_by=name&sort_order=asc` - Sort by name A-Z +- `/users/?sort_by=created_at&sort_order=desc` - Newest first + +## Validation + +Add validation to prevent issues: + +```python +from fastapi import Query + +@router.get("/", response_model=PaginatedListResponse[UserRead]) +async def get_users( + page: Annotated[int, Query(ge=1)] = 1, # Must be >= 1 + items_per_page: Annotated[int, Query(ge=1, le=100)] = 10, # Between 1-100 + db: Annotated[AsyncSession, Depends(async_get_db)] +): + # Your pagination logic here +``` + +## Complete Example + +Here's a full-featured paginated endpoint: + +```python +@router.get("/", response_model=PaginatedListResponse[UserRead]) +async def get_users( + # Pagination + page: Annotated[int, Query(ge=1)] = 1, + items_per_page: Annotated[int, Query(ge=1, le=100)] = 10, + + # Filtering + search: Annotated[str | None, Query(max_length=100)] = None, + is_active: bool | None = None, + tier_id: int | None = None, + + # Sorting + sort_by: str = "created_at", + sort_order: str = "desc", + + db: Annotated[AsyncSession, Depends(async_get_db)] +): + """Get paginated users with filtering and sorting.""" + + # Build filters + filters = {"is_deleted": False} # Always exclude deleted users + + if is_active is not None: + filters["is_active"] = is_active + if tier_id: + filters["tier_id"] = tier_id + + # Handle search + search_criteria = [] + if search: + from sqlalchemy import or_, func + search_criteria = [ + or_( + func.lower(User.name).contains(search.lower()), + func.lower(User.username).contains(search.lower()), + func.lower(User.email).contains(search.lower()) + ) + ] + + users = await crud_users.get_multi( + db=db, + offset=(page - 1) * items_per_page, + limit=items_per_page, + schema_to_select=UserRead, + return_as_model=True, + return_total_count=True, + sort_columns=sort_by, + sort_orders=sort_order, + **filters, + **{"filter_criteria": search_criteria} if search_criteria else {} + ) + + return paginated_response( + crud_data=users, + page=page, + items_per_page=items_per_page + ) +``` + +This endpoint supports: + +- `/users/` - First 10 users +- `/users/?page=2&items_per_page=20` - Page 2, 20 items +- `/users/?search=john&is_active=true` - Active users named john +- `/users/?sort_by=name&sort_order=asc` - Sorted by name + +## Simple List (No Pagination) + +Sometimes you just want a simple list without pagination: + +```python +@router.get("/all", response_model=list[UserRead]) +async def get_all_users( + limit: int = 100, # Prevent too many results + db: Annotated[AsyncSession, Depends(async_get_db)] +): + users = await crud_users.get_multi( + db=db, + limit=limit, + schema_to_select=UserRead, + return_as_model=True + ) + return users["data"] +``` + +## Performance Tips + +1. **Always set a maximum page size**: +```python +items_per_page: Annotated[int, Query(ge=1, le=100)] = 10 # Max 100 items +``` + +2. **Use `schema_to_select` to only fetch needed fields**: +```python +users = await crud_users.get_multi( + schema_to_select=UserRead, # Only fetch UserRead fields + return_as_model=True +) +``` + +3. **Add database indexes** for columns you sort by: +```sql +-- In your migration +CREATE INDEX idx_users_created_at ON users(created_at); +CREATE INDEX idx_users_name ON users(name); +``` + +## Common Patterns + +### Admin List with All Users +```python +@router.get("/admin", dependencies=[Depends(get_current_superuser)]) +async def get_all_users_admin( + include_deleted: bool = False, + page: int = 1, + items_per_page: int = 50, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + filters = {} + if not include_deleted: + filters["is_deleted"] = False + + users = await crud_users.get_multi(db=db, **filters) + return paginated_response(users, page, items_per_page) +``` + +### User's Own Items +```python +@router.get("/my-posts", response_model=PaginatedListResponse[PostRead]) +async def get_my_posts( + page: int = 1, + items_per_page: int = 10, + current_user: Annotated[dict, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(async_get_db)] +): + posts = await crud_posts.get_multi( + db=db, + author_id=current_user["id"], # Only user's own posts + offset=(page - 1) * items_per_page, + limit=items_per_page + ) + return paginated_response(posts, page, items_per_page) +``` + +## What's Next + +Now that you understand pagination: + +- **[Database CRUD](../database/crud.md)** - Learn more about the CRUD operations +- **[Database Schemas](../database/schemas.md)** - Create schemas for your data +- **[Authentication](../authentication/index.md)** - Add user authentication to your endpoints + +The boilerplate makes pagination simple - just use these patterns! \ No newline at end of file diff --git a/docs/user-guide/api/versioning.md b/docs/user-guide/api/versioning.md new file mode 100644 index 0000000..299fa62 --- /dev/null +++ b/docs/user-guide/api/versioning.md @@ -0,0 +1,418 @@ +# API Versioning + +Learn how to version your APIs properly using the boilerplate's built-in versioning structure and best practices for maintaining backward compatibility. + +## Quick Start + +The boilerplate is already set up for versioning with a `v1` structure: + +```text +src/app/api/ +โ”œโ”€โ”€ dependencies.py # Shared across all versions +โ””โ”€โ”€ v1/ # Version 1 of your API + โ”œโ”€โ”€ __init__.py # Router registration + โ”œโ”€โ”€ users.py # User endpoints + โ”œโ”€โ”€ posts.py # Post endpoints + โ””โ”€โ”€ ... # Other endpoints +``` + +Your endpoints are automatically available at `/api/v1/...`: + +- `GET /api/v1/users/` - Get users +- `POST /api/v1/users/` - Create user +- `GET /api/v1/posts/` - Get posts + +## Current Structure + +### Version 1 (v1) + +The current API version is in `src/app/api/v1/`: + +```python +# src/app/api/v1/__init__.py +from fastapi import APIRouter + +from .users import router as users_router +from .posts import router as posts_router +from .login import router as login_router + +# Main v1 router +api_router = APIRouter() + +# Include all v1 endpoints +api_router.include_router(users_router) +api_router.include_router(posts_router) +api_router.include_router(login_router) +``` + +### Main App Registration + +In `src/app/main.py`, v1 is registered: + +```python +from fastapi import FastAPI +from app.api.v1 import api_router as api_v1_router + +app = FastAPI() + +# Register v1 API +app.include_router(api_v1_router, prefix="/api/v1") +``` + +## Adding Version 2 + +When you need to make breaking changes, create a new version: + +### Step 1: Create v2 Directory + +```text +src/app/api/ +โ”œโ”€โ”€ dependencies.py +โ”œโ”€โ”€ v1/ # Keep v1 unchanged +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ users.py +โ”‚ โ””โ”€โ”€ ... +โ””โ”€โ”€ v2/ # New version + โ”œโ”€โ”€ __init__.py + โ”œโ”€โ”€ users.py # Updated user endpoints + โ””โ”€โ”€ ... +``` + +### Step 2: Create v2 Router + +```python +# src/app/api/v2/__init__.py +from fastapi import APIRouter + +from .users import router as users_router +# Import other v2 routers + +# Main v2 router +api_router = APIRouter() + +# Include v2 endpoints +api_router.include_router(users_router) +``` + +### Step 3: Register v2 in Main App + +```python +# src/app/main.py +from fastapi import FastAPI +from app.api.v1 import api_router as api_v1_router +from app.api.v2 import api_router as api_v2_router + +app = FastAPI() + +# Register both versions +app.include_router(api_v1_router, prefix="/api/v1") +app.include_router(api_v2_router, prefix="/api/v2") +``` + +## Version 2 Example + +Here's how you might evolve the user endpoints in v2: + +### v1 User Endpoint +```python +# src/app/api/v1/users.py +from app.schemas.user import UserRead, UserCreate + +@router.get("/", response_model=list[UserRead]) +async def get_users(): + users = await crud_users.get_multi(db=db, schema_to_select=UserRead) + return users["data"] + +@router.post("/", response_model=UserRead) +async def create_user(user_data: UserCreate): + return await crud_users.create(db=db, object=user_data) +``` + +### v2 User Endpoint (with breaking changes) +```python +# src/app/api/v2/users.py +from app.schemas.user import UserReadV2, UserCreateV2 # New schemas +from fastcrud.paginated import PaginatedListResponse + +# Breaking change: Always return paginated response +@router.get("/", response_model=PaginatedListResponse[UserReadV2]) +async def get_users(page: int = 1, items_per_page: int = 10): + users = await crud_users.get_multi( + db=db, + offset=(page - 1) * items_per_page, + limit=items_per_page, + schema_to_select=UserReadV2 + ) + return paginated_response(users, page, items_per_page) + +# Breaking change: Require authentication +@router.post("/", response_model=UserReadV2) +async def create_user( + user_data: UserCreateV2, + current_user: Annotated[dict, Depends(get_current_user)] # Now required +): + return await crud_users.create(db=db, object=user_data) +``` + +## Schema Versioning + +Create separate schemas for different versions: + +### Version 1 Schema +```python +# src/app/schemas/user.py (existing) +class UserRead(BaseModel): + id: int + name: str + username: str + email: str + profile_image_url: str + tier_id: int | None + +class UserCreate(BaseModel): + name: str + username: str + email: str + password: str +``` + +### Version 2 Schema (with changes) +```python +# src/app/schemas/user_v2.py (new file) +from datetime import datetime + +class UserReadV2(BaseModel): + id: int + name: str + username: str + email: str + avatar_url: str # Changed from profile_image_url + subscription_tier: str # Changed from tier_id to string + created_at: datetime # New field + is_verified: bool # New field + +class UserCreateV2(BaseModel): + name: str + username: str + email: str + password: str + accept_terms: bool # New required field +``` + +## Gradual Migration Strategy + +### 1. Keep Both Versions Running + +```python +# Both versions work simultaneously +# v1: GET /api/v1/users/ -> list[UserRead] +# v2: GET /api/v2/users/ -> PaginatedListResponse[UserReadV2] +``` + +### 2. Add Deprecation Warnings + +```python +# src/app/api/v1/users.py +import warnings +from fastapi import HTTPException + +@router.get("/", response_model=list[UserRead]) +async def get_users(response: Response): + # Add deprecation header + response.headers["X-API-Deprecation"] = "v1 is deprecated. Use v2." + response.headers["X-API-Sunset"] = "2024-12-31" # When v1 will be removed + + users = await crud_users.get_multi(db=db, schema_to_select=UserRead) + return users["data"] +``` + +### 3. Monitor Usage + +Track which versions are being used: + +```python +# src/app/api/middleware.py +from fastapi import Request +import logging + +logger = logging.getLogger(__name__) + +async def version_tracking_middleware(request: Request, call_next): + if request.url.path.startswith("/api/v1/"): + logger.info(f"v1 usage: {request.method} {request.url.path}") + elif request.url.path.startswith("/api/v2/"): + logger.info(f"v2 usage: {request.method} {request.url.path}") + + response = await call_next(request) + return response +``` + +## Shared Code Between Versions + +Keep common logic in shared modules: + +### Shared Dependencies +```python +# src/app/api/dependencies.py - shared across all versions +async def get_current_user(...): + # Authentication logic used by all versions + pass + +async def get_db(): + # Database connection used by all versions + pass +``` + +### Shared CRUD Operations +```python +# The CRUD layer can be shared between versions +# Only the schemas and endpoints change + +# v1 endpoint +@router.get("/", response_model=list[UserRead]) +async def get_users_v1(): + users = await crud_users.get_multi(schema_to_select=UserRead) + return users["data"] + +# v2 endpoint +@router.get("/", response_model=PaginatedListResponse[UserReadV2]) +async def get_users_v2(): + users = await crud_users.get_multi(schema_to_select=UserReadV2) + return paginated_response(users, page, items_per_page) +``` + +## Version Discovery + +Let clients discover available versions: + +```python +# src/app/api/versions.py +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/versions") +async def get_api_versions(): + return { + "available_versions": ["v1", "v2"], + "current_version": "v2", + "deprecated_versions": [], + "sunset_dates": { + "v1": "2024-12-31" + } + } +``` + +Register it in main.py: +```python +# src/app/main.py +from app.api.versions import router as versions_router + +app.include_router(versions_router, prefix="/api") +# Now available at GET /api/versions +``` + +## Testing Multiple Versions + +Test both versions to ensure compatibility: + +```python +# tests/test_api_versioning.py +import pytest +from httpx import AsyncClient + +@pytest.mark.asyncio +async def test_v1_users(client: AsyncClient): + """Test v1 returns simple list""" + response = await client.get("/api/v1/users/") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) # v1 returns list + +@pytest.mark.asyncio +async def test_v2_users(client: AsyncClient): + """Test v2 returns paginated response""" + response = await client.get("/api/v2/users/") + assert response.status_code == 200 + + data = response.json() + assert "data" in data # v2 returns paginated response + assert "total_count" in data + assert "page" in data +``` + +## OpenAPI Documentation + +Each version gets its own docs: + +```python +# src/app/main.py +from fastapi import FastAPI + +# Create separate apps for documentation +v1_app = FastAPI(title="My API v1", version="1.0.0") +v2_app = FastAPI(title="My API v2", version="2.0.0") + +# Register routes +v1_app.include_router(api_v1_router) +v2_app.include_router(api_v2_router) + +# Mount as sub-applications +main_app = FastAPI() +main_app.mount("/api/v1", v1_app) +main_app.mount("/api/v2", v2_app) +``` + +Now you have separate documentation: +- `/api/v1/docs` - v1 documentation +- `/api/v2/docs` - v2 documentation + +## Best Practices + +### 1. Semantic Versioning + +- **v1.0** โ†’ **v1.1**: New features (backward compatible) +- **v1.1** โ†’ **v2.0**: Breaking changes (new version) + +### 2. Clear Migration Path + +```python +# Document what changed in v2 +""" +API v2 Changes: +- GET /users/ now returns paginated response instead of array +- POST /users/ now requires authentication +- UserRead.profile_image_url renamed to avatar_url +- UserRead.tier_id changed to subscription_tier (string) +- Added UserRead.created_at and is_verified fields +- UserCreate now requires accept_terms field +""" +``` + +### 3. Gradual Deprecation + +1. Release v2 alongside v1 +2. Add deprecation warnings to v1 +3. Set sunset date for v1 +4. Monitor v1 usage +5. Remove v1 after sunset date + +### 4. Consistent Patterns + +Keep the same patterns across versions: + +- Same URL structure: `/api/v{number}/resource` +- Same HTTP methods and status codes +- Same authentication approach +- Same error response format + +## What's Next + +Now that you understand API versioning: + +- **[Database Migrations](../database/migrations.md)** - Handle database schema changes +- **[Testing](../testing.md)** - Test multiple API versions +- **[Production](../production.md)** - Deploy versioned APIs + +Proper versioning lets you evolve your API without breaking existing clients! \ No newline at end of file diff --git a/docs/user-guide/authentication/index.md b/docs/user-guide/authentication/index.md new file mode 100644 index 0000000..a78f380 --- /dev/null +++ b/docs/user-guide/authentication/index.md @@ -0,0 +1,198 @@ +# Authentication & Security + +Learn how to implement secure authentication in your FastAPI application. The boilerplate provides a complete JWT-based authentication system with user management, permissions, and security best practices. + +## What You'll Learn + +- **[JWT Tokens](jwt-tokens.md)** - Understand access and refresh token management +- **[User Management](user-management.md)** - Handle registration, login, and user profiles +- **[Permissions](permissions.md)** - Implement role-based access control and authorization + +## Authentication Overview + +The system uses JWT tokens with refresh token rotation for secure, stateless authentication: + +```python +# Basic login flow +@router.post("/login", response_model=Token) +async def login_for_access_token(response: Response, form_data: OAuth2PasswordRequestForm): + user = await authenticate_user(form_data.username, form_data.password, db) + access_token = await create_access_token(data={"sub": user["username"]}) + refresh_token = await create_refresh_token(data={"sub": user["username"]}) + + # Set secure HTTP-only cookie for refresh token + response.set_cookie("refresh_token", refresh_token, httponly=True, secure=True) + return {"access_token": access_token, "token_type": "bearer"} +``` + +## Key Features + +### JWT Token System +- **Access tokens**: Short-lived (30 minutes), for API requests +- **Refresh tokens**: Long-lived (7 days), stored in secure cookies +- **Token blacklisting**: Secure logout implementation +- **Automatic expiration**: Built-in token lifecycle management + +### User Management +- **Flexible authentication**: Username or email login +- **Secure passwords**: bcrypt hashing with salt +- **Profile management**: Complete user CRUD operations +- **Soft delete**: User deactivation without data loss + +### Permission System +- **Superuser privileges**: Administrative access control +- **Resource ownership**: User-specific data access +- **User tiers**: Subscription-based feature access +- **Rate limiting**: Per-user and per-tier API limits + +## Authentication Patterns + +### Endpoint Protection + +```python +# Required authentication +@router.get("/protected") +async def protected_endpoint(current_user: dict = Depends(get_current_user)): + return {"message": f"Hello {current_user['username']}"} + +# Optional authentication +@router.get("/public") +async def public_endpoint(user: dict | None = Depends(get_optional_user)): + if user: + return {"premium_content": True} + return {"premium_content": False} + +# Superuser only +@router.get("/admin", dependencies=[Depends(get_current_superuser)]) +async def admin_endpoint(): + return {"admin_data": "sensitive"} +``` + +### Resource Ownership + +```python +@router.patch("/posts/{post_id}") +async def update_post(post_id: int, current_user: dict = Depends(get_current_user)): + post = await crud_posts.get(db=db, id=post_id) + + # Check ownership or admin privileges + if post["created_by_user_id"] != current_user["id"] and not current_user["is_superuser"]: + raise ForbiddenException("Cannot update other users' posts") + + return await crud_posts.update(db=db, id=post_id, object=updates) +``` + +## Security Features + +### Token Security +- Short-lived access tokens limit exposure +- HTTP-only refresh token cookies prevent XSS +- Token blacklisting enables secure logout +- Configurable token expiration times + +### Password Security +- bcrypt hashing with automatic salt generation +- Configurable password complexity requirements +- No plain text passwords stored anywhere +- Rate limiting on authentication endpoints + +### API Protection +- CORS policies for cross-origin request control +- Rate limiting prevents brute force attacks +- Input validation prevents injection attacks +- Consistent error messages prevent information disclosure + +## Configuration + +### JWT Settings +```env +SECRET_KEY="your-super-secret-key-here" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 +``` + +### Security Settings +```env +# Cookie security +COOKIE_SECURE=true +COOKIE_SAMESITE="lax" + +# Password requirements +PASSWORD_MIN_LENGTH=8 +ENABLE_PASSWORD_COMPLEXITY=true +``` + +## Getting Started + +Follow this progressive learning path: + +### 1. **[JWT Tokens](jwt-tokens.md)** - Foundation +Understand how JWT tokens work, including access and refresh token management, verification, and blacklisting. + +### 2. **[User Management](user-management.md)** - Core Features +Implement user registration, login, profile management, and administrative operations. + +### 3. **[Permissions](permissions.md)** - Access Control +Set up role-based access control, resource ownership checking, and tier-based permissions. + +## Implementation Examples + +### Quick Authentication Setup + +```python +# Protect an endpoint +@router.get("/my-data") +async def get_my_data(current_user: dict = Depends(get_current_user)): + return await get_user_specific_data(current_user["id"]) + +# Check user permissions +def check_tier_access(user: dict, required_tier: str): + if not user.get("tier") or user["tier"]["name"] != required_tier: + raise ForbiddenException(f"Requires {required_tier} tier") + +# Custom authentication dependency +async def get_premium_user(current_user: dict = Depends(get_current_user)): + check_tier_access(current_user, "Pro") + return current_user +``` + +### Frontend Integration + +```javascript +// Basic authentication flow +class AuthManager { + async login(username, password) { + const response = await fetch('/api/v1/login', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: new URLSearchParams({username, password}) + }); + + const tokens = await response.json(); + localStorage.setItem('access_token', tokens.access_token); + return tokens; + } + + async makeAuthenticatedRequest(url, options = {}) { + const token = localStorage.getItem('access_token'); + return fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${token}` + } + }); + } +} +``` + +## What's Next + +Start building your authentication system: + +1. **[JWT Tokens](jwt-tokens.md)** - Learn token creation, verification, and lifecycle management +2. **[User Management](user-management.md)** - Implement registration, login, and profile operations +3. **[Permissions](permissions.md)** - Add authorization patterns and access control + +The authentication system provides a secure foundation for your API. Each guide includes practical examples and implementation details for production-ready authentication. \ No newline at end of file diff --git a/docs/user-guide/authentication/jwt-tokens.md b/docs/user-guide/authentication/jwt-tokens.md new file mode 100644 index 0000000..1b4d30c --- /dev/null +++ b/docs/user-guide/authentication/jwt-tokens.md @@ -0,0 +1,669 @@ +# JWT Tokens + +JSON Web Tokens (JWT) form the backbone of modern web authentication. This comprehensive guide explains how the boilerplate implements a secure, stateless authentication system using access and refresh tokens. + +## Understanding JWT Authentication + +JWT tokens are self-contained, digitally signed packages of information that can be safely transmitted between parties. Unlike traditional session-based authentication that requires server-side storage, JWT tokens are stateless - all the information needed to verify a user's identity is contained within the token itself. + +### Why Use JWT? + +**Stateless Design**: No need to store session data on the server, making it perfect for distributed systems and microservices. + +**Scalability**: Since tokens contain all necessary information, they work seamlessly across multiple servers without shared session storage. + +**Security**: Digital signatures ensure tokens can't be tampered with, and expiration times limit exposure if compromised. + +**Cross-Domain Support**: Unlike cookies, JWT tokens work across different domains and can be used in mobile applications. + +## Token Types + +The authentication system uses a **dual-token approach** for maximum security and user experience: + +### Access Tokens +Access tokens are short-lived credentials that prove a user's identity for API requests. Think of them as temporary keys that grant access to protected resources. + +- **Purpose**: Authenticate API requests and authorize actions +- **Lifetime**: 30 minutes (configurable) - short enough to limit damage if compromised +- **Storage**: Authorization header (`Bearer `) - sent with each API request +- **Usage**: Include in every call to protected endpoints + +**Why Short-Lived?** If an access token is stolen (e.g., through XSS), the damage window is limited to 30 minutes before it expires naturally. + +### Refresh Tokens +Refresh tokens are longer-lived credentials used solely to generate new access tokens. They provide a balance between security and user convenience. + +- **Purpose**: Generate new access tokens without requiring re-login +- **Lifetime**: 7 days (configurable) - long enough for good UX, short enough for security +- **Storage**: Secure HTTP-only cookie - inaccessible to JavaScript, preventing XSS attacks +- **Usage**: Automatically used by the browser when access tokens need refreshing + +**Why HTTP-Only Cookies?** This prevents malicious JavaScript from accessing refresh tokens, providing protection against XSS attacks while allowing automatic renewal. + +## Token Creation + +Understanding how tokens are created helps you customize the authentication system for your specific needs. + +### Creating Access Tokens + +Access tokens are generated during login and token refresh operations. The process involves encoding user information with an expiration time and signing it with your secret key. + +```python +from datetime import timedelta +from app.core.security import create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES + +# Basic access token with default expiration +access_token = await create_access_token(data={"sub": username}) + +# Custom expiration for special cases (e.g., admin sessions) +custom_expires = timedelta(minutes=60) +access_token = await create_access_token( + data={"sub": username}, + expires_delta=custom_expires +) +``` + +**When to Customize Expiration:** +- **High-security environments**: Shorter expiration (15 minutes) +- **Development/testing**: Longer expiration for convenience +- **Admin operations**: Variable expiration based on sensitivity + +### Creating Refresh Tokens + +Refresh tokens follow the same creation pattern but with longer expiration times. They're typically created only during login. + +```python +from app.core.security import create_refresh_token, REFRESH_TOKEN_EXPIRE_DAYS + +# Standard refresh token +refresh_token = await create_refresh_token(data={"sub": username}) + +# Extended refresh token for "remember me" functionality +extended_expires = timedelta(days=30) +refresh_token = await create_refresh_token( + data={"sub": username}, + expires_delta=extended_expires +) +``` + +### Token Structure + +JWT tokens consist of three parts separated by dots: `header.payload.signature`. The payload contains the actual user information and metadata. + +```python +# Access token payload structure +{ + "sub": "username", # Subject (user identifier) + "exp": 1234567890, # Expiration timestamp (Unix) + "token_type": "access", # Distinguishes from refresh tokens + "iat": 1234567890 # Issued at (automatic) +} + +# Refresh token payload structure +{ + "sub": "username", # Same user identifier + "exp": 1234567890, # Longer expiration time + "token_type": "refresh", # Prevents confusion/misuse + "iat": 1234567890 # Issue timestamp +} +``` + +**Key Fields Explained:** +- **`sub` (Subject)**: Identifies the user - can be username, email, or user ID +- **`exp` (Expiration)**: Unix timestamp when token becomes invalid +- **`token_type`**: Custom field preventing tokens from being used incorrectly +- **`iat` (Issued At)**: Useful for token rotation and audit trails + +## Token Verification + +Token verification is a multi-step process that ensures both the token's authenticity and the user's current authorization status. + +### Verifying Access Tokens + +Every protected endpoint must verify the access token before processing the request. This involves checking the signature, expiration, and blacklist status. + +```python +from app.core.security import verify_token, TokenType + +# Verify access token in endpoint +token_data = await verify_token(token, TokenType.ACCESS, db) +if token_data: + username = token_data.username_or_email + # Token is valid, proceed with request processing +else: + # Token is invalid, expired, or blacklisted + raise UnauthorizedException("Invalid or expired token") +``` + +### Verifying Refresh Tokens + +Refresh token verification follows the same process but with different validation rules and outcomes. + +```python +# Verify refresh token for renewal +token_data = await verify_token(token, TokenType.REFRESH, db) +if token_data: + # Generate new access token + new_access_token = await create_access_token( + data={"sub": token_data.username_or_email} + ) + return {"access_token": new_access_token, "token_type": "bearer"} +else: + # Refresh token invalid - user must log in again + raise UnauthorizedException("Invalid refresh token") +``` + +### Token Verification Process + +The verification process includes several security checks to prevent various attack vectors: + +```python +async def verify_token(token: str, expected_token_type: TokenType, db: AsyncSession) -> TokenData | None: + # 1. Check blacklist first (prevents use of logged-out tokens) + is_blacklisted = await crud_token_blacklist.exists(db, token=token) + if is_blacklisted: + return None + + try: + # 2. Verify signature and decode payload + payload = jwt.decode(token, SECRET_KEY.get_secret_value(), algorithms=[ALGORITHM]) + + # 3. Extract and validate claims + username_or_email: str | None = payload.get("sub") + token_type: str | None = payload.get("token_type") + + # 4. Ensure token type matches expectation + if username_or_email is None or token_type != expected_token_type: + return None + + # 5. Return validated data + return TokenData(username_or_email=username_or_email) + + except JWTError: + # Token is malformed, expired, or signature invalid + return None +``` + +**Security Checks Explained:** + +1. **Blacklist Check**: Prevents use of tokens from logged-out users +2. **Signature Verification**: Ensures token hasn't been tampered with +3. **Expiration Check**: Automatically handled by JWT library +4. **Type Validation**: Prevents refresh tokens from being used as access tokens +5. **Subject Validation**: Ensures token contains valid user identifier + +## Client-Side Authentication Flow + +Understanding the complete authentication flow helps frontend developers integrate properly with the API. + +### Recommended Client Flow + +**1. Login Process** +```javascript +// Send credentials to login endpoint +const response = await fetch('/api/v1/login', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'username=user&password=pass', + credentials: 'include' // Important: includes cookies +}); + +const { access_token, token_type } = await response.json(); + +// Store access token in memory (not localStorage) +sessionStorage.setItem('access_token', access_token); +``` + +**2. Making Authenticated Requests** +```javascript +// Include access token in Authorization header +const response = await fetch('/api/v1/protected-endpoint', { + headers: { + 'Authorization': `Bearer ${sessionStorage.getItem('access_token')}` + }, + credentials: 'include' +}); +``` + +**3. Handling Token Expiration** +```javascript +// Automatic token refresh on 401 errors +async function apiCall(url, options = {}) { + let response = await fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${sessionStorage.getItem('access_token')}` + }, + credentials: 'include' + }); + + // If token expired, try to refresh + if (response.status === 401) { + const refreshResponse = await fetch('/api/v1/refresh', { + method: 'POST', + credentials: 'include' // Sends refresh token cookie + }); + + if (refreshResponse.ok) { + const { access_token } = await refreshResponse.json(); + sessionStorage.setItem('access_token', access_token); + + // Retry original request + response = await fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${access_token}` + }, + credentials: 'include' + }); + } else { + // Refresh failed - redirect to login + window.location.href = '/login'; + } + } + + return response; +} +``` + +**4. Logout Process** +```javascript +// Clear tokens and call logout endpoint +await fetch('/api/v1/logout', { + method: 'POST', + credentials: 'include' +}); + +sessionStorage.removeItem('access_token'); +// Refresh token cookie is cleared by server +``` + +### Cookie Configuration + +The refresh token cookie is configured for maximum security: + +```python +response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, # Prevents JavaScript access (XSS protection) + secure=True, # HTTPS only in production + samesite="Lax", # CSRF protection with good usability + max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 +) +``` + +**SameSite Options:** + +- **`Lax`** (Recommended): Cookies sent on top-level navigation but not cross-site requests +- **`Strict`**: Maximum security but may break some user flows +- **`None`**: Required for cross-origin requests (must use with Secure) + +## Token Blacklisting + +Token blacklisting solves a fundamental problem with JWT tokens: once issued, they remain valid until expiration, even if the user logs out. Blacklisting provides immediate token revocation. + +### Why Blacklisting Matters + +Without blacklisting, logged-out users could continue accessing your API until their tokens naturally expire. This creates security risks, especially on shared computers or if tokens are compromised. + +### Blacklisting Implementation + +The system uses a database table to track invalidated tokens: + +```python +# models/token_blacklist.py +class TokenBlacklist(Base): + __tablename__ = "token_blacklist" + + id: Mapped[int] = mapped_column(primary_key=True) + token: Mapped[str] = mapped_column(unique=True, index=True) # Full token string + expires_at: Mapped[datetime] = mapped_column() # When to clean up + created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) +``` + +**Design Considerations:** +- **Unique constraint**: Prevents duplicate entries +- **Index on token**: Fast lookup during verification +- **Expires_at field**: Enables automatic cleanup of old entries + +### Blacklisting Tokens + +The system provides functions for both single token and dual token blacklisting: + +```python +from app.core.security import blacklist_token, blacklist_tokens + +# Single token blacklisting (for specific scenarios) +await blacklist_token(token, db) + +# Dual token blacklisting (standard logout) +await blacklist_tokens(access_token, refresh_token, db) +``` + +### Blacklisting Process + +The blacklisting process extracts the expiration time from the token to set an appropriate cleanup schedule: + +```python +async def blacklist_token(token: str, db: AsyncSession) -> None: + # 1. Decode token to extract expiration (no verification needed) + payload = jwt.decode(token, SECRET_KEY.get_secret_value(), algorithms=[ALGORITHM]) + exp_timestamp = payload.get("exp") + + if exp_timestamp is not None: + # 2. Convert Unix timestamp to datetime + expires_at = datetime.fromtimestamp(exp_timestamp) + + # 3. Store in blacklist with expiration + await crud_token_blacklist.create( + db, + object=TokenBlacklistCreate(token=token, expires_at=expires_at) + ) +``` + +**Cleanup Strategy**: Blacklisted tokens can be automatically removed from the database after their natural expiration time, preventing unlimited database growth. + +## Login Flow Implementation + +### Complete Login Endpoint + +```python +@router.post("/login", response_model=Token) +async def login_for_access_token( + response: Response, + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + db: Annotated[AsyncSession, Depends(async_get_db)], +) -> dict[str, str]: + # 1. Authenticate user + user = await authenticate_user( + username_or_email=form_data.username, + password=form_data.password, + db=db + ) + + if not user: + raise HTTPException( + status_code=401, + detail="Incorrect username or password" + ) + + # 2. Create access token + access_token = await create_access_token(data={"sub": user["username"]}) + + # 3. Create refresh token + refresh_token = await create_refresh_token(data={"sub": user["username"]}) + + # 4. Set refresh token as HTTP-only cookie + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=True, + samesite="strict", + max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + ) + + return {"access_token": access_token, "token_type": "bearer"} +``` + +### Token Refresh Endpoint + +```python +@router.post("/refresh", response_model=Token) +async def refresh_access_token( + response: Response, + db: Annotated[AsyncSession, Depends(async_get_db)], + refresh_token: str = Cookie(None) +) -> dict[str, str]: + if not refresh_token: + raise HTTPException(status_code=401, detail="Refresh token missing") + + # 1. Verify refresh token + token_data = await verify_token(refresh_token, TokenType.REFRESH, db) + if not token_data: + raise HTTPException(status_code=401, detail="Invalid refresh token") + + # 2. Create new access token + new_access_token = await create_access_token( + data={"sub": token_data.username_or_email} + ) + + # 3. Optionally create new refresh token (token rotation) + new_refresh_token = await create_refresh_token( + data={"sub": token_data.username_or_email} + ) + + # 4. Blacklist old refresh token + await blacklist_token(refresh_token, db) + + # 5. Set new refresh token cookie + response.set_cookie( + key="refresh_token", + value=new_refresh_token, + httponly=True, + secure=True, + samesite="strict", + max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + ) + + return {"access_token": new_access_token, "token_type": "bearer"} +``` + +### Logout Implementation + +```python +@router.post("/logout") +async def logout( + response: Response, + db: Annotated[AsyncSession, Depends(async_get_db)], + current_user: dict = Depends(get_current_user), + token: str = Depends(oauth2_scheme), + refresh_token: str = Cookie(None) +) -> dict[str, str]: + # 1. Blacklist access token + await blacklist_token(token, db) + + # 2. Blacklist refresh token if present + if refresh_token: + await blacklist_token(refresh_token, db) + + # 3. Clear refresh token cookie + response.delete_cookie( + key="refresh_token", + httponly=True, + secure=True, + samesite="strict" + ) + + return {"message": "Successfully logged out"} +``` + +## Authentication Dependencies + +### get_current_user + +```python +async def get_current_user( + db: AsyncSession = Depends(async_get_db), + token: str = Depends(oauth2_scheme) +) -> dict: + # 1. Verify token + token_data = await verify_token(token, TokenType.ACCESS, db) + if not token_data: + raise HTTPException(status_code=401, detail="Invalid token") + + # 2. Get user from database + user = await crud_users.get( + db=db, + username=token_data.username_or_email, + schema_to_select=UserRead + ) + + if user is None: + raise HTTPException(status_code=401, detail="User not found") + + return user +``` + +### get_optional_user + +```python +async def get_optional_user( + db: AsyncSession = Depends(async_get_db), + token: str = Depends(optional_oauth2_scheme) +) -> dict | None: + if not token: + return None + + try: + return await get_current_user(db=db, token=token) + except HTTPException: + return None +``` + +### get_current_superuser + +```python +async def get_current_superuser( + current_user: dict = Depends(get_current_user) +) -> dict: + if not current_user.get("is_superuser", False): + raise HTTPException( + status_code=403, + detail="Not enough permissions" + ) + return current_user +``` + +## Configuration + +### Environment Variables + +```bash +# JWT Configuration +SECRET_KEY=your-secret-key-here +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# Security Headers +SECURE_COOKIES=true +CORS_ORIGINS=["http://localhost:3000", "https://yourapp.com"] +``` + +### Security Configuration + +```python +# app/core/config.py +class Settings(BaseSettings): + SECRET_KEY: SecretStr + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # Cookie settings + SECURE_COOKIES: bool = True + COOKIE_DOMAIN: str | None = None + COOKIE_SAMESITE: str = "strict" +``` + +## Security Best Practices + +### Token Security + +- **Use strong secrets**: Generate cryptographically secure SECRET_KEY +- **Rotate secrets**: Regularly change SECRET_KEY in production +- **Environment separation**: Different secrets for dev/staging/production +- **Secure transmission**: Always use HTTPS in production + +### Cookie Security + +- **HttpOnly flag**: Prevents JavaScript access to refresh tokens +- **Secure flag**: Ensures cookies only sent over HTTPS +- **SameSite attribute**: Prevents CSRF attacks +- **Domain restrictions**: Set cookie domain appropriately + +### Implementation Security + +- **Input validation**: Validate all token inputs +- **Rate limiting**: Implement login attempt limits +- **Audit logging**: Log authentication events +- **Token rotation**: Regularly refresh tokens + +## Common Patterns + +### API Key Authentication + +For service-to-service communication: + +```python +async def get_api_key_user( + api_key: str = Header(None), + db: AsyncSession = Depends(async_get_db) +) -> dict: + if not api_key: + raise HTTPException(status_code=401, detail="API key required") + + # Verify API key + user = await crud_users.get(db=db, api_key=api_key) + if not user: + raise HTTPException(status_code=401, detail="Invalid API key") + + return user +``` + +### Multiple Authentication Methods + +```python +async def get_authenticated_user( + db: AsyncSession = Depends(async_get_db), + token: str = Depends(optional_oauth2_scheme), + api_key: str = Header(None) +) -> dict: + # Try JWT token first + if token: + try: + return await get_current_user(db=db, token=token) + except HTTPException: + pass + + # Fall back to API key + if api_key: + return await get_api_key_user(api_key=api_key, db=db) + + raise HTTPException(status_code=401, detail="Authentication required") +``` + +## Troubleshooting + +### Common Issues + +**Token Expired**: Implement automatic refresh using refresh tokens +**Invalid Signature**: Check SECRET_KEY consistency across environments +**Blacklisted Token**: User logged out - redirect to login +**Missing Token**: Ensure Authorization header is properly set + +### Debugging Tips + +```python +# Enable debug logging +import logging +logging.getLogger("app.core.security").setLevel(logging.DEBUG) + +# Test token validation +async def debug_token(token: str, db: AsyncSession): + try: + payload = jwt.decode(token, SECRET_KEY.get_secret_value(), algorithms=[ALGORITHM]) + print(f"Token payload: {payload}") + + is_blacklisted = await crud_token_blacklist.exists(db, token=token) + print(f"Is blacklisted: {is_blacklisted}") + + except JWTError as e: + print(f"JWT Error: {e}") +``` + +This comprehensive JWT implementation provides secure, scalable authentication for your FastAPI application. \ No newline at end of file diff --git a/docs/user-guide/authentication/permissions.md b/docs/user-guide/authentication/permissions.md new file mode 100644 index 0000000..c1daddf --- /dev/null +++ b/docs/user-guide/authentication/permissions.md @@ -0,0 +1,634 @@ +# Permissions and Authorization + +Authorization determines what authenticated users can do within your application. While authentication answers "who are you?", authorization answers "what can you do?". This section covers the permission system, access control patterns, and how to implement secure authorization in your endpoints. + +## Understanding Authorization + +Authorization is a multi-layered security concept that protects resources and operations based on user identity, roles, and contextual information. The boilerplate implements several authorization patterns to handle different security requirements. + +### Authorization vs Authentication + +**Authentication**: Verifies user identity - confirms the user is who they claim to be +**Authorization**: Determines user permissions - decides what the authenticated user can access + +These work together: you must authenticate first (prove identity) before you can authorize (check permissions). + +### Authorization Patterns + +The system implements several common authorization patterns: + +1. **Role-Based Access Control (RBAC)**: Users have roles (superuser, regular user) that determine permissions +2. **Resource Ownership**: Users can only access resources they own +3. **Tiered Access**: Different user tiers have different capabilities and limits +4. **Contextual Authorization**: Permissions based on request context (rate limits, time-based access) + +## Core Authorization Patterns + +### Superuser Permissions + +Superusers have elevated privileges for administrative operations. This pattern is essential for system management but must be carefully controlled. + +```python +from app.api.dependencies import get_current_superuser + +# Superuser-only endpoint +@router.get("/admin/users/", dependencies=[Depends(get_current_superuser)]) +async def get_all_users( + db: AsyncSession = Depends(async_get_db) +) -> list[UserRead]: + # Only superusers can access this endpoint + users = await crud_users.get_multi( + db=db, + schema_to_select=UserRead, + return_as_model=True + ) + return users.data +``` + +**When to Use Superuser Authorization:** + +- **User management operations**: Creating, deleting, or modifying other users +- **System configuration**: Changing application settings or configuration +- **Data export/import**: Bulk operations on sensitive data +- **Administrative reporting**: Access to system-wide analytics and logs + +**Security Considerations:** + +- **Minimal Assignment**: Only assign superuser status when absolutely necessary +- **Regular Audits**: Periodically review who has superuser access +- **Activity Logging**: Log all superuser actions for security monitoring +- **Time-Limited Access**: Consider temporary superuser elevation for specific tasks + +### Resource Ownership + +Resource ownership ensures users can only access and modify their own data. This is the most common authorization pattern in user-facing applications. + +```python +@router.get("/posts/me/") +async def get_my_posts( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) +) -> list[PostRead]: + # Get posts owned by current user + posts = await crud_posts.get_multi( + db=db, + created_by_user_id=current_user["id"], + schema_to_select=PostRead, + return_as_model=True + ) + return posts.data + +@router.delete("/posts/{post_id}") +async def delete_post( + post_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) +) -> dict[str, str]: + # 1. Get the post + post = await crud_posts.get(db=db, id=post_id) + if not post: + raise NotFoundException("Post not found") + + # 2. Check ownership + if post["created_by_user_id"] != current_user["id"]: + raise ForbiddenException("You can only delete your own posts") + + # 3. Delete the post + await crud_posts.delete(db=db, id=post_id) + return {"message": "Post deleted"} +``` + +**Ownership Validation Pattern:** + +1. **Retrieve Resource**: Get the resource from the database +2. **Check Ownership**: Compare resource owner with current user +3. **Authorize or Deny**: Allow action if user owns resource, deny otherwise + +### User Tiers and Rate Limiting + +User tiers provide differentiated access based on subscription levels or user status. This enables business models with different feature sets for different user types. + +```python +@router.post("/posts/", response_model=PostRead) +async def create_post( + post: PostCreate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) +) -> PostRead: + # Check rate limits based on user tier + await check_rate_limit( + resource="posts", + user_id=current_user["id"], + tier_id=current_user.get("tier_id"), + db=db + ) + + # Create post with user association + post_internal = PostCreateInternal( + **post.model_dump(), + created_by_user_id=current_user["id"] + ) + + created_post = await crud_posts.create(db=db, object=post_internal) + return created_post +``` + +**Rate Limiting Implementation:** + +```python +async def check_rate_limit( + resource: str, + user_id: int, + tier_id: int | None, + db: AsyncSession +) -> None: + # 1. Get user's tier information + if tier_id: + tier = await crud_tiers.get(db=db, id=tier_id) + limit = tier["rate_limit_posts"] if tier else 10 # Default limit + else: + limit = 5 # Free tier limit + + # 2. Count recent posts (last 24 hours) + recent_posts = await crud_posts.count( + db=db, + created_by_user_id=user_id, + created_at__gte=datetime.utcnow() - timedelta(hours=24) + ) + + # 3. Check if limit exceeded + if recent_posts >= limit: + raise RateLimitException(f"Daily {resource} limit exceeded ({limit})") +``` + +**Tier-Based Authorization Benefits:** + +- **Business Model Support**: Different features for different subscription levels +- **Resource Protection**: Prevents abuse by limiting free tier usage +- **Progressive Enhancement**: Encourages upgrades by showing tier benefits +- **Fair Usage**: Ensures equitable resource distribution among users + +### Custom Permission Helpers + +Custom permission functions provide reusable authorization logic for complex scenarios. + +```python +# Permission helper functions +async def can_edit_post(user: dict, post_id: int, db: AsyncSession) -> bool: + """Check if user can edit a specific post.""" + post = await crud_posts.get(db=db, id=post_id) + if not post: + return False + + # Superusers can edit any post + if user.get("is_superuser", False): + return True + + # Users can edit their own posts + if post["created_by_user_id"] == user["id"]: + return True + + return False + +async def can_access_admin_panel(user: dict) -> bool: + """Check if user can access admin panel.""" + return user.get("is_superuser", False) + +async def has_tier_feature(user: dict, feature: str, db: AsyncSession) -> bool: + """Check if user's tier includes a specific feature.""" + tier_id = user.get("tier_id") + if not tier_id: + return False # Free tier - no premium features + + tier = await crud_tiers.get(db=db, id=tier_id) + if not tier: + return False + + # Check tier features (example) + return tier.get(f"allows_{feature}", False) + +# Usage in endpoints +@router.put("/posts/{post_id}") +async def update_post( + post_id: int, + post_updates: PostUpdate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) +) -> PostRead: + # Use permission helper + if not await can_edit_post(current_user, post_id, db): + raise ForbiddenException("Cannot edit this post") + + updated_post = await crud_posts.update( + db=db, + object=post_updates, + id=post_id + ) + return updated_post +``` + +**Permission Helper Benefits:** + +- **Reusability**: Same logic used across multiple endpoints +- **Consistency**: Ensures uniform permission checking +- **Maintainability**: Changes to permissions only need updates in one place +- **Testability**: Permission logic can be unit tested separately + +## Authorization Dependencies + +### Basic Authorization Dependencies + +```python +# Required authentication +async def get_current_user( + token: str = Depends(oauth2_scheme), + db: AsyncSession = Depends(async_get_db) +) -> dict: + """Get currently authenticated user.""" + token_data = await verify_token(token, TokenType.ACCESS, db) + if not token_data: + raise HTTPException(status_code=401, detail="Invalid token") + + user = await crud_users.get(db=db, username=token_data.username_or_email) + if not user: + raise HTTPException(status_code=401, detail="User not found") + + return user + +# Optional authentication +async def get_optional_user( + token: str = Depends(optional_oauth2_scheme), + db: AsyncSession = Depends(async_get_db) +) -> dict | None: + """Get currently authenticated user, or None if not authenticated.""" + if not token: + return None + + try: + return await get_current_user(token=token, db=db) + except HTTPException: + return None + +# Superuser requirement +async def get_current_superuser( + current_user: dict = Depends(get_current_user) +) -> dict: + """Get current user and ensure they are a superuser.""" + if not current_user.get("is_superuser", False): + raise HTTPException(status_code=403, detail="Not enough permissions") + return current_user +``` + +### Advanced Authorization Dependencies + +```python +# Tier-based access control +def require_tier(minimum_tier: str): + """Factory function for tier-based dependencies.""" + async def check_user_tier( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) + ) -> dict: + tier_id = current_user.get("tier_id") + if not tier_id: + raise HTTPException(status_code=403, detail="No subscription tier") + + tier = await crud_tiers.get(db=db, id=tier_id) + if not tier or tier["name"] != minimum_tier: + raise HTTPException( + status_code=403, + detail=f"Requires {minimum_tier} tier" + ) + + return current_user + + return check_user_tier + +# Resource ownership dependency +def require_resource_ownership(resource_type: str): + """Factory function for resource ownership dependencies.""" + async def check_ownership( + resource_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) + ) -> dict: + if resource_type == "post": + resource = await crud_posts.get(db=db, id=resource_id) + owner_field = "created_by_user_id" + else: + raise ValueError(f"Unknown resource type: {resource_type}") + + if not resource: + raise HTTPException(status_code=404, detail="Resource not found") + + # Superusers can access any resource + if current_user.get("is_superuser", False): + return current_user + + # Check ownership + if resource[owner_field] != current_user["id"]: + raise HTTPException( + status_code=403, + detail="You don't own this resource" + ) + + return current_user + + return check_ownership + +# Usage examples +@router.get("/premium-feature", dependencies=[Depends(require_tier("Premium"))]) +async def premium_feature(): + return {"message": "Premium feature accessed"} + +@router.put("/posts/{post_id}") +async def update_post( + post_id: int, + post_update: PostUpdate, + current_user: dict = Depends(require_resource_ownership("post")), + db: AsyncSession = Depends(async_get_db) +) -> PostRead: + # User ownership already verified by dependency + updated_post = await crud_posts.update(db=db, object=post_update, id=post_id) + return updated_post +``` + +## Security Best Practices + +### Principle of Least Privilege + +Always grant the minimum permissions necessary for users to complete their tasks. + +**Implementation:** + +- **Default Deny**: Start with no permissions and explicitly grant what's needed +- **Regular Review**: Periodically audit user permissions and remove unnecessary access +- **Role Segregation**: Separate administrative and user-facing permissions +- **Temporary Elevation**: Use temporary permissions for one-time administrative tasks + +### Defense in Depth + +Implement multiple layers of authorization checks throughout your application. + +**Authorization Layers:** + +1. **API Gateway**: Route-level permission checks +2. **Endpoint Dependencies**: FastAPI dependency injection for common patterns +3. **Business Logic**: Method-level permission validation +4. **Database**: Row-level security where applicable + +### Input Validation and Sanitization + +Always validate and sanitize user input, even from authorized users. + +```python +@router.post("/admin/users/{user_id}/tier") +async def update_user_tier( + user_id: int, + tier_update: UserTierUpdate, + current_user: dict = Depends(get_current_superuser), + db: AsyncSession = Depends(async_get_db) +) -> dict[str, str]: + # 1. Validate tier exists + tier = await crud_tiers.get(db=db, id=tier_update.tier_id) + if not tier: + raise NotFoundException("Tier not found") + + # 2. Validate user exists + user = await crud_users.get(db=db, id=user_id) + if not user: + raise NotFoundException("User not found") + + # 3. Prevent self-demotion (optional business rule) + if user_id == current_user["id"] and tier["name"] == "free": + raise ForbiddenException("Cannot demote yourself to free tier") + + # 4. Update user tier + await crud_users.update( + db=db, + object={"tier_id": tier_update.tier_id}, + id=user_id + ) + + return {"message": f"User tier updated to {tier['name']}"} +``` + +### Audit Logging + +Log all significant authorization decisions for security monitoring and compliance. + +```python +import logging + +security_logger = logging.getLogger("security") + +async def log_authorization_event( + user_id: int, + action: str, + resource: str, + result: str, + details: dict = None +): + """Log authorization events for security auditing.""" + security_logger.info( + f"Authorization {result}: User {user_id} attempted {action} on {resource}", + extra={ + "user_id": user_id, + "action": action, + "resource": resource, + "result": result, + "details": details or {} + } + ) + +# Usage in permission checks +async def delete_user_account(user_id: int, current_user: dict, db: AsyncSession): + if current_user["id"] != user_id and not current_user.get("is_superuser"): + await log_authorization_event( + user_id=current_user["id"], + action="delete_account", + resource=f"user:{user_id}", + result="denied", + details={"reason": "insufficient_permissions"} + ) + raise ForbiddenException("Cannot delete other users' accounts") + + await log_authorization_event( + user_id=current_user["id"], + action="delete_account", + resource=f"user:{user_id}", + result="granted" + ) + + # Proceed with deletion + await crud_users.delete(db=db, id=user_id) +``` + +## Common Authorization Patterns + +### Multi-Tenant Authorization + +For applications serving multiple organizations or tenants: + +```python +@router.get("/organizations/{org_id}/users/") +async def get_organization_users( + org_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) +) -> list[UserRead]: + # Check if user belongs to organization + membership = await crud_org_members.get( + db=db, + organization_id=org_id, + user_id=current_user["id"] + ) + + if not membership: + raise ForbiddenException("Not a member of this organization") + + # Check if user has admin role in organization + if membership.role not in ["admin", "owner"]: + raise ForbiddenException("Insufficient organization permissions") + + # Get organization users + users = await crud_users.get_multi( + db=db, + organization_id=org_id, + schema_to_select=UserRead, + return_as_model=True + ) + + return users.data +``` + +### Time-Based Permissions + +For permissions that change based on time or schedule: + +```python +from datetime import datetime, time + +async def check_business_hours_access(user: dict) -> bool: + """Check if user can access during business hours only.""" + now = datetime.now() + business_start = time(9, 0) # 9 AM + business_end = time(17, 0) # 5 PM + + # Superusers can always access + if user.get("is_superuser", False): + return True + + # Regular users only during business hours + current_time = now.time() + return business_start <= current_time <= business_end + +# Usage in dependency +async def require_business_hours( + current_user: dict = Depends(get_current_user) +) -> dict: + """Require access during business hours for non-admin users.""" + if not await check_business_hours_access(current_user): + raise ForbiddenException("Access only allowed during business hours") + return current_user + +@router.post("/business-operation", dependencies=[Depends(require_business_hours)]) +async def business_operation(): + return {"message": "Business operation completed"} +``` + +### Role-Based Access Control (RBAC) + +For more complex permission systems: + +```python +# Role definitions +class Role(str, Enum): + USER = "user" + MODERATOR = "moderator" + ADMIN = "admin" + SUPERUSER = "superuser" + +# Permission checking +def has_role(user: dict, required_role: Role) -> bool: + """Check if user has required role or higher.""" + role_hierarchy = { + Role.USER: 0, + Role.MODERATOR: 1, + Role.ADMIN: 2, + Role.SUPERUSER: 3 + } + + user_role = Role(user.get("role", "user")) + return role_hierarchy[user_role] >= role_hierarchy[required_role] + +# Role-based dependency +def require_role(minimum_role: Role): + """Factory for role-based dependencies.""" + async def check_role(current_user: dict = Depends(get_current_user)) -> dict: + if not has_role(current_user, minimum_role): + raise HTTPException( + status_code=403, + detail=f"Requires {minimum_role.value} role or higher" + ) + return current_user + + return check_role + +# Usage +@router.delete("/posts/{post_id}", dependencies=[Depends(require_role(Role.MODERATOR))]) +async def moderate_delete_post(post_id: int, db: AsyncSession = Depends(async_get_db)): + await crud_posts.delete(db=db, id=post_id) + return {"message": "Post deleted by moderator"} +``` + +### Feature Flags and Permissions + +For gradual feature rollouts: + +```python +async def has_feature_access(user: dict, feature: str, db: AsyncSession) -> bool: + """Check if user has access to a specific feature.""" + # Check feature flags + feature_flag = await crud_feature_flags.get(db=db, name=feature) + if not feature_flag or not feature_flag.enabled: + return False + + # Check user tier permissions + if feature_flag.requires_tier: + tier_id = user.get("tier_id") + if not tier_id: + return False + + tier = await crud_tiers.get(db=db, id=tier_id) + if not tier or tier["level"] < feature_flag["minimum_tier_level"]: + return False + + # Check beta user status + if feature_flag.beta_only: + return user.get("is_beta_user", False) + + return True + +# Feature flag dependency +def require_feature(feature_name: str): + """Factory for feature flag dependencies.""" + async def check_feature_access( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db) + ) -> dict: + if not await has_feature_access(current_user, feature_name, db): + raise HTTPException( + status_code=403, + detail=f"Access to {feature_name} feature not available" + ) + return current_user + + return check_feature_access + +@router.get("/beta-feature", dependencies=[Depends(require_feature("beta_analytics"))]) +async def get_beta_analytics(): + return {"analytics": "beta_data"} +``` + +This comprehensive permissions system provides flexible, secure authorization patterns that can be adapted to your specific application requirements while maintaining security best practices. diff --git a/docs/user-guide/authentication/user-management.md b/docs/user-guide/authentication/user-management.md new file mode 100644 index 0000000..af2be65 --- /dev/null +++ b/docs/user-guide/authentication/user-management.md @@ -0,0 +1,879 @@ +# User Management + +User management forms the core of any authentication system, handling everything from user registration and login to profile updates and account deletion. This section covers the complete user lifecycle with secure authentication flows and administrative operations. + +## Understanding User Lifecycle + +The user lifecycle in the boilerplate follows a secure, well-defined process that protects user data while providing a smooth experience. Understanding this flow helps you customize the system for your specific needs. + +**Registration โ†’ Authentication โ†’ Profile Management โ†’ Administrative Operations** + +Each stage has specific security considerations and business logic that ensure data integrity and user safety. + +## User Registration + +User registration is the entry point to your application. The process must be secure, user-friendly, and prevent common issues like duplicate accounts or weak passwords. + +### Registration Process + +The registration endpoint performs several validation steps before creating a user account. This multi-step validation prevents common registration issues and ensures data quality. + +```python +# User registration endpoint +@router.post("/user", response_model=UserRead, status_code=201) +async def write_user( + user: UserCreate, + db: AsyncSession +) -> UserRead: + # 1. Check if email exists + email_row = await crud_users.exists(db=db, email=user.email) + if email_row: + raise DuplicateValueException("Email is already registered") + + # 2. Check if username exists + username_row = await crud_users.exists(db=db, username=user.username) + if username_row: + raise DuplicateValueException("Username not available") + + # 3. Hash password + user_internal_dict = user.model_dump() + user_internal_dict["hashed_password"] = get_password_hash( + password=user_internal_dict["password"] + ) + del user_internal_dict["password"] + + # 4. Create user + user_internal = UserCreateInternal(**user_internal_dict) + created_user = await crud_users.create(db=db, object=user_internal) + + return created_user +``` + +**Security Steps Explained:** + +1. **Email Uniqueness**: Prevents multiple accounts with the same email, which could cause confusion and security issues +2. **Username Uniqueness**: Ensures usernames are unique identifiers within your system +3. **Password Hashing**: Converts plain text passwords into secure hashes before database storage +4. **Data Separation**: Plain text passwords are immediately removed from memory after hashing + +### Registration Schema + +The registration schema defines what data is required and how it's validated. This ensures consistent data quality and prevents malformed user accounts. + +```python +# User registration input +class UserCreate(UserBase): + model_config = ConfigDict(extra="forbid") + + password: Annotated[ + str, + Field( + pattern=r"^.{8,}|[0-9]+|[A-Z]+|[a-z]+|[^a-zA-Z0-9]+$", + examples=["Str1ngst!"] + ) + ] + +# Internal schema for database storage +class UserCreateInternal(UserBase): + hashed_password: str +``` + +**Schema Design Principles:** + +- **`extra="forbid"`**: Rejects unexpected fields, preventing injection of unauthorized data +- **Password Patterns**: Enforces minimum security requirements for passwords +- **Separation of Concerns**: External schema accepts passwords, internal schema stores hashes + +## User Authentication + +Authentication verifies user identity using credentials. The process must be secure against common attacks while remaining user-friendly. + +### Authentication Process + +```python +async def authenticate_user(username_or_email: str, password: str, db: AsyncSession) -> dict | False: + # 1. Get user by email or username + if "@" in username_or_email: + db_user = await crud_users.get(db=db, email=username_or_email, is_deleted=False) + else: + db_user = await crud_users.get(db=db, username=username_or_email, is_deleted=False) + + if not db_user: + return False + + # 2. Verify password + if not await verify_password(password, db_user["hashed_password"]): + return False + + return db_user +``` + +**Security Considerations:** + +- **Flexible Login**: Accepts both username and email for better user experience +- **Soft Delete Check**: `is_deleted=False` prevents deleted users from logging in +- **Consistent Timing**: Both user lookup and password verification take similar time + +### Password Security + +Password security is critical for protecting user accounts. The system uses industry-standard bcrypt hashing with automatic salt generation. + +```python +import bcrypt + +async def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a plain password against its hash.""" + correct_password: bool = bcrypt.checkpw( + plain_password.encode(), + hashed_password.encode() + ) + return correct_password + +def get_password_hash(password: str) -> str: + """Generate password hash with salt.""" + hashed_password: str = bcrypt.hashpw( + password.encode(), + bcrypt.gensalt() + ).decode() + return hashed_password +``` + +**Why bcrypt?** + +- **Adaptive Hashing**: Computationally expensive, making brute force attacks impractical +- **Automatic Salt**: Each password gets a unique salt, preventing rainbow table attacks +- **Future-Proof**: Can increase computational cost as hardware improves + +### Login Validation + +Client-side validation provides immediate feedback but should never be the only validation layer. + +```python +# Password validation pattern +PASSWORD_PATTERN = r"^.{8,}|[0-9]+|[A-Z]+|[a-z]+|[^a-zA-Z0-9]+$" + +# Frontend validation (example) +function validatePassword(password) { + const minLength = password.length >= 8; + const hasNumber = /[0-9]/.test(password); + const hasUpper = /[A-Z]/.test(password); + const hasLower = /[a-z]/.test(password); + const hasSpecial = /[^a-zA-Z0-9]/.test(password); + + return minLength && hasNumber && hasUpper && hasLower && hasSpecial; +} +``` + +**Validation Strategy:** + +- **Server-Side**: Always validate on the server - client validation can be bypassed +- **Client-Side**: Provides immediate feedback for better user experience +- **Progressive**: Validate as user types to catch issues early + +## Profile Management + +Profile management allows users to update their information while maintaining security and data integrity. + +### Get Current User Profile + +Retrieving the current user's profile is a fundamental operation that should be fast and secure. + +```python +@router.get("/user/me/", response_model=UserRead) +async def read_users_me(current_user: dict = Depends(get_current_user)) -> dict: + return current_user + +# Frontend usage +async function getCurrentUser() { + const token = localStorage.getItem('access_token'); + const response = await fetch('/api/v1/user/me/', { + headers: { + 'Authorization': `Bearer ${token}` + } + }); + + if (response.ok) { + return await response.json(); + } + throw new Error('Failed to get user profile'); +} +``` + +**Design Decisions:** + +- **`/me` Endpoint**: Common pattern that's intuitive for users and developers +- **Current User Dependency**: Automatically handles authentication and user lookup +- **Minimal Data**: Returns only safe, user-relevant information + +### Update User Profile + +Profile updates require careful validation to prevent unauthorized changes and maintain data integrity. + +```python +@router.patch("/user/{username}") +async def patch_user( + values: UserUpdate, + username: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db), +) -> dict[str, str]: + # 1. Get user from database + db_user = await crud_users.get(db=db, username=username, schema_to_select=UserRead) + if db_user is None: + raise NotFoundException("User not found") + + # 2. Check ownership (users can only update their own profile) + if db_user["username"] != current_user["username"]: + raise ForbiddenException("Cannot update other users") + + # 3. Validate unique constraints + if values.username and values.username != db_user["username"]: + existing_username = await crud_users.exists(db=db, username=values.username) + if existing_username: + raise DuplicateValueException("Username not available") + + if values.email and values.email != db_user["email"]: + existing_email = await crud_users.exists(db=db, email=values.email) + if existing_email: + raise DuplicateValueException("Email is already registered") + + # 4. Update user + await crud_users.update(db=db, object=values, username=username) + return {"message": "User updated"} +``` + +**Security Measures:** + +1. **Ownership Verification**: Users can only update their own profiles +2. **Uniqueness Checks**: Prevents conflicts when changing username/email +3. **Partial Updates**: Only provided fields are updated +4. **Input Validation**: Pydantic schemas validate all input data + +## User Deletion + +User deletion requires careful consideration of data retention, user rights, and system integrity. + +### Self-Deletion + +Users should be able to delete their own accounts, but the process should be secure and potentially reversible. + +```python +@router.delete("/user/{username}") +async def erase_user( + username: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(async_get_db), + token: str = Depends(oauth2_scheme), +) -> dict[str, str]: + # 1. Get user from database + db_user = await crud_users.get(db=db, username=username, schema_to_select=UserRead) + if not db_user: + raise NotFoundException("User not found") + + # 2. Check ownership + if username != current_user["username"]: + raise ForbiddenException() + + # 3. Soft delete user + await crud_users.delete(db=db, username=username) + + # 4. Blacklist current token + await blacklist_token(token=token, db=db) + + return {"message": "User deleted"} +``` + +**Soft Delete Benefits:** + +- **Data Recovery**: Users can be restored if needed +- **Audit Trail**: Maintain records for compliance +- **Relationship Integrity**: Related data (posts, comments) remain accessible +- **Gradual Cleanup**: Allow time for data migration or backup + +### Admin Deletion (Hard Delete) + +Administrators may need to permanently remove users in specific circumstances. + +```python +@router.delete("/db_user/{username}", dependencies=[Depends(get_current_superuser)]) +async def erase_db_user( + username: str, + db: AsyncSession = Depends(async_get_db), + token: str = Depends(oauth2_scheme), +) -> dict[str, str]: + # 1. Check if user exists + db_user = await crud_users.exists(db=db, username=username) + if not db_user: + raise NotFoundException("User not found") + + # 2. Hard delete from database + await crud_users.db_delete(db=db, username=username) + + # 3. Blacklist current token + await blacklist_token(token=token, db=db) + + return {"message": "User deleted from the database"} +``` + +**When to Use Hard Delete:** + +- **Legal Requirements**: GDPR "right to be forgotten" requests +- **Data Breach Response**: Complete removal of compromised accounts +- **Spam/Abuse**: Permanent removal of malicious accounts + +## Administrative Operations + +### List All Users + +```python +@router.get("/users", response_model=PaginatedListResponse[UserRead]) +async def read_users( + db: AsyncSession = Depends(async_get_db), + page: int = 1, + items_per_page: int = 10 +) -> dict: + users_data = await crud_users.get_multi( + db=db, + offset=compute_offset(page, items_per_page), + limit=items_per_page, + is_deleted=False, + ) + + response: dict[str, Any] = paginated_response( + crud_data=users_data, + page=page, + items_per_page=items_per_page + ) + return response +``` + +### Get User by Username + +```python +@router.get("/user/{username}", response_model=UserRead) +async def read_user( + username: str, + db: AsyncSession = Depends(async_get_db) +) -> UserRead: + db_user = await crud_users.get( + db=db, + username=username, + is_deleted=False, + schema_to_select=UserRead + ) + if db_user is None: + raise NotFoundException("User not found") + + return db_user +``` + +### User with Tier Information + +```python +@router.get("/user/{username}/tier") +async def read_user_tier( + username: str, + db: AsyncSession = Depends(async_get_db) +) -> dict | None: + # 1. Get user + db_user = await crud_users.get(db=db, username=username, schema_to_select=UserRead) + if db_user is None: + raise NotFoundException("User not found") + + # 2. Return None if no tier assigned + if db_user["tier_id"] is None: + return None + + # 3. Get tier information + db_tier = await crud_tiers.get(db=db, id=db_user["tier_id"], schema_to_select=TierRead) + if not db_tier: + raise NotFoundException("Tier not found") + + # 4. Combine user and tier data + user_dict = dict(db_user) # Convert to dict if needed + tier_dict = dict(db_tier) # Convert to dict if needed + + for key, value in tier_dict.items(): + user_dict[f"tier_{key}"] = value + + return user_dict +``` + +## User Tiers and Permissions + +### Assign User Tier + +```python +@router.patch("/user/{username}/tier", dependencies=[Depends(get_current_superuser)]) +async def patch_user_tier( + username: str, + values: UserTierUpdate, + db: AsyncSession = Depends(async_get_db) +) -> dict[str, str]: + # 1. Verify user exists + db_user = await crud_users.get(db=db, username=username, schema_to_select=UserRead) + if db_user is None: + raise NotFoundException("User not found") + + # 2. Verify tier exists + tier_exists = await crud_tiers.exists(db=db, id=values.tier_id) + if not tier_exists: + raise NotFoundException("Tier not found") + + # 3. Update user tier + await crud_users.update(db=db, object=values, username=username) + return {"message": "User tier updated"} + +# Tier update schema +class UserTierUpdate(BaseModel): + tier_id: int +``` + +### User Rate Limits + +```python +@router.get("/user/{username}/rate_limits", dependencies=[Depends(get_current_superuser)]) +async def read_user_rate_limits( + username: str, + db: AsyncSession = Depends(async_get_db) +) -> dict[str, Any]: + # 1. Get user + db_user = await crud_users.get(db=db, username=username, schema_to_select=UserRead) + if db_user is None: + raise NotFoundException("User not found") + + user_dict = dict(db_user) # Convert to dict if needed + + # 2. No tier assigned + if db_user["tier_id"] is None: + user_dict["tier_rate_limits"] = [] + return user_dict + + # 3. Get tier and rate limits + db_tier = await crud_tiers.get(db=db, id=db_user["tier_id"], schema_to_select=TierRead) + if db_tier is None: + raise NotFoundException("Tier not found") + + db_rate_limits = await crud_rate_limits.get_multi(db=db, tier_id=db_tier["id"]) + user_dict["tier_rate_limits"] = db_rate_limits["data"] + + return user_dict +``` + +## User Model Structure + +### Database Model + +```python +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(30)) + username: Mapped[str] = mapped_column(String(20), unique=True, index=True) + email: Mapped[str] = mapped_column(String(50), unique=True, index=True) + hashed_password: Mapped[str] + profile_image_url: Mapped[str] = mapped_column(default="https://www.profileimageurl.com") + is_superuser: Mapped[bool] = mapped_column(default=False) + tier_id: Mapped[int | None] = mapped_column(ForeignKey("tier.id"), default=None) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) + updated_at: Mapped[datetime | None] = mapped_column(default=None) + + # Soft delete + is_deleted: Mapped[bool] = mapped_column(default=False) + deleted_at: Mapped[datetime | None] = mapped_column(default=None) + + # Relationships + tier: Mapped["Tier"] = relationship(back_populates="users") + posts: Mapped[list["Post"]] = relationship(back_populates="created_by_user") +``` + +### User Schemas + +```python +# Base schema with common fields +class UserBase(BaseModel): + name: Annotated[str, Field(min_length=2, max_length=30)] + username: Annotated[str, Field(min_length=2, max_length=20, pattern=r"^[a-z0-9]+$")] + email: Annotated[EmailStr, Field(examples=["user@example.com"])] + +# Reading user data (API responses) +class UserRead(BaseModel): + id: int + name: str + username: str + email: str + profile_image_url: str + tier_id: int | None + +# Full user data (internal use) +class User(TimestampSchema, UserBase, UUIDSchema, PersistentDeletion): + profile_image_url: str = "https://www.profileimageurl.com" + hashed_password: str + is_superuser: bool = False + tier_id: int | None = None +``` + +## Common User Operations + +### Check User Existence + +```python +# By email +email_exists = await crud_users.exists(db=db, email="user@example.com") + +# By username +username_exists = await crud_users.exists(db=db, username="johndoe") + +# By ID +user_exists = await crud_users.exists(db=db, id=123) +``` + +### Search Users + +```python +# Get active users only +active_users = await crud_users.get_multi( + db=db, + is_deleted=False, + limit=10 +) + +# Get users by tier +tier_users = await crud_users.get_multi( + db=db, + tier_id=1, + is_deleted=False +) + +# Get superusers +superusers = await crud_users.get_multi( + db=db, + is_superuser=True, + is_deleted=False +) +``` + +### User Statistics + +```python +async def get_user_stats(db: AsyncSession) -> dict: + # Total users + total_users = await crud_users.count(db=db, is_deleted=False) + + # Active users (logged in recently) + # This would require tracking last_login_at + + # Users by tier + tier_stats = {} + tiers = await crud_tiers.get_multi(db=db) + for tier in tiers["data"]: + count = await crud_users.count(db=db, tier_id=tier["id"], is_deleted=False) + tier_stats[tier["name"]] = count + + return { + "total_users": total_users, + "tier_distribution": tier_stats + } +``` + +## Frontend Integration + +### Complete User Management Component + +```javascript +class UserManager { + constructor(baseUrl = '/api/v1') { + this.baseUrl = baseUrl; + this.token = localStorage.getItem('access_token'); + } + + async register(userData) { + const response = await fetch(`${this.baseUrl}/user`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userData) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail); + } + + return await response.json(); + } + + async login(username, password) { + const response = await fetch(`${this.baseUrl}/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + username: username, + password: password + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail); + } + + const tokens = await response.json(); + localStorage.setItem('access_token', tokens.access_token); + this.token = tokens.access_token; + + return tokens; + } + + async getProfile() { + const response = await fetch(`${this.baseUrl}/user/me/`, { + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + if (!response.ok) { + throw new Error('Failed to get profile'); + } + + return await response.json(); + } + + async updateProfile(username, updates) { + const response = await fetch(`${this.baseUrl}/user/${username}`, { + method: 'PATCH', + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(updates) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail); + } + + return await response.json(); + } + + async deleteAccount(username) { + const response = await fetch(`${this.baseUrl}/user/${username}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail); + } + + // Clear local storage + localStorage.removeItem('access_token'); + this.token = null; + + return await response.json(); + } + + async logout() { + const response = await fetch(`${this.baseUrl}/logout`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.token}` + } + }); + + // Clear local storage regardless of response + localStorage.removeItem('access_token'); + this.token = null; + + if (response.ok) { + return await response.json(); + } + } +} + +// Usage +const userManager = new UserManager(); + +// Register new user +try { + const user = await userManager.register({ + name: "John Doe", + username: "johndoe", + email: "john@example.com", + password: "SecurePass123!" + }); + console.log('User registered:', user); +} catch (error) { + console.error('Registration failed:', error.message); +} + +// Login +try { + const tokens = await userManager.login('johndoe', 'SecurePass123!'); + console.log('Login successful'); + + // Get profile + const profile = await userManager.getProfile(); + console.log('User profile:', profile); +} catch (error) { + console.error('Login failed:', error.message); +} +``` + +## Security Considerations + +### Input Validation + +```python +# Server-side validation +class UserCreate(UserBase): + password: Annotated[ + str, + Field( + min_length=8, + pattern=r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]", + description="Password must contain uppercase, lowercase, number, and special character" + ) + ] +``` + +### Rate Limiting + +```python +# Protect registration endpoint +@router.post("/user", dependencies=[Depends(rate_limiter_dependency)]) +async def write_user(user: UserCreate, db: AsyncSession): + # Registration logic + pass + +# Protect login endpoint +@router.post("/login", dependencies=[Depends(rate_limiter_dependency)]) +async def login_for_access_token(): + # Login logic + pass +``` + +### Data Sanitization + +```python +def sanitize_user_input(user_data: dict) -> dict: + """Sanitize user input to prevent XSS and injection.""" + import html + + sanitized = {} + for key, value in user_data.items(): + if isinstance(value, str): + # HTML escape + sanitized[key] = html.escape(value.strip()) + else: + sanitized[key] = value + + return sanitized +``` + +## Next Steps + +Now that you understand user management: + +1. **[Permissions](permissions.md)** - Learn about role-based access control and authorization +2. **[Production Guide](../production.md)** - Implement production-grade security measures +3. **[JWT Tokens](jwt-tokens.md)** - Review token management if needed + +User management provides the core functionality for authentication systems. Master these patterns before implementing advanced permission systems. + +## Common Authentication Tasks + +### Protect New Endpoints + +```python +# Add authentication dependency to your router +@router.get("/my-endpoint") +async def my_endpoint(current_user: dict = Depends(get_current_user)): + # Endpoint now requires authentication + return {"user_specific_data": f"Hello {current_user['username']}"} + +# Optional authentication for public endpoints +@router.get("/public-endpoint") +async def public_endpoint(user: dict | None = Depends(get_optional_user)): + if user: + return {"message": f"Hello {user['username']}", "premium_features": True} + return {"message": "Hello anonymous user", "premium_features": False} +``` + +### Complete Authentication Flow + +```python +# 1. User registration +user_data = UserCreate( + name="John Doe", + username="johndoe", + email="john@example.com", + password="SecurePassword123!" +) +user = await crud_users.create(db=db, object=user_data) + +# 2. User login +form_data = {"username": "johndoe", "password": "SecurePassword123!"} +user = await authenticate_user(form_data["username"], form_data["password"], db) + +# 3. Token generation (handled in login endpoint) +access_token = await create_access_token(data={"sub": user["username"]}) +refresh_token = await create_refresh_token(data={"sub": user["username"]}) + +# 4. API access with token +headers = {"Authorization": f"Bearer {access_token}"} +response = requests.get("/api/v1/users/me", headers=headers) + +# 5. Token refresh when access token expires +response = requests.post("/api/v1/refresh") # Uses refresh token cookie +new_access_token = response.json()["access_token"] + +# 6. Secure logout (blacklists both tokens) +await logout_user(access_token=access_token, refresh_token=refresh_token, db=db) +``` + +### Check User Permissions + +```python +def check_user_permission(user: dict, required_tier: str = None): + """Check if user has required permissions.""" + if not user.get("is_active", True): + raise UnauthorizedException("User account is disabled") + + if required_tier and user.get("tier", {}).get("name") != required_tier: + raise ForbiddenException(f"Requires {required_tier} tier") + +# Usage in endpoint +@router.get("/premium-feature") +async def premium_feature(current_user: dict = Depends(get_current_user)): + check_user_permission(current_user, "Pro") + return {"premium_data": "exclusive_content"} +``` + +### Custom Authentication Logic + +```python +async def get_user_with_posts(current_user: dict = Depends(get_current_user)): + """Custom dependency that adds user's posts.""" + posts = await crud_posts.get_multi(db=db, created_by_user_id=current_user["id"]) + current_user["posts"] = posts + return current_user + +# Usage +@router.get("/dashboard") +async def get_dashboard(user_with_posts: dict = Depends(get_user_with_posts)): + return { + "user": user_with_posts, + "post_count": len(user_with_posts["posts"]) + } +``` \ No newline at end of file diff --git a/docs/user-guide/background-tasks/index.md b/docs/user-guide/background-tasks/index.md new file mode 100644 index 0000000..70c2a18 --- /dev/null +++ b/docs/user-guide/background-tasks/index.md @@ -0,0 +1,92 @@ +# Background Tasks + +The boilerplate includes a robust background task system built on ARQ (Async Redis Queue) for handling long-running operations asynchronously. This enables your API to remain responsive while processing intensive tasks in the background. + +## Overview + +Background tasks are essential for operations that: + +- **Take longer than 2 seconds** to complete +- **Don't block user interactions** in your frontend +- **Can be processed asynchronously** without immediate user feedback +- **Require intensive computation** or external API calls + +## Quick Example + +```python +# Define a background task +async def send_welcome_email(ctx: Worker, user_id: int, email: str) -> str: + # Send email logic here + await send_email_service(email, "Welcome!") + return f"Welcome email sent to {email}" + +# Enqueue the task from an API endpoint +@router.post("/users/", response_model=UserRead) +async def create_user(user_data: UserCreate): + # Create user in database + user = await crud_users.create(db=db, object=user_data) + + # Queue welcome email in background + await queue.pool.enqueue_job("send_welcome_email", user["id"], user["email"]) + + return user +``` + +## Architecture + +### ARQ Worker System +- **Redis-Based**: Uses Redis as the message broker for job queues +- **Async Processing**: Fully asynchronous task execution +- **Worker Pool**: Multiple workers can process tasks concurrently +- **Job Persistence**: Tasks survive application restarts + +### Task Lifecycle +1. **Enqueue**: Tasks are added to Redis queue from API endpoints +2. **Processing**: ARQ workers pick up and execute tasks +3. **Results**: Task results are stored and can be retrieved +4. **Monitoring**: Track task status and execution history + +## Key Features + +**Scalable Processing** +- Multiple worker instances for high throughput +- Automatic load balancing across workers +- Configurable concurrency per worker + +**Reliable Execution** +- Task retry mechanisms for failed jobs +- Dead letter queues for problematic tasks +- Graceful shutdown and task cleanup + +**Database Integration** +- Shared database sessions with main application +- CRUD operations available in background tasks +- Transaction management and error handling + +## Common Use Cases + +- **Email Processing**: Welcome emails, notifications, newsletters +- **File Operations**: Image processing, PDF generation, file uploads +- **External APIs**: Third-party integrations, webhooks, data sync +- **Data Processing**: Report generation, analytics, batch operations +- **ML/AI Tasks**: Model inference, data analysis, predictions + +## Getting Started + +The boilerplate provides everything needed to start using background tasks immediately. Simply define your task functions, register them in the worker settings, and enqueue them from your API endpoints. + +## Configuration + +Basic Redis queue configuration: + +```bash +# Redis Queue Settings +REDIS_QUEUE_HOST=localhost +REDIS_QUEUE_PORT=6379 +``` + +The system automatically handles Redis connection pooling and worker lifecycle management. + +## Next Steps + +Check the [ARQ documentation](https://arq-docs.helpmanual.io/) for advanced usage patterns and refer to the boilerplate's example implementation in `src/app/core/worker/` and `src/app/api/v1/tasks.py`. \ No newline at end of file diff --git a/docs/user-guide/caching/cache-strategies.md b/docs/user-guide/caching/cache-strategies.md new file mode 100644 index 0000000..bbdd527 --- /dev/null +++ b/docs/user-guide/caching/cache-strategies.md @@ -0,0 +1,191 @@ +# Cache Strategies + +Effective cache strategies balance performance gains with data consistency. This section covers invalidation patterns, cache warming, and optimization techniques for building robust caching systems. + +## Cache Invalidation Strategies + +Cache invalidation is one of the hardest problems in computer science. The boilerplate provides several strategies to handle different scenarios while maintaining data consistency. + +### Understanding Cache Invalidation + +**Cache invalidation** ensures that cached data doesn't become stale when the underlying data changes. Poor invalidation leads to users seeing outdated information, while over-aggressive invalidation negates caching benefits. + +### Basic Invalidation Patterns + +#### Time-Based Expiration (TTL) + +The simplest strategy relies on cache expiration times: + +```python +# Set different TTL based on data characteristics +@cache(key_prefix="user_profile", expiration=3600) # 1 hour for profiles +@cache(key_prefix="post_content", expiration=1800) # 30 min for posts +@cache(key_prefix="live_stats", expiration=60) # 1 min for live data +``` + +**Pros:** + +- Simple to implement and understand +- Guarantees cache freshness within TTL period +- Works well for data with predictable change patterns + +**Cons:** + +- May serve stale data until TTL expires +- Difficult to optimize TTL for all scenarios +- Cache miss storms when many keys expire simultaneously + +#### Write-Through Invalidation + +Automatically invalidate cache when data is modified: + +```python +@router.put("/posts/{post_id}") +@cache( + key_prefix="post_cache", + resource_id_name="post_id", + to_invalidate_extra={ + "user_posts": "{user_id}", # User's post list + "category_posts": "{category_id}", # Category post list + "recent_posts": "global" # Global recent posts + } +) +async def update_post( + request: Request, + post_id: int, + post_data: PostUpdate, + user_id: int, + category_id: int +): + # Update triggers automatic cache invalidation + updated_post = await crud_posts.update(db=db, id=post_id, object=post_data) + return updated_post +``` + +**Pros:** + +- Immediate consistency when data changes +- No stale data served to users +- Precise control over what gets invalidated + +**Cons:** + +- More complex implementation +- Can impact write performance +- Risk of over-invalidation + +### Advanced Invalidation Patterns + +#### Pattern-Based Invalidation + +Use Redis pattern matching for bulk invalidation: + +```python +@router.put("/users/{user_id}/profile") +@cache( + key_prefix="user_profile", + resource_id_name="user_id", + pattern_to_invalidate_extra=[ + "user_{user_id}_*", # All user-related caches + "*_user_{user_id}_*", # Caches containing this user + "leaderboard_*", # Leaderboards might change + "search_users_*" # User search results + ] +) +async def update_user_profile(request: Request, user_id: int, profile_data: ProfileUpdate): + await crud_users.update(db=db, id=user_id, object=profile_data) + return {"message": "Profile updated"} +``` + +**Pattern Examples:** +```python +# User-specific patterns +"user_{user_id}_posts_*" # All paginated post lists for user +"user_{user_id}_*_cache" # All cached data for user +"*_following_{user_id}" # All caches tracking this user's followers + +# Content patterns +"posts_category_{category_id}_*" # All posts in category +"comments_post_{post_id}_*" # All comments for post +"search_*_{query}" # All search results for query + +# Time-based patterns +"daily_stats_*" # All daily statistics +"hourly_*" # All hourly data +"temp_*" # Temporary cache entries +``` + +## Cache Warming Strategies + +Cache warming proactively loads data into cache to avoid cache misses during peak usage. + +### Application Startup Warming + +```python +# core/startup.py +async def warm_critical_caches(): + """Warm up critical caches during application startup.""" + + logger.info("Starting cache warming...") + + # Warm up reference data + await warm_reference_data() + + # Warm up popular content + await warm_popular_content() + + # Warm up user session data for active users + await warm_active_user_data() + + logger.info("Cache warming completed") + +async def warm_reference_data(): + """Warm up reference data that rarely changes.""" + + # Countries, currencies, timezones, etc. + reference_data = await crud_reference.get_all_countries() + for country in reference_data: + cache_key = f"country:{country['code']}" + await cache.client.set(cache_key, json.dumps(country), ex=86400) # 24 hours + + # Categories + categories = await crud_categories.get_all() + await cache.client.set("all_categories", json.dumps(categories), ex=3600) + +async def warm_popular_content(): + """Warm up frequently accessed content.""" + + # Most viewed posts + popular_posts = await crud_posts.get_popular(limit=100) + for post in popular_posts: + cache_key = f"post_cache:{post['id']}" + await cache.client.set(cache_key, json.dumps(post), ex=1800) + + # Trending topics + trending = await crud_posts.get_trending_topics(limit=50) + await cache.client.set("trending_topics", json.dumps(trending), ex=600) + +async def warm_active_user_data(): + """Warm up data for recently active users.""" + + # Get users active in last 24 hours + active_users = await crud_users.get_recently_active(hours=24) + + for user in active_users: + # Warm user profile + profile_key = f"user_profile:{user['id']}" + await cache.client.set(profile_key, json.dumps(user), ex=3600) + + # Warm user's recent posts + user_posts = await crud_posts.get_user_posts(user['id'], limit=10) + posts_key = f"user_{user['id']}_posts:page_1" + await cache.client.set(posts_key, json.dumps(user_posts), ex=1800) + +# Add to startup events +@app.on_event("startup") +async def startup_event(): + await create_redis_cache_pool() + await warm_critical_caches() +``` + +These cache strategies provide a comprehensive approach to building performant, consistent caching systems that scale with your application's needs while maintaining data integrity. \ No newline at end of file diff --git a/docs/user-guide/caching/client-cache.md b/docs/user-guide/caching/client-cache.md new file mode 100644 index 0000000..7096e78 --- /dev/null +++ b/docs/user-guide/caching/client-cache.md @@ -0,0 +1,515 @@ +# Client Cache + +Client-side caching leverages HTTP cache headers to instruct browsers and CDNs to cache responses locally. This reduces server load and improves user experience by serving cached content directly from the client. + +## Understanding Client Caching + +Client caching works by setting HTTP headers that tell browsers, proxies, and CDNs how long they should cache responses. When implemented correctly, subsequent requests for the same resource are served instantly from the local cache. + +### Benefits of Client Caching + +**Reduced Latency**: Instant response from local cache eliminates network round trips +**Lower Server Load**: Fewer requests reach your server infrastructure +**Bandwidth Savings**: Cached responses don't consume network bandwidth +**Better User Experience**: Faster page loads and improved responsiveness +**Cost Reduction**: Lower server resource usage and bandwidth costs + +## Cache-Control Headers + +The `Cache-Control` header is the primary mechanism for controlling client-side caching behavior. + +### Header Components + +```http +Cache-Control: public, max-age=3600, s-maxage=7200, must-revalidate +``` + +**Directive Breakdown:** + +- **`public`**: Response can be cached by any cache (browsers, CDNs, proxies) +- **`private`**: Response can only be cached by browsers, not shared caches +- **`max-age=3600`**: Cache for 3600 seconds (1 hour) in browsers +- **`s-maxage=7200`**: Cache for 7200 seconds (2 hours) in shared caches (CDNs) +- **`must-revalidate`**: Must check with server when cache expires +- **`no-cache`**: Must revalidate with server before using cached response +- **`no-store`**: Must not store any part of the response + +### Common Cache Patterns + +```python +# Static assets (images, CSS, JS) +"Cache-Control: public, max-age=31536000, immutable" # 1 year + +# API data that changes rarely +"Cache-Control: public, max-age=3600" # 1 hour + +# User-specific data +"Cache-Control: private, max-age=1800" # 30 minutes, browser only + +# Real-time data +"Cache-Control: no-cache, must-revalidate" # Always validate + +# Sensitive data +"Cache-Control: no-store, no-cache, must-revalidate" # Never cache +``` + +## Middleware Implementation + +The boilerplate includes middleware that automatically adds cache headers to responses. + +### ClientCacheMiddleware + +```python +# middleware/client_cache_middleware.py +from fastapi import FastAPI, Request, Response +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + +class ClientCacheMiddleware(BaseHTTPMiddleware): + """Middleware to set Cache-Control headers for client-side caching.""" + + def __init__(self, app: FastAPI, max_age: int = 60) -> None: + super().__init__(app) + self.max_age = max_age + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + response: Response = await call_next(request) + response.headers["Cache-Control"] = f"public, max-age={self.max_age}" + return response +``` + +### Adding Middleware to Application + +```python +# main.py +from fastapi import FastAPI +from app.middleware.client_cache_middleware import ClientCacheMiddleware + +app = FastAPI() + +# Add client caching middleware +app.add_middleware( + ClientCacheMiddleware, + max_age=300 # 5 minutes default cache +) +``` + +### Custom Middleware Configuration + +```python +class AdvancedClientCacheMiddleware(BaseHTTPMiddleware): + """Advanced client cache middleware with path-specific configurations.""" + + def __init__( + self, + app: FastAPI, + default_max_age: int = 300, + path_configs: dict[str, dict] = None + ): + super().__init__(app) + self.default_max_age = default_max_age + self.path_configs = path_configs or {} + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + response = await call_next(request) + + # Get path-specific configuration + cache_config = self._get_cache_config(request.url.path) + + # Set cache headers based on configuration + if cache_config.get("no_cache", False): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + else: + max_age = cache_config.get("max_age", self.default_max_age) + visibility = "private" if cache_config.get("private", False) else "public" + + cache_control = f"{visibility}, max-age={max_age}" + + if cache_config.get("must_revalidate", False): + cache_control += ", must-revalidate" + + if cache_config.get("immutable", False): + cache_control += ", immutable" + + response.headers["Cache-Control"] = cache_control + + return response + + def _get_cache_config(self, path: str) -> dict: + """Get cache configuration for a specific path.""" + for pattern, config in self.path_configs.items(): + if path.startswith(pattern): + return config + return {} + +# Usage with path-specific configurations +app.add_middleware( + AdvancedClientCacheMiddleware, + default_max_age=300, + path_configs={ + "/api/v1/static/": {"max_age": 31536000, "immutable": True}, # 1 year for static assets + "/api/v1/auth/": {"no_cache": True}, # No cache for auth endpoints + "/api/v1/users/me": {"private": True, "max_age": 900}, # 15 min private cache for user data + "/api/v1/public/": {"max_age": 1800}, # 30 min for public data + } +) +``` + +## Manual Cache Control + +Set cache headers manually in specific endpoints for fine-grained control. + +### Response Header Manipulation + +```python +from fastapi import APIRouter, Response + +router = APIRouter() + +@router.get("/api/v1/static-data") +async def get_static_data(response: Response): + """Endpoint with long-term caching for static data.""" + # Set cache headers for static data + response.headers["Cache-Control"] = "public, max-age=86400, immutable" # 24 hours + response.headers["Last-Modified"] = "Wed, 21 Oct 2023 07:28:00 GMT" + response.headers["ETag"] = '"abc123"' + + return {"data": "static content that rarely changes"} + +@router.get("/api/v1/user-data") +async def get_user_data(response: Response, current_user: dict = Depends(get_current_user)): + """Endpoint with private caching for user-specific data.""" + # Private cache for user-specific data + response.headers["Cache-Control"] = "private, max-age=1800" # 30 minutes + response.headers["Vary"] = "Authorization" # Cache varies by auth header + + return {"user_id": current_user["id"], "preferences": "user data"} + +@router.get("/api/v1/real-time-data") +async def get_real_time_data(response: Response): + """Endpoint that should not be cached.""" + # Prevent caching for real-time data + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + + return {"timestamp": datetime.utcnow(), "live_data": "current status"} +``` + +### Conditional Caching + +Implement conditional caching based on request parameters: + +```python +@router.get("/api/v1/posts") +async def get_posts( + response: Response, + page: int = 1, + per_page: int = 10, + category: str | None = None, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + """Conditional caching based on parameters.""" + + # Different cache strategies based on parameters + if category: + # Category-specific data changes less frequently + response.headers["Cache-Control"] = "public, max-age=1800" # 30 minutes + elif page == 1: + # First page cached more aggressively + response.headers["Cache-Control"] = "public, max-age=600" # 10 minutes + else: + # Other pages cached for shorter duration + response.headers["Cache-Control"] = "public, max-age=300" # 5 minutes + + # Add ETag for efficient revalidation + content_hash = hashlib.md5(f"{page}{per_page}{category}".encode()).hexdigest() + response.headers["ETag"] = f'"{content_hash}"' + + posts = await crud_posts.get_multi( + db=db, + offset=(page - 1) * per_page, + limit=per_page, + category=category + ) + + return {"posts": posts, "page": page, "per_page": per_page} +``` + +## ETag Implementation + +ETags enable efficient cache validation by allowing clients to check if content has changed. + +### ETag Generation + +```python +import hashlib +from typing import Any + +def generate_etag(data: Any) -> str: + """Generate ETag from data content.""" + content = json.dumps(data, sort_keys=True, default=str) + return hashlib.md5(content.encode()).hexdigest() + +@router.get("/api/v1/users/{user_id}") +async def get_user( + request: Request, + response: Response, + user_id: int, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + """Endpoint with ETag support for efficient caching.""" + + user = await crud_users.get(db=db, id=user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Generate ETag from user data + etag = generate_etag(user) + + # Check if client has current version + if_none_match = request.headers.get("If-None-Match") + if if_none_match == f'"{etag}"': + # Content hasn't changed, return 304 Not Modified + response.status_code = 304 + return Response(status_code=304) + + # Set ETag and cache headers + response.headers["ETag"] = f'"{etag}"' + response.headers["Cache-Control"] = "private, max-age=1800, must-revalidate" + + return user +``` + +### Last-Modified Headers + +Use Last-Modified headers for time-based cache validation: + +```python +@router.get("/api/v1/posts/{post_id}") +async def get_post( + request: Request, + response: Response, + post_id: int, + db: Annotated[AsyncSession, Depends(async_get_db)] +): + """Endpoint with Last-Modified header support.""" + + post = await crud_posts.get(db=db, id=post_id) + if not post: + raise HTTPException(status_code=404, detail="Post not found") + + # Use post's updated_at timestamp + last_modified = post["updated_at"] + + # Check If-Modified-Since header + if_modified_since = request.headers.get("If-Modified-Since") + if if_modified_since: + client_time = datetime.strptime(if_modified_since, "%a, %d %b %Y %H:%M:%S GMT") + if last_modified <= client_time: + response.status_code = 304 + return Response(status_code=304) + + # Set Last-Modified header + response.headers["Last-Modified"] = last_modified.strftime("%a, %d %b %Y %H:%M:%S GMT") + response.headers["Cache-Control"] = "public, max-age=3600, must-revalidate" + + return post +``` + +## Cache Strategy by Content Type + +Different types of content require different caching strategies. + +### Static Assets + +```python +@router.get("/static/{file_path:path}") +async def serve_static(response: Response, file_path: str): + """Serve static files with aggressive caching.""" + + # Static assets can be cached for a long time + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" # 1 year + response.headers["Vary"] = "Accept-Encoding" # Vary by compression + + # Add file-specific ETag based on file modification time + file_stat = os.stat(f"static/{file_path}") + etag = hashlib.md5(f"{file_path}{file_stat.st_mtime}".encode()).hexdigest() + response.headers["ETag"] = f'"{etag}"' + + return FileResponse(f"static/{file_path}") +``` + +### API Responses + +```python +# Reference data (rarely changes) +@router.get("/api/v1/countries") +async def get_countries(response: Response, db: Annotated[AsyncSession, Depends(async_get_db)]): + response.headers["Cache-Control"] = "public, max-age=86400" # 24 hours + return await crud_countries.get_all(db=db) + +# User-generated content (moderate changes) +@router.get("/api/v1/posts") +async def get_posts(response: Response, db: Annotated[AsyncSession, Depends(async_get_db)]): + response.headers["Cache-Control"] = "public, max-age=1800" # 30 minutes + return await crud_posts.get_multi(db=db, is_deleted=False) + +# Personal data (private caching only) +@router.get("/api/v1/users/me/notifications") +async def get_notifications( + response: Response, + current_user: dict = Depends(get_current_user), + db: Annotated[AsyncSession, Depends(async_get_db)] +): + response.headers["Cache-Control"] = "private, max-age=300" # 5 minutes + response.headers["Vary"] = "Authorization" + return await crud_notifications.get_user_notifications(db=db, user_id=current_user["id"]) + +# Real-time data (no caching) +@router.get("/api/v1/system/status") +async def get_system_status(response: Response): + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + return {"status": "online", "timestamp": datetime.utcnow()} +``` + +## Vary Header Usage + +The `Vary` header tells caches which request headers affect the response, enabling proper cache key generation. + +### Common Vary Patterns + +```python +# Cache varies by authorization (user-specific content) +response.headers["Vary"] = "Authorization" + +# Cache varies by accepted language +response.headers["Vary"] = "Accept-Language" + +# Cache varies by compression support +response.headers["Vary"] = "Accept-Encoding" + +# Multiple varying headers +response.headers["Vary"] = "Authorization, Accept-Language, Accept-Encoding" + +# Example implementation +@router.get("/api/v1/dashboard") +async def get_dashboard( + request: Request, + response: Response, + current_user: dict = Depends(get_current_user) +): + """Dashboard content that varies by user and language.""" + + # Content varies by user (Authorization) and language preference + response.headers["Vary"] = "Authorization, Accept-Language" + response.headers["Cache-Control"] = "private, max-age=900" # 15 minutes + + language = request.headers.get("Accept-Language", "en") + + dashboard_data = await generate_dashboard( + user_id=current_user["id"], + language=language + ) + + return dashboard_data +``` + +## CDN Integration + +Configure cache headers for optimal CDN performance. + +### CDN-Specific Headers + +```python +@router.get("/api/v1/public-content") +async def get_public_content(response: Response): + """Content optimized for CDN caching.""" + + # Different cache times for browser vs CDN + response.headers["Cache-Control"] = "public, max-age=300, s-maxage=3600" # 5 min browser, 1 hour CDN + + # CDN-specific headers (CloudFlare example) + response.headers["CF-Cache-Tag"] = "public-content,api-v1" # Cache tags for purging + response.headers["CF-Edge-Cache"] = "max-age=86400" # Edge cache for 24 hours + + return await get_public_content_data() +``` + +### Cache Purging + +Implement cache purging for content updates: + +```python +@router.put("/api/v1/posts/{post_id}") +async def update_post( + response: Response, + post_id: int, + post_data: PostUpdate, + current_user: dict = Depends(get_current_user), + db: Annotated[AsyncSession, Depends(async_get_db)] +): + """Update post and invalidate related caches.""" + + # Update the post + updated_post = await crud_posts.update(db=db, id=post_id, object=post_data) + if not updated_post: + raise HTTPException(status_code=404, detail="Post not found") + + # Set headers to indicate cache invalidation is needed + response.headers["Cache-Control"] = "no-cache" + response.headers["X-Cache-Purge"] = f"post-{post_id},user-{current_user['id']}-posts" + + # In production, trigger CDN purge here + # await purge_cdn_cache([f"post-{post_id}", f"user-{current_user['id']}-posts"]) + + return updated_post +``` + +## Best Practices + +### Cache Duration Guidelines + +```python +# Choose appropriate cache durations based on content characteristics: + +# Static assets (CSS, JS, images with versioning) +max_age = 31536000 # 1 year + +# API reference data (countries, categories) +max_age = 86400 # 24 hours + +# User-generated content (posts, comments) +max_age = 1800 # 30 minutes + +# User-specific data (profiles, preferences) +max_age = 900 # 15 minutes + +# Search results +max_age = 600 # 10 minutes + +# Real-time data (live scores, chat) +max_age = 0 # No caching +``` + +### Security Considerations + +```python +# Never cache sensitive data +@router.get("/api/v1/admin/secrets") +async def get_secrets(response: Response): + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return {"secret": "sensitive_data"} + +# Use private caching for user-specific content +@router.get("/api/v1/users/me/private-data") +async def get_private_data(response: Response): + response.headers["Cache-Control"] = "private, max-age=300, must-revalidate" + response.headers["Vary"] = "Authorization" + return {"private": "user_data"} +``` + +Client-side caching, when properly implemented, provides significant performance improvements while maintaining security and data freshness through intelligent cache control strategies. \ No newline at end of file diff --git a/docs/user-guide/caching/index.md b/docs/user-guide/caching/index.md new file mode 100644 index 0000000..45ffed0 --- /dev/null +++ b/docs/user-guide/caching/index.md @@ -0,0 +1,77 @@ +# Caching + +The boilerplate includes a comprehensive caching system built on Redis that improves performance through server-side caching and client-side cache control. This section covers the complete caching implementation. + +## Overview + +The caching system provides multiple layers of optimization: + +- **Server-Side Caching**: Redis-based caching with automatic invalidation +- **Client-Side Caching**: HTTP cache headers for browser optimization +- **Cache Invalidation**: Smart invalidation strategies for data consistency + +## Quick Example + +```python +from app.core.utils.cache import cache + +@router.get("/posts/{post_id}") +@cache(key_prefix="post_cache", expiration=3600) +async def get_post(request: Request, post_id: int): + # Cached for 1 hour, automatic invalidation on updates + return await crud_posts.get(db=db, id=post_id) +``` + +## Architecture + +### Server-Side Caching +- **Redis Integration**: Connection pooling and async operations +- **Decorator-Based**: Simple `@cache` decorator for endpoints +- **Smart Invalidation**: Automatic cache clearing on data changes +- **Pattern Matching**: Bulk invalidation using Redis patterns + +### Client-Side Caching +- **HTTP Headers**: Cache-Control headers for browser caching +- **Middleware**: Automatic header injection +- **Configurable TTL**: Customizable cache duration + +## Key Features + +**Automatic Cache Management** +- Caches GET requests automatically +- Invalidates cache on PUT/POST/DELETE operations +- Supports complex invalidation patterns + +**Flexible Configuration** +- Per-endpoint expiration times +- Custom cache key generation +- Environment-specific Redis settings + +**Performance Optimization** +- Connection pooling for Redis +- Efficient key pattern matching +- Minimal overhead for cache operations + +## Getting Started + +1. **[Redis Cache](redis-cache.md)** - Server-side caching with Redis +2. **[Client Cache](client-cache.md)** - Browser caching with HTTP headers +3. **[Cache Strategies](cache-strategies.md)** - Invalidation patterns and best practices + +Each section provides detailed implementation examples and configuration options for building a robust caching layer. + +## Configuration + +Basic Redis configuration in your environment: + +```bash +# Redis Cache Settings +REDIS_CACHE_HOST=localhost +REDIS_CACHE_PORT=6379 +``` + +The caching system automatically handles connection pooling and provides efficient cache operations for your FastAPI endpoints. + +## Next Steps + +Start with **[Redis Cache](redis-cache.md)** to understand the core server-side caching implementation, then explore client-side caching and advanced invalidation strategies. \ No newline at end of file diff --git a/docs/user-guide/caching/redis-cache.md b/docs/user-guide/caching/redis-cache.md new file mode 100644 index 0000000..e9e6092 --- /dev/null +++ b/docs/user-guide/caching/redis-cache.md @@ -0,0 +1,359 @@ +# Redis Cache + +Redis-based server-side caching provides fast, in-memory storage for API responses. The boilerplate includes a sophisticated caching decorator that automatically handles cache storage, retrieval, and invalidation. + +## Understanding Redis Caching + +Redis serves as a high-performance cache layer between your API and database. When properly implemented, it can reduce response times from hundreds of milliseconds to single-digit milliseconds by serving data directly from memory. + +### Why Redis? + +**Performance**: In-memory storage provides sub-millisecond data access +**Scalability**: Handles thousands of concurrent connections efficiently +**Persistence**: Optional data persistence for cache warm-up after restarts +**Atomic Operations**: Thread-safe operations for concurrent applications +**Pattern Matching**: Advanced key pattern operations for bulk cache invalidation + +## Cache Decorator + +The `@cache` decorator provides a simple interface for adding caching to any FastAPI endpoint. + +### Basic Usage + +```python +from fastapi import APIRouter, Request, Depends +from sqlalchemy.orm import Session +from app.core.utils.cache import cache +from app.core.db.database import get_db + +router = APIRouter() + +@router.get("/posts/{post_id}") +@cache(key_prefix="post_cache", expiration=3600) +async def get_post(request: Request, post_id: int, db: Session = Depends(get_db)): + # This function's result will be cached for 1 hour + post = await crud_posts.get(db=db, id=post_id) + return post +``` + +**How It Works:** + +1. **Cache Check**: On GET requests, checks Redis for existing cached data +2. **Cache Miss**: If no cache exists, executes the function and stores the result +3. **Cache Hit**: Returns cached data directly, bypassing function execution +4. **Invalidation**: Automatically removes cache on non-GET requests (POST, PUT, DELETE) + +### Decorator Parameters + +```python +@cache( + key_prefix: str, # Cache key prefix + resource_id_name: str = None, # Explicit resource ID parameter + expiration: int = 3600, # Cache TTL in seconds + resource_id_type: type | tuple[type, ...] = int, # Expected ID type + to_invalidate_extra: dict[str, str] = None, # Additional keys to invalidate + pattern_to_invalidate_extra: list[str] = None # Pattern-based invalidation +) +``` + +#### Key Prefix + +The key prefix creates unique cache identifiers: + +```python +# Simple prefix +@cache(key_prefix="user_data") +# Generates keys like: "user_data:123" + +# Dynamic prefix with placeholders +@cache(key_prefix="{username}_posts") +# Generates keys like: "johndoe_posts:456" + +# Complex prefix with multiple parameters +@cache(key_prefix="user_{user_id}_posts_page_{page}") +# Generates keys like: "user_123_posts_page_2:789" +``` + +#### Resource ID Handling + +```python +# Automatic ID inference (looks for 'id' parameter) +@cache(key_prefix="post_cache") +async def get_post(request: Request, post_id: int): + # Uses post_id automatically + +# Explicit ID parameter +@cache(key_prefix="user_cache", resource_id_name="username") +async def get_user(request: Request, username: str): + # Uses username instead of looking for 'id' + +# Multiple ID types +@cache(key_prefix="search", resource_id_type=(int, str)) +async def search(request: Request, query: str, page: int): + # Accepts either string or int as resource ID +``` + +### Advanced Caching Patterns + +#### Paginated Data Caching + +```python +@router.get("/users/{username}/posts") +@cache( + key_prefix="{username}_posts:page_{page}:items_per_page_{items_per_page}", + resource_id_name="username", + expiration=300 # 5 minutes for paginated data +) +async def get_user_posts( + request: Request, + username: str, + page: int = 1, + items_per_page: int = 10 +): + offset = compute_offset(page, items_per_page) + posts = await crud_posts.get_multi( + db=db, + offset=offset, + limit=items_per_page, + created_by_user_id=user_id + ) + return paginated_response(posts, page, items_per_page) +``` + +#### Hierarchical Data Caching + +```python +@router.get("/organizations/{org_id}/departments/{dept_id}/employees") +@cache( + key_prefix="org_{org_id}_dept_{dept_id}_employees", + resource_id_name="dept_id", + expiration=1800 # 30 minutes +) +async def get_department_employees( + request: Request, + org_id: int, + dept_id: int +): + employees = await crud_employees.get_multi( + db=db, + department_id=dept_id, + organization_id=org_id + ) + return employees +``` + +## Cache Invalidation + +Cache invalidation ensures data consistency when the underlying data changes. + +### Automatic Invalidation + +The cache decorator automatically invalidates cache entries on non-GET requests: + +```python +@router.put("/posts/{post_id}") +@cache(key_prefix="post_cache", resource_id_name="post_id") +async def update_post(request: Request, post_id: int, data: PostUpdate): + # Automatically invalidates "post_cache:123" when called with PUT/POST/DELETE + await crud_posts.update(db=db, id=post_id, object=data) + return {"message": "Post updated"} +``` + +### Extra Key Invalidation + +Invalidate related cache entries when data changes: + +```python +@router.post("/posts") +@cache( + key_prefix="new_post", + resource_id_name="user_id", + to_invalidate_extra={ + "user_posts": "{user_id}", # Invalidate user's post list + "latest_posts": "global", # Invalidate global latest posts + "user_stats": "{user_id}" # Invalidate user statistics + } +) +async def create_post(request: Request, post: PostCreate, user_id: int): + # Creating a post invalidates related cached data + new_post = await crud_posts.create(db=db, object=post) + return new_post +``` + +### Pattern-Based Invalidation + +Use Redis pattern matching for bulk invalidation: + +```python +@router.put("/users/{user_id}/profile") +@cache( + key_prefix="user_profile", + resource_id_name="user_id", + pattern_to_invalidate_extra=[ + "user_{user_id}_*", # All user-related caches + "*_user_{user_id}_*", # Caches that include this user + "search_results_*" # All search result caches + ] +) +async def update_user_profile(request: Request, user_id: int, data: UserUpdate): + # Invalidates all matching cache patterns + await crud_users.update(db=db, id=user_id, object=data) + return {"message": "Profile updated"} +``` + +**Pattern Examples:** + +- `user_*` - All keys starting with "user_" +- `*_posts` - All keys ending with "_posts" +- `user_*_posts_*` - Complex patterns with wildcards +- `temp_*` - Temporary cache entries + +## Configuration + +### Redis Settings + +Configure Redis connection in your environment settings: + +```python +# core/config.py +class RedisCacheSettings(BaseSettings): + REDIS_CACHE_HOST: str = config("REDIS_CACHE_HOST", default="localhost") + REDIS_CACHE_PORT: int = config("REDIS_CACHE_PORT", default=6379) + REDIS_CACHE_PASSWORD: str = config("REDIS_CACHE_PASSWORD", default="") + REDIS_CACHE_DB: int = config("REDIS_CACHE_DB", default=0) + REDIS_CACHE_URL: str = f"redis://:{REDIS_CACHE_PASSWORD}@{REDIS_CACHE_HOST}:{REDIS_CACHE_PORT}/{REDIS_CACHE_DB}" +``` + +### Environment Variables + +```bash +# Basic Configuration +REDIS_CACHE_HOST=localhost +REDIS_CACHE_PORT=6379 + +# Production Configuration +REDIS_CACHE_HOST=redis.production.com +REDIS_CACHE_PORT=6379 +REDIS_CACHE_PASSWORD=your-secure-password +REDIS_CACHE_DB=0 + +# Docker Compose +REDIS_CACHE_HOST=redis +REDIS_CACHE_PORT=6379 +``` + +### Connection Pool Setup + +The boilerplate automatically configures Redis connection pooling: + +```python +# core/setup.py +async def create_redis_cache_pool() -> None: + """Initialize Redis connection pool for caching.""" + cache.pool = redis.ConnectionPool.from_url( + settings.REDIS_CACHE_URL, + max_connections=20, # Maximum connections in pool + retry_on_timeout=True, # Retry on connection timeout + socket_timeout=5.0, # Socket timeout in seconds + health_check_interval=30 # Health check frequency + ) + cache.client = redis.Redis.from_pool(cache.pool) +``` + +### Cache Client Usage + +Direct Redis client access for custom caching logic: + +```python +from app.core.utils.cache import client + +async def custom_cache_operation(): + if client is None: + raise MissingClientError("Redis client not initialized") + + # Set custom cache entry + await client.set("custom_key", "custom_value", ex=3600) + + # Get cached value + cached_value = await client.get("custom_key") + + # Delete cache entry + await client.delete("custom_key") + + # Bulk operations + pipe = client.pipeline() + pipe.set("key1", "value1") + pipe.set("key2", "value2") + pipe.expire("key1", 3600) + await pipe.execute() +``` + +## Performance Optimization + +### Connection Pooling + +Connection pooling prevents the overhead of creating new Redis connections for each request: + +```python +# Benefits of connection pooling: +# - Reuses existing connections +# - Handles connection failures gracefully +# - Provides connection health checks +# - Supports concurrent operations + +# Pool configuration +redis.ConnectionPool.from_url( + settings.REDIS_CACHE_URL, + max_connections=20, # Adjust based on expected load + retry_on_timeout=True, # Handle network issues + socket_keepalive=True, # Keep connections alive + socket_keepalive_options={} +) +``` + +### Cache Key Generation + +The cache decorator automatically generates keys using this pattern: + +```python +# Decorator generates: "{formatted_key_prefix}:{resource_id}" +@cache(key_prefix="post_cache", resource_id_name="post_id") +# Generates: "post_cache:123" + +@cache(key_prefix="{username}_posts:page_{page}") +# Generates: "johndoe_posts:page_1:456" (where 456 is the resource_id) + +# The system handles key formatting automatically - you just provide the prefix template +``` + +**What you control:** +- `key_prefix` template with placeholders like `{username}`, `{page}` +- `resource_id_name` to specify which parameter to use as the ID +- The decorator handles the rest + +**Generated key examples from the boilerplate:** +```python +# From posts.py +"{username}_posts:page_{page}:items_per_page_{items_per_page}" โ†’ "john_posts:page_1:items_per_page_10:789" +"{username}_post_cache" โ†’ "john_post_cache:123" +``` + +### Expiration Strategies + +Choose appropriate expiration times based on data characteristics: + +```python +# Static reference data (rarely changes) +@cache(key_prefix="countries", expiration=86400) # 24 hours + +# User-generated content (changes moderately) +@cache(key_prefix="user_posts", expiration=1800) # 30 minutes + +# Real-time data (changes frequently) +@cache(key_prefix="live_stats", expiration=60) # 1 minute + +# Search results (can be stale) +@cache(key_prefix="search", expiration=3600) # 1 hour +``` + +This comprehensive Redis caching system provides high-performance data access while maintaining data consistency through intelligent invalidation strategies. \ No newline at end of file diff --git a/docs/user-guide/configuration/docker-setup.md b/docs/user-guide/configuration/docker-setup.md new file mode 100644 index 0000000..db4d1bb --- /dev/null +++ b/docs/user-guide/configuration/docker-setup.md @@ -0,0 +1,539 @@ +# Docker Setup + +Learn how to configure and run the FastAPI Boilerplate using Docker Compose. The project includes a complete containerized setup with PostgreSQL, Redis, background workers, and optional services. + +## Docker Compose Architecture + +The boilerplate includes these core services: + +```yaml +services: + web: # FastAPI application (uvicorn or gunicorn) + worker: # ARQ background task worker + db: # PostgreSQL 13 database + redis: # Redis Alpine for caching/queues + # Optional services (commented out by default): + # pgadmin: # Database administration + # nginx: # Reverse proxy + # create_superuser: # One-time superuser creation + # create_tier: # One-time tier creation +``` + +## Basic Docker Compose + +### Main Configuration + +The main `docker-compose.yml` includes: + +```yaml +version: '3.8' + +services: + web: + build: + context: . + dockerfile: Dockerfile + # Development mode (reload enabled) + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + # Production mode (uncomment for production) + # command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 + env_file: + - ./src/.env + ports: + - "8000:8000" + depends_on: + - db + - redis + volumes: + - ./src/app:/code/app + - ./src/.env:/code/.env + + worker: + build: + context: . + dockerfile: Dockerfile + command: arq app.core.worker.settings.WorkerSettings + env_file: + - ./src/.env + depends_on: + - db + - redis + volumes: + - ./src/app:/code/app + - ./src/.env:/code/.env + + db: + image: postgres:13 + env_file: + - ./src/.env + volumes: + - postgres-data:/var/lib/postgresql/data + expose: + - "5432" + + redis: + image: redis:alpine + volumes: + - redis-data:/data + expose: + - "6379" + +volumes: + postgres-data: + redis-data: +``` + +### Environment File Loading + +All services automatically load environment variables from `./src/.env`: + +```yaml +env_file: + - ./src/.env +``` + +The Docker services use these environment variables: + +- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` for database +- `REDIS_*_HOST` variables automatically resolve to service names +- All application settings from your `.env` file + +## Service Details + +### Web Service (FastAPI Application) + +The web service runs your FastAPI application: + +```yaml +web: + build: + context: . + dockerfile: Dockerfile + # Development: uvicorn with reload + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + # Production: gunicorn with multiple workers (commented out) + # command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 + env_file: + - ./src/.env + ports: + - "8000:8000" # Direct access in development + volumes: + - ./src/app:/code/app # Live code reloading + - ./src/.env:/code/.env +``` + +**Key Features:** + +- **Development mode**: Uses uvicorn with `--reload` for automatic code reloading +- **Production mode**: Switch to gunicorn with multiple workers (commented out) +- **Live reloading**: Source code mounted as volume for development +- **Port exposure**: Direct access on port 8000 (can be disabled for nginx) + +### Worker Service (Background Tasks) + +Handles background job processing with ARQ: + +```yaml +worker: + build: + context: . + dockerfile: Dockerfile + command: arq app.core.worker.settings.WorkerSettings + env_file: + - ./src/.env + depends_on: + - db + - redis + volumes: + - ./src/app:/code/app + - ./src/.env:/code/.env +``` + +**Features:** +- Runs ARQ worker for background job processing +- Shares the same codebase and environment as web service +- Automatically connects to Redis for job queues +- Live code reloading in development + +### Database Service (PostgreSQL 13) + +```yaml +db: + image: postgres:13 + env_file: + - ./src/.env + volumes: + - postgres-data:/var/lib/postgresql/data + expose: + - "5432" # Internal network only +``` + +**Configuration:** +- Uses environment variables: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` +- Data persisted in named volume `postgres-data` +- Only exposed to internal Docker network (no external port) +- To enable external access, uncomment the ports section + +### Redis Service + +```yaml +redis: + image: redis:alpine + volumes: + - redis-data:/data + expose: + - "6379" # Internal network only +``` + +**Features:** +- Lightweight Alpine Linux image +- Data persistence with named volume +- Used for caching, job queues, and rate limiting +- Internal network access only + +## Optional Services + +### Database Administration (pgAdmin) + +Uncomment to enable web-based database management: + +```yaml +pgadmin: + container_name: pgadmin4 + image: dpage/pgadmin4:latest + restart: always + ports: + - "5050:80" + volumes: + - pgadmin-data:/var/lib/pgadmin + env_file: + - ./src/.env + depends_on: + - db +``` + +**Usage:** +- Access at `http://localhost:5050` +- Requires `PGADMIN_DEFAULT_EMAIL` and `PGADMIN_DEFAULT_PASSWORD` in `.env` +- Connect to database using service name `db` and port `5432` + +### Reverse Proxy (Nginx) + +Uncomment for production-style reverse proxy: + +```yaml +nginx: + image: nginx:latest + ports: + - "80:80" + volumes: + - ./default.conf:/etc/nginx/conf.d/default.conf + depends_on: + - web +``` + +**Configuration:** +The included `default.conf` provides: + +```nginx +server { + listen 80; + + location / { + proxy_pass http://web:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +**When using nginx:** + +1. Uncomment the nginx service +2. Comment out the `ports` section in the web service +3. Uncomment `expose: ["8000"]` in the web service + +### Initialization Services + +#### Create First Superuser + +```yaml +create_superuser: + build: + context: . + dockerfile: Dockerfile + env_file: + - ./src/.env + depends_on: + - db + - web + command: python -m src.scripts.create_first_superuser + volumes: + - ./src:/code/src +``` + +#### Create First Tier + +```yaml +create_tier: + build: + context: . + dockerfile: Dockerfile + env_file: + - ./src/.env + depends_on: + - db + - web + command: python -m src.scripts.create_first_tier + volumes: + - ./src:/code/src +``` + +**Usage:** + +- These are one-time setup services +- Uncomment when you need to initialize data +- Run once, then comment out again + +## Dockerfile Details + +The project uses a multi-stage Dockerfile with `uv` for fast Python package management: + +### Builder Stage + +```dockerfile +FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +WORKDIR /app + +# Install dependencies (cached layer) +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project + +# Copy and install project +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-editable +``` + +### Final Stage + +```dockerfile +FROM python:3.11-slim-bookworm + +# Create non-root user for security +RUN groupadd --gid 1000 app \ + && useradd --uid 1000 --gid app --shell /bin/bash --create-home app + +# Copy virtual environment from builder +COPY --from=builder --chown=app:app /app/.venv /app/.venv + +ENV PATH="/app/.venv/bin:$PATH" +USER app +WORKDIR /code + +# Default command (can be overridden) +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] +``` + +**Security Features:** + +- Non-root user execution +- Multi-stage build for smaller final image +- Cached dependency installation + +## Common Docker Commands + +### Development Workflow + +```bash +# Start all services +docker compose up + +# Start in background +docker compose up -d + +# Rebuild and start (after code changes) +docker compose up --build + +# View logs +docker compose logs -f web +docker compose logs -f worker + +# Stop services +docker compose down + +# Stop and remove volumes (reset data) +docker compose down -v +``` + +### Service Management + +```bash +# Start specific services +docker compose up web db redis + +# Scale workers +docker compose up --scale worker=3 + +# Execute commands in running containers +docker compose exec web bash +docker compose exec db psql -U postgres +docker compose exec redis redis-cli + +# View service status +docker compose ps +``` + +### Production Mode + +To switch to production mode: + +1. **Enable Gunicorn:** + ```yaml + # Comment out uvicorn line + # command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + # Uncomment gunicorn line + command: gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 + ``` + +2. **Enable Nginx** (optional): + ```yaml + # Uncomment nginx service + nginx: + image: nginx:latest + ports: + - "80:80" + + # In web service, comment out ports and uncomment expose + # ports: + # - "8000:8000" + expose: + - "8000" + ``` + +3. **Remove development volumes:** + ```yaml + # Remove or comment out for production + # volumes: + # - ./src/app:/code/app + # - ./src/.env:/code/.env + ``` + +## Environment Configuration + +### Service Communication + +Services communicate using service names: + +```yaml +# In your .env file for Docker +POSTGRES_SERVER=db # Not localhost +REDIS_CACHE_HOST=redis # Not localhost +REDIS_QUEUE_HOST=redis +REDIS_RATE_LIMIT_HOST=redis +``` + +### Port Management + +**Development (default):** +- Web: `localhost:8000` (direct access) +- Database: `localhost:5432` (uncomment ports to enable) +- Redis: `localhost:6379` (uncomment ports to enable) +- pgAdmin: `localhost:5050` (if enabled) + +**Production with Nginx:** +- Web: `localhost:80` (through nginx) +- Database: Internal only +- Redis: Internal only + +## Troubleshooting + +### Common Issues + +**Container won't start:** +```bash +# Check logs +docker compose logs web + +# Rebuild image +docker compose build --no-cache web + +# Check environment file +docker compose exec web env | grep POSTGRES +``` + +**Database connection issues:** +```bash +# Check if db service is running +docker compose ps db + +# Test connection from web container +docker compose exec web ping db + +# Check database logs +docker compose logs db +``` + +**Port conflicts:** +```bash +# Check what's using the port +lsof -i :8000 + +# Use different ports +ports: + - "8001:8000" # Use port 8001 instead +``` + +### Development vs Production + +**Development features:** + +- Live code reloading with volume mounts +- Direct port access +- uvicorn with `--reload` +- Exposed database/redis ports for debugging + +**Production optimizations:** + +- No volume mounts (code baked into image) +- Nginx reverse proxy +- Gunicorn with multiple workers +- Internal service networking only +- Resource limits and health checks + +## Best Practices + +### Development +- Use volume mounts for live code reloading +- Enable direct port access for debugging +- Use uvicorn with reload for fast development +- Enable optional services (pgAdmin) as needed + +### Production +- Switch to gunicorn with multiple workers +- Use nginx for reverse proxy and load balancing +- Remove volume mounts and bake code into images +- Use internal networking only +- Set resource limits and health checks + +### Security +- Containers run as non-root user +- Use internal networking for service communication +- Don't expose database/redis ports externally +- Use Docker secrets for sensitive data in production + +### Monitoring +- Use `docker compose logs` to monitor services +- Set up health checks for all services +- Monitor resource usage with `docker stats` +- Use structured logging for better observability + +The Docker setup provides everything you need for both development and production. Start with the default configuration and customize as your needs grow! \ No newline at end of file diff --git a/docs/user-guide/configuration/environment-specific.md b/docs/user-guide/configuration/environment-specific.md new file mode 100644 index 0000000..d544cbb --- /dev/null +++ b/docs/user-guide/configuration/environment-specific.md @@ -0,0 +1,692 @@ +# Environment-Specific Configuration + +Learn how to configure your FastAPI application for different environments (development, staging, production) with appropriate security, performance, and monitoring settings. + +## Environment Types + +The boilerplate supports three environment types: + +- **`local`** - Development environment with full debugging +- **`staging`** - Pre-production testing environment +- **`production`** - Production environment with security hardening + +Set the environment type with: + +```env +ENVIRONMENT="local" # or "staging" or "production" +``` + +## Development Environment + +### Local Development Settings + +Create `src/.env.development`: + +```env +# ------------- environment ------------- +ENVIRONMENT="local" +DEBUG=true + +# ------------- app settings ------------- +APP_NAME="MyApp (Development)" +APP_VERSION="0.1.0-dev" + +# ------------- database ------------- +POSTGRES_USER="dev_user" +POSTGRES_PASSWORD="dev_password" +POSTGRES_SERVER="localhost" +POSTGRES_PORT=5432 +POSTGRES_DB="myapp_dev" + +# ------------- crypt ------------- +SECRET_KEY="dev-secret-key-not-for-production-use" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=60 # Longer for development +REFRESH_TOKEN_EXPIRE_DAYS=30 # Longer for development + +# ------------- redis ------------- +REDIS_CACHE_HOST="localhost" +REDIS_CACHE_PORT=6379 +REDIS_QUEUE_HOST="localhost" +REDIS_QUEUE_PORT=6379 +REDIS_RATE_LIMIT_HOST="localhost" +REDIS_RATE_LIMIT_PORT=6379 + +# ------------- caching ------------- +CLIENT_CACHE_MAX_AGE=0 # Disable caching for development + +# ------------- rate limiting ------------- +DEFAULT_RATE_LIMIT_LIMIT=1000 # Higher limits for development +DEFAULT_RATE_LIMIT_PERIOD=3600 + +# ------------- admin ------------- +ADMIN_NAME="Dev Admin" +ADMIN_EMAIL="admin@localhost" +ADMIN_USERNAME="admin" +ADMIN_PASSWORD="admin123" + +# ------------- tier ------------- +TIER_NAME="dev_tier" + +# ------------- logging ------------- +DATABASE_ECHO=true # Log all SQL queries +``` + +### Development Features + +```python +# Development-specific features +if settings.ENVIRONMENT == "local": + # Enable detailed error pages + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Allow all origins in development + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Enable API documentation + app.openapi_url = "/openapi.json" + app.docs_url = "/docs" + app.redoc_url = "/redoc" +``` + +### Docker Development Override + +`docker-compose.override.yml`: + +```yaml +version: '3.8' + +services: + web: + environment: + - ENVIRONMENT=local + - DEBUG=true + - DATABASE_ECHO=true + volumes: + - ./src:/code/src:cached + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + ports: + - "8000:8000" + + db: + environment: + - POSTGRES_DB=myapp_dev + ports: + - "5432:5432" + + redis: + ports: + - "6379:6379" + + # Development tools + adminer: + image: adminer + ports: + - "8080:8080" + depends_on: + - db +``` + +## Staging Environment + +### Staging Settings + +Create `src/.env.staging`: + +```env +# ------------- environment ------------- +ENVIRONMENT="staging" +DEBUG=false + +# ------------- app settings ------------- +APP_NAME="MyApp (Staging)" +APP_VERSION="0.1.0-staging" + +# ------------- database ------------- +POSTGRES_USER="staging_user" +POSTGRES_PASSWORD="complex_staging_password_123!" +POSTGRES_SERVER="staging-db.example.com" +POSTGRES_PORT=5432 +POSTGRES_DB="myapp_staging" + +# ------------- crypt ------------- +SECRET_KEY="staging-secret-key-different-from-production" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# ------------- redis ------------- +REDIS_CACHE_HOST="staging-redis.example.com" +REDIS_CACHE_PORT=6379 +REDIS_QUEUE_HOST="staging-redis.example.com" +REDIS_QUEUE_PORT=6379 +REDIS_RATE_LIMIT_HOST="staging-redis.example.com" +REDIS_RATE_LIMIT_PORT=6379 + +# ------------- caching ------------- +CLIENT_CACHE_MAX_AGE=300 # 5 minutes + +# ------------- rate limiting ------------- +DEFAULT_RATE_LIMIT_LIMIT=100 +DEFAULT_RATE_LIMIT_PERIOD=3600 + +# ------------- admin ------------- +ADMIN_NAME="Staging Admin" +ADMIN_EMAIL="admin@staging.example.com" +ADMIN_USERNAME="staging_admin" +ADMIN_PASSWORD="secure_staging_password_456!" + +# ------------- tier ------------- +TIER_NAME="staging_tier" + +# ------------- logging ------------- +DATABASE_ECHO=false +``` + +### Staging Features + +```python +# Staging-specific features +if settings.ENVIRONMENT == "staging": + # Restricted CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["https://staging.example.com"], + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["*"], + ) + + # API docs available to superusers only + @app.get("/docs", include_in_schema=False) + async def custom_swagger_ui(current_user: User = Depends(get_current_superuser)): + return get_swagger_ui_html(openapi_url="/openapi.json") +``` + +### Docker Staging Configuration + +`docker-compose.staging.yml`: + +```yaml +version: '3.8' + +services: + web: + environment: + - ENVIRONMENT=staging + - DEBUG=false + deploy: + replicas: 2 + resources: + limits: + memory: 1G + reservations: + memory: 512M + restart: always + + db: + environment: + - POSTGRES_DB=myapp_staging + volumes: + - postgres_staging_data:/var/lib/postgresql/data + restart: always + + redis: + restart: always + + worker: + deploy: + replicas: 2 + restart: always + +volumes: + postgres_staging_data: +``` + +## Production Environment + +### Production Settings + +Create `src/.env.production`: + +```env +# ------------- environment ------------- +ENVIRONMENT="production" +DEBUG=false + +# ------------- app settings ------------- +APP_NAME="MyApp" +APP_VERSION="1.0.0" +CONTACT_NAME="Support Team" +CONTACT_EMAIL="support@example.com" + +# ------------- database ------------- +POSTGRES_USER="prod_user" +POSTGRES_PASSWORD="ultra_secure_production_password_789!" +POSTGRES_SERVER="prod-db.example.com" +POSTGRES_PORT=5433 # Custom port for security +POSTGRES_DB="myapp_production" + +# ------------- crypt ------------- +SECRET_KEY="ultra-secure-production-key-generated-with-openssl-rand-hex-32" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=15 # Shorter for security +REFRESH_TOKEN_EXPIRE_DAYS=3 # Shorter for security + +# ------------- redis ------------- +REDIS_CACHE_HOST="prod-redis.example.com" +REDIS_CACHE_PORT=6380 # Custom port for security +REDIS_QUEUE_HOST="prod-redis.example.com" +REDIS_QUEUE_PORT=6380 +REDIS_RATE_LIMIT_HOST="prod-redis.example.com" +REDIS_RATE_LIMIT_PORT=6380 + +# ------------- caching ------------- +CLIENT_CACHE_MAX_AGE=3600 # 1 hour + +# ------------- rate limiting ------------- +DEFAULT_RATE_LIMIT_LIMIT=100 +DEFAULT_RATE_LIMIT_PERIOD=3600 + +# ------------- admin ------------- +ADMIN_NAME="System Administrator" +ADMIN_EMAIL="admin@example.com" +ADMIN_USERNAME="sysadmin" +ADMIN_PASSWORD="extremely_secure_admin_password_with_symbols_#$%!" + +# ------------- tier ------------- +TIER_NAME="production_tier" + +# ------------- logging ------------- +DATABASE_ECHO=false +``` + +### Production Security Features + +```python +# Production-specific features +if settings.ENVIRONMENT == "production": + # Strict CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["https://example.com", "https://www.example.com"], + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["Authorization", "Content-Type"], + ) + + # Disable API documentation + app.openapi_url = None + app.docs_url = None + app.redoc_url = None + + # Add security headers + @app.middleware("http") + async def add_security_headers(request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response +``` + +### Docker Production Configuration + +`docker-compose.prod.yml`: + +```yaml +version: '3.8' + +services: + web: + environment: + - ENVIRONMENT=production + - DEBUG=false + deploy: + replicas: 3 + resources: + limits: + memory: 2G + cpus: '1' + reservations: + memory: 1G + cpus: '0.5' + restart: always + ports: [] # No direct exposure + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/ssl:/etc/nginx/ssl + - ./nginx/htpasswd:/etc/nginx/htpasswd + depends_on: + - web + restart: always + + db: + environment: + - POSTGRES_DB=myapp_production + volumes: + - postgres_prod_data:/var/lib/postgresql/data + ports: [] # No external access + deploy: + resources: + limits: + memory: 4G + reservations: + memory: 2G + restart: always + + redis: + volumes: + - redis_prod_data:/data + ports: [] # No external access + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 512M + restart: always + + worker: + deploy: + replicas: 2 + resources: + limits: + memory: 1G + reservations: + memory: 512M + restart: always + +volumes: + postgres_prod_data: + redis_prod_data: +``` + +## Environment Detection + +### Runtime Environment Checks + +```python +# src/app/core/config.py +class Settings(BaseSettings): + @computed_field + @property + def IS_DEVELOPMENT(self) -> bool: + return self.ENVIRONMENT == "local" + + @computed_field + @property + def IS_PRODUCTION(self) -> bool: + return self.ENVIRONMENT == "production" + + @computed_field + @property + def IS_STAGING(self) -> bool: + return self.ENVIRONMENT == "staging" + +# Use in application +if settings.IS_DEVELOPMENT: + # Development-only code + pass + +if settings.IS_PRODUCTION: + # Production-only code + pass +``` + +### Environment-Specific Validation + +```python +@model_validator(mode="after") +def validate_environment_config(self) -> "Settings": + if self.ENVIRONMENT == "production": + # Production validation + if self.DEBUG: + raise ValueError("DEBUG must be False in production") + if len(self.SECRET_KEY) < 32: + raise ValueError("SECRET_KEY must be at least 32 characters in production") + if "dev" in self.SECRET_KEY.lower(): + raise ValueError("Production SECRET_KEY cannot contain 'dev'") + + if self.ENVIRONMENT == "local": + # Development warnings + if not self.DEBUG: + logger.warning("DEBUG is False in development environment") + + return self +``` + +## Configuration Management + +### Environment File Templates + +Create template files for each environment: + +```bash +# Create environment templates +cp src/.env.example src/.env.development +cp src/.env.example src/.env.staging +cp src/.env.example src/.env.production + +# Use environment-specific files +ln -sf .env.development src/.env # For development +ln -sf .env.staging src/.env # For staging +ln -sf .env.production src/.env # For production +``` + +### Configuration Validation + +```python +# src/scripts/validate_config.py +import asyncio +from src.app.core.config import settings +from src.app.core.db.database import async_get_db + +async def validate_configuration(): + """Validate configuration for current environment.""" + print(f"Validating configuration for {settings.ENVIRONMENT} environment...") + + # Basic settings validation + assert settings.APP_NAME, "APP_NAME is required" + assert settings.SECRET_KEY, "SECRET_KEY is required" + assert len(settings.SECRET_KEY) >= 32, "SECRET_KEY must be at least 32 characters" + + # Environment-specific validation + if settings.ENVIRONMENT == "production": + assert not settings.DEBUG, "DEBUG must be False in production" + assert "dev" not in settings.SECRET_KEY.lower(), "Production SECRET_KEY invalid" + assert settings.POSTGRES_PORT != 5432, "Use custom PostgreSQL port in production" + + # Test database connection + try: + db = await anext(async_get_db()) + print("โœ“ Database connection successful") + await db.close() + except Exception as e: + print(f"โœ— Database connection failed: {e}") + return False + + print("โœ“ Configuration validation passed") + return True + +if __name__ == "__main__": + asyncio.run(validate_configuration()) +``` + +### Environment Switching + +```bash +#!/bin/bash +# scripts/switch_env.sh + +ENV=$1 + +if [ -z "$ENV" ]; then + echo "Usage: $0 " + exit 1 +fi + +case $ENV in + development) + ln -sf .env.development src/.env + echo "Switched to development environment" + ;; + staging) + ln -sf .env.staging src/.env + echo "Switched to staging environment" + ;; + production) + ln -sf .env.production src/.env + echo "Switched to production environment" + echo "WARNING: Make sure to review all settings before deployment!" + ;; + *) + echo "Invalid environment: $ENV" + echo "Valid options: development, staging, production" + exit 1 + ;; +esac + +# Validate configuration +python -c "from src.app.core.config import settings; print(f'Current environment: {settings.ENVIRONMENT}')" +``` + +## Security Best Practices + +### Environment-Specific Security + +```python +# Different security levels per environment +SECURITY_CONFIGS = { + "local": { + "token_expire_minutes": 60, + "enable_cors_origins": ["*"], + "enable_docs": True, + "log_level": "DEBUG", + }, + "staging": { + "token_expire_minutes": 30, + "enable_cors_origins": ["https://staging.example.com"], + "enable_docs": True, # For testing + "log_level": "INFO", + }, + "production": { + "token_expire_minutes": 15, + "enable_cors_origins": ["https://example.com"], + "enable_docs": False, + "log_level": "WARNING", + } +} + +config = SECURITY_CONFIGS[settings.ENVIRONMENT] +``` + +### Secrets Management + +```bash +# Use secrets management in production +# Instead of plain text environment variables +POSTGRES_PASSWORD_FILE="/run/secrets/postgres_password" +SECRET_KEY_FILE="/run/secrets/jwt_secret" + +# Docker secrets +services: + web: + secrets: + - postgres_password + - jwt_secret + environment: + - POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password + - SECRET_KEY_FILE=/run/secrets/jwt_secret + +secrets: + postgres_password: + external: true + jwt_secret: + external: true +``` + +## Monitoring and Logging + +### Environment-Specific Logging + +```python +LOGGING_CONFIG = { + "local": { + "level": "DEBUG", + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + "handlers": ["console"], + }, + "staging": { + "level": "INFO", + "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + "handlers": ["console", "file"], + }, + "production": { + "level": "WARNING", + "format": "%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s", + "handlers": ["file", "syslog"], + } +} +``` + +### Health Checks by Environment + +```python +@app.get("/health") +async def health_check(): + health_info = { + "status": "healthy", + "environment": settings.ENVIRONMENT, + "version": settings.APP_VERSION, + } + + # Add detailed info in non-production + if not settings.IS_PRODUCTION: + health_info.update({ + "database": await check_database_health(), + "redis": await check_redis_health(), + "worker_queue": await check_worker_health(), + }) + + return health_info +``` + +## Best Practices + +### Security +- Use different secret keys for each environment +- Disable debug mode in staging and production +- Use custom ports in production +- Implement proper CORS policies +- Remove API documentation in production + +### Performance +- Configure appropriate resource limits per environment +- Use caching in staging and production +- Set shorter token expiration in production +- Use connection pooling in production + +### Configuration +- Keep environment files in version control (except production) +- Use validation to prevent misconfiguration +- Document all environment-specific settings +- Test configuration changes in staging first + +### Monitoring +- Use appropriate log levels per environment +- Monitor different metrics in each environment +- Set up alerts for production only +- Use health checks for all environments + +Environment-specific configuration ensures your application runs securely and efficiently in each deployment stage. Start with development settings and progressively harden for production! \ No newline at end of file diff --git a/docs/user-guide/configuration/environment-variables.md b/docs/user-guide/configuration/environment-variables.md new file mode 100644 index 0000000..e1714d4 --- /dev/null +++ b/docs/user-guide/configuration/environment-variables.md @@ -0,0 +1,651 @@ +# Configuration Guide + +This guide covers all configuration options available in the FastAPI Boilerplate, including environment variables, settings classes, and advanced deployment configurations. + +## Configuration Overview + +The boilerplate uses a layered configuration approach: + +- **Environment Variables** (`.env` file) - Primary configuration method +- **Settings Classes** (`src/app/core/config.py`) - Python-based configuration +- **Docker Configuration** (`docker-compose.yml`) - Container orchestration +- **Database Configuration** (`alembic.ini`) - Database migrations + +## Environment Variables Reference + +All configuration is managed through environment variables defined in the `.env` file located in the `src/` directory. + +### Application Settings + +Basic application metadata displayed in API documentation: + +```env +# ------------- app settings ------------- +APP_NAME="Your App Name" +APP_DESCRIPTION="Your app description here" +APP_VERSION="0.1.0" +CONTACT_NAME="Your Name" +CONTACT_EMAIL="your.email@example.com" +LICENSE_NAME="MIT" +``` + +**Variables Explained:** + +- `APP_NAME`: Displayed in API documentation and responses +- `APP_DESCRIPTION`: Shown in OpenAPI documentation +- `APP_VERSION`: API version for documentation and headers +- `CONTACT_NAME`: Contact information for API documentation +- `CONTACT_EMAIL`: Support email for API users +- `LICENSE_NAME`: License type for the API + +### Database Configuration + +PostgreSQL database connection settings: + +```env +# ------------- database ------------- +POSTGRES_USER="your_postgres_user" +POSTGRES_PASSWORD="your_secure_password" +POSTGRES_SERVER="localhost" +POSTGRES_PORT=5432 +POSTGRES_DB="your_database_name" +``` + +**Variables Explained:** + +- `POSTGRES_USER`: Database user with appropriate permissions +- `POSTGRES_PASSWORD`: Strong password for database access +- `POSTGRES_SERVER`: Hostname or IP of PostgreSQL server +- `POSTGRES_PORT`: PostgreSQL port (default: 5432) +- `POSTGRES_DB`: Name of the database to connect to + +**Environment-Specific Values:** + +```env +# Local development +POSTGRES_SERVER="localhost" + +# Docker Compose +POSTGRES_SERVER="db" + +# Production +POSTGRES_SERVER="your-prod-db-host.com" +``` + +### Security & Authentication + +JWT and password security configuration: + +```env +# ------------- crypt ------------- +SECRET_KEY="your-super-secret-key-here" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 +``` + +**Variables Explained:** + +- `SECRET_KEY`: Used for JWT token signing (generate with `openssl rand -hex 32`) +- `ALGORITHM`: JWT signing algorithm (HS256 recommended) +- `ACCESS_TOKEN_EXPIRE_MINUTES`: How long access tokens remain valid +- `REFRESH_TOKEN_EXPIRE_DAYS`: How long refresh tokens remain valid + +!!! danger "Security Warning" + Never use default values in production. Generate a strong secret key: + ```bash + openssl rand -hex 32 + ``` + +### Redis Configuration + +Redis is used for caching, job queues, and rate limiting: + +```env +# ------------- redis cache ------------- +REDIS_CACHE_HOST="localhost" # Use "redis" for Docker Compose +REDIS_CACHE_PORT=6379 + +# ------------- redis queue ------------- +REDIS_QUEUE_HOST="localhost" # Use "redis" for Docker Compose +REDIS_QUEUE_PORT=6379 + +# ------------- redis rate limit ------------- +REDIS_RATE_LIMIT_HOST="localhost" # Use "redis" for Docker Compose +REDIS_RATE_LIMIT_PORT=6379 +``` + +**Best Practices:** + +- **Development**: Use the same Redis instance for all services +- **Production**: Use separate Redis instances for better isolation + +```env +# Production example with separate instances +REDIS_CACHE_HOST="cache.redis.example.com" +REDIS_QUEUE_HOST="queue.redis.example.com" +REDIS_RATE_LIMIT_HOST="ratelimit.redis.example.com" +``` + +### Caching Settings + +Client-side and server-side caching configuration: + +```env +# ------------- redis client-side cache ------------- +CLIENT_CACHE_MAX_AGE=30 # seconds +``` + +**Variables Explained:** + +- `CLIENT_CACHE_MAX_AGE`: How long browsers should cache responses + +### Rate Limiting + +Default rate limiting configuration: + +```env +# ------------- default rate limit settings ------------- +DEFAULT_RATE_LIMIT_LIMIT=10 # requests per period +DEFAULT_RATE_LIMIT_PERIOD=3600 # period in seconds (1 hour) +``` + +**Variables Explained:** + +- `DEFAULT_RATE_LIMIT_LIMIT`: Number of requests allowed per period +- `DEFAULT_RATE_LIMIT_PERIOD`: Time window in seconds + +### Admin User + +First superuser account configuration: + +```env +# ------------- admin ------------- +ADMIN_NAME="Admin User" +ADMIN_EMAIL="admin@example.com" +ADMIN_USERNAME="admin" +ADMIN_PASSWORD="secure_admin_password" +``` + +**Variables Explained:** + +- `ADMIN_NAME`: Display name for the admin user +- `ADMIN_EMAIL`: Email address for the admin account +- `ADMIN_USERNAME`: Username for admin login +- `ADMIN_PASSWORD`: Initial password (change after first login) + +### User Tiers + +Initial tier configuration: + +```env +# ------------- first tier ------------- +TIER_NAME="free" +``` + +**Variables Explained:** + +- `TIER_NAME`: Name of the default user tier + +### Environment Type + +Controls API documentation visibility and behavior: + +```env +# ------------- environment ------------- +ENVIRONMENT="local" # local, staging, or production +``` + +**Environment Types:** + +- **local**: Full API docs available publicly at `/docs` +- **staging**: API docs available to superusers only +- **production**: API docs completely disabled + +## Docker Compose Configuration + +### Basic Setup + +Docker Compose automatically loads the `.env` file: + +```yaml +# In docker-compose.yml +services: + web: + env_file: + - ./src/.env +``` + +### Development Overrides + +Create `docker-compose.override.yml` for local customizations: + +```yaml +version: '3.8' +services: + web: + ports: + - "8001:8000" # Use different port + environment: + - DEBUG=true + volumes: + - ./custom-logs:/code/logs +``` + +### Service Configuration + +Understanding each Docker service: + +```yaml +services: + web: # FastAPI application + db: # PostgreSQL database + redis: # Redis for caching/queues + worker: # ARQ background task worker + nginx: # Reverse proxy (optional) +``` + +## Python Settings Classes + +Advanced configuration is handled in `src/app/core/config.py`: + +### Settings Composition + +The main `Settings` class inherits from multiple setting groups: + +```python +class Settings( + AppSettings, + PostgresSettings, + CryptSettings, + FirstUserSettings, + RedisCacheSettings, + ClientSideCacheSettings, + RedisQueueSettings, + RedisRateLimiterSettings, + DefaultRateLimitSettings, + EnvironmentSettings, +): + pass +``` + +### Adding Custom Settings + +Create your own settings group: + +```python +class CustomSettings(BaseSettings): + CUSTOM_API_KEY: str = "" + CUSTOM_TIMEOUT: int = 30 + ENABLE_FEATURE_X: bool = False + +# Add to main Settings class +class Settings( + AppSettings, + # ... other settings ... + CustomSettings, +): + pass +``` + +### Opting Out of Services + +Remove unused services by excluding their settings: + +```python +# Minimal setup without Redis services +class Settings( + AppSettings, + PostgresSettings, + CryptSettings, + FirstUserSettings, + # Removed: RedisCacheSettings + # Removed: RedisQueueSettings + # Removed: RedisRateLimiterSettings + EnvironmentSettings, +): + pass +``` + +## Database Configuration + +### Alembic Configuration + +Database migrations are configured in `src/alembic.ini`: + +```ini +[alembic] +script_location = migrations +sqlalchemy.url = postgresql://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_SERVER)s:%(POSTGRES_PORT)s/%(POSTGRES_DB)s +``` + +### Connection Pooling + +SQLAlchemy connection pool settings in `src/app/core/db/database.py`: + +```python +engine = create_async_engine( + DATABASE_URL, + pool_size=20, # Number of connections to maintain + max_overflow=30, # Additional connections allowed + pool_timeout=30, # Seconds to wait for connection + pool_recycle=1800, # Seconds before connection refresh +) +``` + +### Database Best Practices + +**Connection Pool Sizing:** +- Start with `pool_size=20`, `max_overflow=30` +- Monitor connection usage and adjust based on load +- Use connection pooling monitoring tools + +**Migration Strategy:** +- Always backup database before running migrations +- Test migrations on staging environment first +- Use `alembic revision --autogenerate` for model changes + +## Security Configuration + +### JWT Token Configuration + +Customize JWT behavior in `src/app/core/security.py`: + +```python +def create_access_token(data: dict, expires_delta: timedelta = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) +``` + +### CORS Configuration + +Configure Cross-Origin Resource Sharing in `src/app/main.py`: + +```python +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000"], # Specify allowed origins + allow_credentials=True, + allow_methods=["GET", "POST"], # Specify allowed methods + allow_headers=["*"], +) +``` + +**Production CORS Settings:** + +```python +# Never use wildcard (*) in production +allow_origins=[ + "https://yourapp.com", + "https://www.yourapp.com" +], +``` + +### Security Headers + +Add security headers middleware: + +```python +from starlette.middleware.base import BaseHTTPMiddleware + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request, call_next): + response = await call_next(request) + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-XSS-Protection"] = "1; mode=block" + return response +``` + +## Logging Configuration + +### Basic Logging Setup + +Configure logging in `src/app/core/logger.py`: + +```python +import logging +from logging.handlers import RotatingFileHandler + +# Set log level +LOGGING_LEVEL = logging.INFO + +# Configure file rotation +file_handler = RotatingFileHandler( + 'logs/app.log', + maxBytes=10485760, # 10MB + backupCount=5 # Keep 5 backup files +) +``` + +### Structured Logging + +Use structured logging for better observability: + +```python +import structlog + +structlog.configure( + processors=[ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.processors.JSONRenderer() + ], + logger_factory=structlog.stdlib.LoggerFactory(), +) +``` + +### Log Levels by Environment + +```python +# Environment-specific log levels +LOG_LEVELS = { + "local": logging.DEBUG, + "staging": logging.INFO, + "production": logging.WARNING +} + +LOGGING_LEVEL = LOG_LEVELS.get(settings.ENVIRONMENT, logging.INFO) +``` + +## Environment-Specific Configurations + +### Development (.env.development) + +```env +ENVIRONMENT="local" +POSTGRES_SERVER="localhost" +REDIS_CACHE_HOST="localhost" +SECRET_KEY="dev-secret-key-not-for-production" +ACCESS_TOKEN_EXPIRE_MINUTES=60 # Longer for development +DEBUG=true +``` + +### Staging (.env.staging) + +```env +ENVIRONMENT="staging" +POSTGRES_SERVER="staging-db.example.com" +REDIS_CACHE_HOST="staging-redis.example.com" +SECRET_KEY="staging-secret-key-different-from-prod" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +DEBUG=false +``` + +### Production (.env.production) + +```env +ENVIRONMENT="production" +POSTGRES_SERVER="prod-db.example.com" +REDIS_CACHE_HOST="prod-redis.example.com" +SECRET_KEY="ultra-secure-production-key-generated-with-openssl" +ACCESS_TOKEN_EXPIRE_MINUTES=15 +DEBUG=false +REDIS_CACHE_PORT=6380 # Custom port for security +POSTGRES_PORT=5433 # Custom port for security +``` + +## Advanced Configuration + +### Custom Middleware + +Add custom middleware in `src/app/core/setup.py`: + +```python +def create_application(router, settings, **kwargs): + app = FastAPI(...) + + # Add custom middleware + app.add_middleware(CustomMiddleware, setting=value) + app.add_middleware(TimingMiddleware) + app.add_middleware(RequestIDMiddleware) + + return app +``` + +### Feature Toggles + +Implement feature flags: + +```python +class FeatureSettings(BaseSettings): + ENABLE_ADVANCED_CACHING: bool = False + ENABLE_ANALYTICS: bool = True + ENABLE_EXPERIMENTAL_FEATURES: bool = False + ENABLE_API_VERSIONING: bool = True + +# Use in endpoints +if settings.ENABLE_ADVANCED_CACHING: + # Advanced caching logic + pass +``` + +### Health Checks + +Configure health check endpoints: + +```python +@app.get("/health") +async def health_check(): + return { + "status": "healthy", + "database": await check_database_health(), + "redis": await check_redis_health(), + "version": settings.APP_VERSION + } +``` + +## Configuration Validation + +### Environment Validation + +Add validation to prevent misconfiguration: + +```python +def validate_settings(): + if not settings.SECRET_KEY: + raise ValueError("SECRET_KEY must be set") + + if settings.ENVIRONMENT == "production": + if settings.SECRET_KEY == "dev-secret-key": + raise ValueError("Production must use secure SECRET_KEY") + + if settings.DEBUG: + raise ValueError("DEBUG must be False in production") +``` + +### Runtime Checks + +Add validation to application startup: + +```python +@app.on_event("startup") +async def startup_event(): + validate_settings() + await check_database_connection() + await check_redis_connection() + logger.info(f"Application started in {settings.ENVIRONMENT} mode") +``` + +## Configuration Troubleshooting + +### Common Issues + +**Environment Variables Not Loading:** +```bash +# Check file location and permissions +ls -la src/.env + +# Check file format (no spaces around =) +cat src/.env | grep "=" | head -5 + +# Verify environment loading in Python +python -c "from src.app.core.config import settings; print(settings.APP_NAME)" +``` + +**Database Connection Failed:** +```bash +# Test connection manually +psql -h localhost -U postgres -d myapp + +# Check if PostgreSQL is running +systemctl status postgresql +# or on macOS +brew services list | grep postgresql +``` + +**Redis Connection Failed:** +```bash +# Test Redis connection +redis-cli -h localhost -p 6379 ping + +# Check Redis status +systemctl status redis +# or on macOS +brew services list | grep redis +``` + +### Configuration Testing + +Test your configuration with a simple script: + +```python +# test_config.py +import asyncio +from src.app.core.config import settings +from src.app.core.db.database import async_get_db + +async def test_config(): + print(f"App: {settings.APP_NAME}") + print(f"Environment: {settings.ENVIRONMENT}") + + # Test database + try: + db = await anext(async_get_db()) + print("โœ“ Database connection successful") + await db.close() + except Exception as e: + print(f"โœ— Database connection failed: {e}") + + # Test Redis (if enabled) + try: + from src.app.core.utils.cache import redis_client + await redis_client.ping() + print("โœ“ Redis connection successful") + except Exception as e: + print(f"โœ— Redis connection failed: {e}") + +if __name__ == "__main__": + asyncio.run(test_config()) +``` + +Run with: +```bash +uv run python test_config.py +``` \ No newline at end of file diff --git a/docs/user-guide/configuration/index.md b/docs/user-guide/configuration/index.md new file mode 100644 index 0000000..ad825d6 --- /dev/null +++ b/docs/user-guide/configuration/index.md @@ -0,0 +1,311 @@ +# Configuration + +Learn how to configure your FastAPI Boilerplate application for different environments and use cases. Everything is configured through environment variables and Python settings classes. + +## What You'll Learn + +- **[Environment Variables](environment-variables.md)** - Configure through `.env` files +- **[Settings Classes](settings-classes.md)** - Python-based configuration management +- **[Docker Setup](docker-setup.md)** - Container and service configuration +- **[Environment-Specific](environment-specific.md)** - Development, staging, and production configs + +## Quick Start + +The boilerplate uses environment variables as the primary configuration method: + +```bash +# Copy the example file +cp src/.env.example src/.env + +# Edit with your values +nano src/.env +``` + +Essential variables to set: + +```env +# Application +APP_NAME="My FastAPI App" +SECRET_KEY="your-super-secret-key-here" + +# Database +POSTGRES_USER="your_user" +POSTGRES_PASSWORD="your_password" +POSTGRES_DB="your_database" + +# Admin Account +ADMIN_EMAIL="admin@example.com" +ADMIN_PASSWORD="secure_password" +``` + +## Configuration Architecture + +The configuration system has three layers: + +``` +Environment Variables (.env files) + โ†“ +Settings Classes (Python validation) + โ†“ +Application Configuration (Runtime) +``` + +### Layer 1: Environment Variables +Primary configuration through `.env` files: +```env +POSTGRES_USER="myuser" +POSTGRES_PASSWORD="mypassword" +REDIS_CACHE_HOST="localhost" +SECRET_KEY="your-secret-key" +``` + +### Layer 2: Settings Classes +Python classes that validate and structure configuration: +```python +class PostgresSettings(BaseSettings): + POSTGRES_USER: str + POSTGRES_PASSWORD: str = Field(min_length=8) + POSTGRES_SERVER: str = "localhost" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str +``` + +### Layer 3: Application Use +Configuration injected throughout the application: +```python +from app.core.config import settings + +# Use anywhere in your code +DATABASE_URL = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_SERVER}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" +``` + +## Key Configuration Areas + +### Security Settings +```env +SECRET_KEY="your-super-secret-key-here" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 +``` + +### Database Configuration +```env +POSTGRES_USER="your_user" +POSTGRES_PASSWORD="your_password" +POSTGRES_SERVER="localhost" +POSTGRES_PORT=5432 +POSTGRES_DB="your_database" +``` + +### Redis Services +```env +# Cache +REDIS_CACHE_HOST="localhost" +REDIS_CACHE_PORT=6379 + +# Background jobs +REDIS_QUEUE_HOST="localhost" +REDIS_QUEUE_PORT=6379 + +# Rate limiting +REDIS_RATE_LIMIT_HOST="localhost" +REDIS_RATE_LIMIT_PORT=6379 +``` + +### Application Settings +```env +APP_NAME="Your App Name" +APP_VERSION="1.0.0" +ENVIRONMENT="local" # local, staging, production +DEBUG=true +``` + +### Rate Limiting +```env +DEFAULT_RATE_LIMIT_LIMIT=100 +DEFAULT_RATE_LIMIT_PERIOD=3600 # 1 hour in seconds +``` + +### Admin User +```env +ADMIN_NAME="Admin User" +ADMIN_EMAIL="admin@example.com" +ADMIN_USERNAME="admin" +ADMIN_PASSWORD="secure_password" +``` + +## Environment-Specific Configurations + +### Development +```env +ENVIRONMENT="local" +DEBUG=true +POSTGRES_SERVER="localhost" +REDIS_CACHE_HOST="localhost" +ACCESS_TOKEN_EXPIRE_MINUTES=60 # Longer for development +``` + +### Staging +```env +ENVIRONMENT="staging" +DEBUG=false +POSTGRES_SERVER="staging-db.example.com" +REDIS_CACHE_HOST="staging-redis.example.com" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +``` + +### Production +```env +ENVIRONMENT="production" +DEBUG=false +POSTGRES_SERVER="prod-db.example.com" +REDIS_CACHE_HOST="prod-redis.example.com" +ACCESS_TOKEN_EXPIRE_MINUTES=15 +# Use custom ports for security +POSTGRES_PORT=5433 +REDIS_CACHE_PORT=6380 +``` + +## Docker Configuration + +### Basic Setup +Docker Compose automatically loads your `.env` file: + +```yaml +services: + web: + env_file: + - ./src/.env + environment: + - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} +``` + +### Service Overview +```yaml +services: + web: # FastAPI application + db: # PostgreSQL database + redis: # Redis for caching/queues + worker: # Background task worker +``` + +## Common Configuration Patterns + +### Feature Flags +```python +# In settings class +class FeatureSettings(BaseSettings): + ENABLE_CACHING: bool = True + ENABLE_ANALYTICS: bool = False + ENABLE_BACKGROUND_JOBS: bool = True + +# Use in code +if settings.ENABLE_CACHING: + cache_result = await get_from_cache(key) +``` + +### Environment Detection +```python +@app.get("/docs", include_in_schema=False) +async def custom_swagger_ui(): + if settings.ENVIRONMENT == "production": + raise HTTPException(404, "Documentation not available") + return get_swagger_ui_html(openapi_url="/openapi.json") +``` + +### Health Checks +```python +@app.get("/health") +async def health_check(): + return { + "status": "healthy", + "environment": settings.ENVIRONMENT, + "version": settings.APP_VERSION, + "database": await check_database_health(), + "redis": await check_redis_health() + } +``` + +## Quick Configuration Tasks + +### Generate Secret Key +```bash +# Generate a secure secret key +openssl rand -hex 32 +``` + +### Test Configuration +```python +# test_config.py +from app.core.config import settings + +print(f"App: {settings.APP_NAME}") +print(f"Environment: {settings.ENVIRONMENT}") +print(f"Database: {settings.POSTGRES_DB}") +``` + +### Environment File Templates +```bash +# Development +cp src/.env.example src/.env.development + +# Staging +cp src/.env.example src/.env.staging + +# Production +cp src/.env.example src/.env.production +``` + +## Best Practices + +### Security +- Never commit `.env` files to version control +- Use different secret keys for each environment +- Disable debug mode in production +- Use secure passwords and keys + +### Performance +- Configure appropriate connection pool sizes +- Set reasonable token expiration times +- Use Redis for caching in production +- Configure proper rate limits + +### Maintenance +- Document all custom environment variables +- Use validation in settings classes +- Test configurations in staging first +- Monitor configuration changes + +### Testing +- Use separate test environment variables +- Mock external services in tests +- Validate configuration on startup +- Test with different environment combinations + +## Getting Started + +Follow this path to configure your application: + +### 1. **[Environment Variables](environment-variables.md)** - Start here +Learn about all available environment variables, their purposes, and recommended values for different environments. + +### 2. **[Settings Classes](settings-classes.md)** - Validation layer +Understand how Python settings classes validate and structure your configuration with type hints and validation rules. + +### 3. **[Docker Setup](docker-setup.md)** - Container configuration +Configure Docker Compose services, networking, and environment-specific overrides. + +### 4. **[Environment-Specific](environment-specific.md)** - Deployment configs +Set up configuration for development, staging, and production environments with best practices. + +## What's Next + +Each guide provides practical examples and copy-paste configurations: + +1. **[Environment Variables](environment-variables.md)** - Complete reference and examples +2. **[Settings Classes](settings-classes.md)** - Custom validation and organization +3. **[Docker Setup](docker-setup.md)** - Service configuration and overrides +4. **[Environment-Specific](environment-specific.md)** - Production-ready configurations + +The boilerplate provides sensible defaults - just customize what you need! \ No newline at end of file diff --git a/docs/user-guide/configuration/settings-classes.md b/docs/user-guide/configuration/settings-classes.md new file mode 100644 index 0000000..2a9e932 --- /dev/null +++ b/docs/user-guide/configuration/settings-classes.md @@ -0,0 +1,537 @@ +# Settings Classes + +Learn how Python settings classes validate, structure, and organize your application configuration. The boilerplate uses Pydantic's `BaseSettings` for type-safe configuration management. + +## Settings Architecture + +The main `Settings` class inherits from multiple specialized setting groups: + +```python +# src/app/core/config.py +class Settings( + AppSettings, + PostgresSettings, + CryptSettings, + FirstUserSettings, + RedisCacheSettings, + ClientSideCacheSettings, + RedisQueueSettings, + RedisRateLimiterSettings, + DefaultRateLimitSettings, + EnvironmentSettings, +): + pass + +# Single instance used throughout the app +settings = Settings() +``` + +## Built-in Settings Groups + +### Application Settings +Basic app metadata and configuration: + +```python +class AppSettings(BaseSettings): + APP_NAME: str = "FastAPI" + APP_DESCRIPTION: str = "A FastAPI project" + APP_VERSION: str = "0.1.0" + CONTACT_NAME: str = "Your Name" + CONTACT_EMAIL: str = "your.email@example.com" + LICENSE_NAME: str = "MIT" +``` + +### Database Settings +PostgreSQL connection configuration: + +```python +class PostgresSettings(BaseSettings): + POSTGRES_USER: str + POSTGRES_PASSWORD: str + POSTGRES_SERVER: str = "localhost" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str + + @computed_field + @property + def DATABASE_URL(self) -> str: + return ( + f"postgresql+asyncpg://{self.POSTGRES_USER}:" + f"{self.POSTGRES_PASSWORD}@{self.POSTGRES_SERVER}:" + f"{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) +``` + +### Security Settings +JWT and authentication configuration: + +```python +class CryptSettings(BaseSettings): + SECRET_KEY: str + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + @field_validator("SECRET_KEY") + @classmethod + def validate_secret_key(cls, v: str) -> str: + if len(v) < 32: + raise ValueError("SECRET_KEY must be at least 32 characters") + return v +``` + +### Redis Settings +Separate Redis instances for different services: + +```python +class RedisCacheSettings(BaseSettings): + REDIS_CACHE_HOST: str = "localhost" + REDIS_CACHE_PORT: int = 6379 + +class RedisQueueSettings(BaseSettings): + REDIS_QUEUE_HOST: str = "localhost" + REDIS_QUEUE_PORT: int = 6379 + +class RedisRateLimiterSettings(BaseSettings): + REDIS_RATE_LIMIT_HOST: str = "localhost" + REDIS_RATE_LIMIT_PORT: int = 6379 +``` + +### Rate Limiting Settings +Default rate limiting configuration: + +```python +class DefaultRateLimitSettings(BaseSettings): + DEFAULT_RATE_LIMIT_LIMIT: int = 10 + DEFAULT_RATE_LIMIT_PERIOD: int = 3600 # 1 hour +``` + +### Admin User Settings +First superuser account creation: + +```python +class FirstUserSettings(BaseSettings): + ADMIN_NAME: str = "Admin" + ADMIN_EMAIL: str + ADMIN_USERNAME: str = "admin" + ADMIN_PASSWORD: str + + @field_validator("ADMIN_EMAIL") + @classmethod + def validate_admin_email(cls, v: str) -> str: + if "@" not in v: + raise ValueError("ADMIN_EMAIL must be a valid email") + return v +``` + +## Creating Custom Settings + +### Basic Custom Settings + +Add your own settings group: + +```python +class CustomSettings(BaseSettings): + CUSTOM_API_KEY: str = "" + CUSTOM_TIMEOUT: int = 30 + ENABLE_FEATURE_X: bool = False + MAX_UPLOAD_SIZE: int = 10485760 # 10MB + + @field_validator("MAX_UPLOAD_SIZE") + @classmethod + def validate_upload_size(cls, v: int) -> int: + if v < 1024: # 1KB minimum + raise ValueError("MAX_UPLOAD_SIZE must be at least 1KB") + if v > 104857600: # 100MB maximum + raise ValueError("MAX_UPLOAD_SIZE cannot exceed 100MB") + return v + +# Add to main Settings class +class Settings( + AppSettings, + PostgresSettings, + # ... other settings ... + CustomSettings, # Add your custom settings +): + pass +``` + +### Advanced Custom Settings + +Settings with complex validation and computed fields: + +```python +class EmailSettings(BaseSettings): + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + SMTP_USERNAME: str = "" + SMTP_PASSWORD: str = "" + SMTP_USE_TLS: bool = True + EMAIL_FROM: str = "" + EMAIL_FROM_NAME: str = "" + + @computed_field + @property + def EMAIL_ENABLED(self) -> bool: + return bool(self.SMTP_HOST and self.SMTP_USERNAME) + + @model_validator(mode="after") + def validate_email_config(self) -> "EmailSettings": + if self.SMTP_HOST and not self.EMAIL_FROM: + raise ValueError("EMAIL_FROM required when SMTP_HOST is set") + if self.SMTP_USERNAME and not self.SMTP_PASSWORD: + raise ValueError("SMTP_PASSWORD required when SMTP_USERNAME is set") + return self +``` + +### Feature Flag Settings + +Organize feature toggles: + +```python +class FeatureSettings(BaseSettings): + # Core features + ENABLE_CACHING: bool = True + ENABLE_RATE_LIMITING: bool = True + ENABLE_BACKGROUND_JOBS: bool = True + + # Optional features + ENABLE_ANALYTICS: bool = False + ENABLE_EMAIL_NOTIFICATIONS: bool = False + ENABLE_FILE_UPLOADS: bool = False + + # Experimental features + ENABLE_EXPERIMENTAL_API: bool = False + ENABLE_BETA_FEATURES: bool = False + + @model_validator(mode="after") + def validate_feature_dependencies(self) -> "FeatureSettings": + if self.ENABLE_EMAIL_NOTIFICATIONS and not self.ENABLE_BACKGROUND_JOBS: + raise ValueError("Email notifications require background jobs") + return self +``` + +## Settings Validation + +### Field Validation + +Validate individual fields: + +```python +class DatabaseSettings(BaseSettings): + DB_POOL_SIZE: int = 20 + DB_MAX_OVERFLOW: int = 30 + DB_TIMEOUT: int = 30 + + @field_validator("DB_POOL_SIZE") + @classmethod + def validate_pool_size(cls, v: int) -> int: + if v < 1: + raise ValueError("Pool size must be at least 1") + if v > 100: + raise ValueError("Pool size should not exceed 100") + return v + + @field_validator("DB_TIMEOUT") + @classmethod + def validate_timeout(cls, v: int) -> int: + if v < 5: + raise ValueError("Timeout must be at least 5 seconds") + return v +``` + +### Model Validation + +Validate across multiple fields: + +```python +class SecuritySettings(BaseSettings): + ENABLE_HTTPS: bool = False + SSL_CERT_PATH: str = "" + SSL_KEY_PATH: str = "" + FORCE_SSL: bool = False + + @model_validator(mode="after") + def validate_ssl_config(self) -> "SecuritySettings": + if self.ENABLE_HTTPS: + if not self.SSL_CERT_PATH: + raise ValueError("SSL_CERT_PATH required when HTTPS enabled") + if not self.SSL_KEY_PATH: + raise ValueError("SSL_KEY_PATH required when HTTPS enabled") + + if self.FORCE_SSL and not self.ENABLE_HTTPS: + raise ValueError("Cannot force SSL without enabling HTTPS") + + return self +``` + +### Environment-Specific Validation + +Different validation rules per environment: + +```python +class EnvironmentSettings(BaseSettings): + ENVIRONMENT: str = "local" + DEBUG: bool = True + + @model_validator(mode="after") + def validate_environment_config(self) -> "EnvironmentSettings": + if self.ENVIRONMENT == "production": + if self.DEBUG: + raise ValueError("DEBUG must be False in production") + + if self.ENVIRONMENT not in ["local", "staging", "production"]: + raise ValueError("ENVIRONMENT must be local, staging, or production") + + return self +``` + +## Computed Properties + +### Dynamic Configuration + +Create computed values from other settings: + +```python +class StorageSettings(BaseSettings): + STORAGE_TYPE: str = "local" # local, s3, gcs + + # Local storage + LOCAL_STORAGE_PATH: str = "./uploads" + + # S3 settings + AWS_ACCESS_KEY_ID: str = "" + AWS_SECRET_ACCESS_KEY: str = "" + AWS_BUCKET_NAME: str = "" + AWS_REGION: str = "us-east-1" + + @computed_field + @property + def STORAGE_ENABLED(self) -> bool: + if self.STORAGE_TYPE == "local": + return bool(self.LOCAL_STORAGE_PATH) + elif self.STORAGE_TYPE == "s3": + return bool(self.AWS_ACCESS_KEY_ID and self.AWS_SECRET_ACCESS_KEY and self.AWS_BUCKET_NAME) + return False + + @computed_field + @property + def STORAGE_CONFIG(self) -> dict: + if self.STORAGE_TYPE == "local": + return {"path": self.LOCAL_STORAGE_PATH} + elif self.STORAGE_TYPE == "s3": + return { + "bucket": self.AWS_BUCKET_NAME, + "region": self.AWS_REGION, + "credentials": { + "access_key": self.AWS_ACCESS_KEY_ID, + "secret_key": self.AWS_SECRET_ACCESS_KEY, + } + } + return {} +``` + +## Organizing Settings + +### Service-Based Organization + +Group settings by service or domain: + +```python +# Authentication service settings +class AuthSettings(BaseSettings): + JWT_SECRET_KEY: str + JWT_ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE: int = 30 + REFRESH_TOKEN_EXPIRE: int = 7200 + PASSWORD_MIN_LENGTH: int = 8 + +# Notification service settings +class NotificationSettings(BaseSettings): + EMAIL_ENABLED: bool = False + SMS_ENABLED: bool = False + PUSH_ENABLED: bool = False + + # Email settings + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + + # SMS settings (example with Twilio) + TWILIO_ACCOUNT_SID: str = "" + TWILIO_AUTH_TOKEN: str = "" + +# Main settings +class Settings( + AppSettings, + AuthSettings, + NotificationSettings, + # ... other settings +): + pass +``` + +### Conditional Settings Loading + +Load different settings based on environment: + +```python +class BaseAppSettings(BaseSettings): + APP_NAME: str = "FastAPI App" + DEBUG: bool = False + +class DevelopmentSettings(BaseAppSettings): + DEBUG: bool = True + LOG_LEVEL: str = "DEBUG" + DATABASE_ECHO: bool = True + +class ProductionSettings(BaseAppSettings): + DEBUG: bool = False + LOG_LEVEL: str = "WARNING" + DATABASE_ECHO: bool = False + +def get_settings() -> BaseAppSettings: + environment = os.getenv("ENVIRONMENT", "local") + + if environment == "production": + return ProductionSettings() + else: + return DevelopmentSettings() + +settings = get_settings() +``` + +## Removing Unused Services + +### Minimal Configuration + +Remove services you don't need: + +```python +# Minimal setup without Redis services +class MinimalSettings( + AppSettings, + PostgresSettings, + CryptSettings, + FirstUserSettings, + # Removed: RedisCacheSettings + # Removed: RedisQueueSettings + # Removed: RedisRateLimiterSettings + EnvironmentSettings, +): + pass +``` + +### Service Feature Flags + +Use feature flags to conditionally enable services: + +```python +class ServiceSettings(BaseSettings): + ENABLE_REDIS: bool = True + ENABLE_CELERY: bool = True + ENABLE_MONITORING: bool = False + +class ConditionalSettings( + AppSettings, + PostgresSettings, + CryptSettings, + ServiceSettings, +): + # Add Redis settings only if enabled + def __init__(self, **kwargs): + super().__init__(**kwargs) + + if self.ENABLE_REDIS: + # Dynamically add Redis settings + self.__class__ = type( + "ConditionalSettings", + (self.__class__, RedisCacheSettings), + {} + ) +``` + +## Testing Settings + +### Test Configuration + +Create separate settings for testing: + +```python +class TestSettings(BaseSettings): + # Override database for testing + POSTGRES_DB: str = "test_database" + + # Disable external services + ENABLE_REDIS: bool = False + ENABLE_EMAIL: bool = False + + # Speed up tests + ACCESS_TOKEN_EXPIRE_MINUTES: int = 5 + + # Test-specific settings + TEST_USER_EMAIL: str = "test@example.com" + TEST_USER_PASSWORD: str = "testpassword123" + +# Use in tests +@pytest.fixture +def test_settings(): + return TestSettings() +``` + +### Settings Validation Testing + +Test your custom settings: + +```python +def test_custom_settings_validation(): + # Test valid configuration + settings = CustomSettings( + CUSTOM_API_KEY="test-key", + CUSTOM_TIMEOUT=60, + MAX_UPLOAD_SIZE=5242880 # 5MB + ) + assert settings.CUSTOM_TIMEOUT == 60 + + # Test validation error + with pytest.raises(ValueError, match="MAX_UPLOAD_SIZE cannot exceed 100MB"): + CustomSettings(MAX_UPLOAD_SIZE=209715200) # 200MB + +def test_settings_computed_fields(): + settings = StorageSettings( + STORAGE_TYPE="s3", + AWS_ACCESS_KEY_ID="test-key", + AWS_SECRET_ACCESS_KEY="test-secret", + AWS_BUCKET_NAME="test-bucket" + ) + + assert settings.STORAGE_ENABLED is True + assert settings.STORAGE_CONFIG["bucket"] == "test-bucket" +``` + +## Best Practices + +### Organization +- Group related settings in dedicated classes +- Use descriptive names for settings groups +- Keep validation logic close to the settings +- Document complex validation rules + +### Security +- Validate sensitive settings like secret keys +- Never set default values for secrets in production +- Use computed fields to derive connection strings +- Separate test and production configurations + +### Performance +- Use `@computed_field` for expensive calculations +- Cache settings instances appropriately +- Avoid complex validation in hot paths +- Use model validators for cross-field validation + +### Testing +- Create separate test settings classes +- Test all validation rules +- Mock external service settings in tests +- Use dependency injection for settings in tests + +The settings system provides type safety, validation, and organization for your application configuration. Start with the built-in settings and extend them as your application grows! \ No newline at end of file diff --git a/docs/user-guide/database/crud.md b/docs/user-guide/database/crud.md new file mode 100644 index 0000000..1bf5a98 --- /dev/null +++ b/docs/user-guide/database/crud.md @@ -0,0 +1,491 @@ +# CRUD Operations + +This guide covers all CRUD (Create, Read, Update, Delete) operations available in the FastAPI Boilerplate using FastCRUD, a powerful library that provides consistent and efficient database operations. + +## Overview + +The boilerplate uses [FastCRUD](https://github.com/igorbenav/fastcrud) for all database operations. FastCRUD provides: + +- **Consistent API** across all models +- **Type safety** with generic type parameters +- **Automatic pagination** support +- **Advanced filtering** and joining capabilities +- **Soft delete** support +- **Optimized queries** with selective field loading + +## CRUD Class Structure + +Each model has a corresponding CRUD class that defines the available operations: + +```python +# src/app/crud/crud_users.py +from fastcrud import FastCRUD +from app.models.user import User +from app.schemas.user import ( + UserCreateInternal, UserUpdate, UserUpdateInternal, + UserDelete, UserRead +) + +CRUDUser = FastCRUD[ + User, # Model class + UserCreateInternal, # Create schema + UserUpdate, # Update schema + UserUpdateInternal, # Internal update schema + UserDelete, # Delete schema + UserRead # Read schema +] +crud_users = CRUDUser(User) +``` + +## Read Operations + +### Get Single Record + +Retrieve a single record by any field: + +```python +# Get user by ID +user = await crud_users.get(db=db, id=user_id) + +# Get user by username +user = await crud_users.get(db=db, username="john_doe") + +# Get user by email +user = await crud_users.get(db=db, email="john@example.com") + +# Get with specific fields only +user = await crud_users.get( + db=db, + schema_to_select=UserRead, # Only select fields defined in UserRead + id=user_id, +) +``` + +**Real usage from the codebase:** + +```python +# From src/app/api/v1/users.py +db_user = await crud_users.get( + db=db, + schema_to_select=UserRead, + username=username, + is_deleted=False, +) +``` + +### Get Multiple Records + +Retrieve multiple records with filtering and pagination: + +```python +# Get all users +users = await crud_users.get_multi(db=db) + +# Get with pagination +users = await crud_users.get_multi( + db=db, + offset=0, # Skip first 0 records + limit=10, # Return maximum 10 records +) + +# Get with filtering +active_users = await crud_users.get_multi( + db=db, + is_deleted=False, # Filter condition + offset=compute_offset(page, items_per_page), + limit=items_per_page +) +``` + +**Pagination response structure:** + +```python +{ + "data": [ + {"id": 1, "username": "john", "email": "john@example.com"}, + {"id": 2, "username": "jane", "email": "jane@example.com"} + ], + "total_count": 25, + "has_more": true, + "page": 1, + "items_per_page": 10 +} +``` + +### Check Existence + +Check if a record exists without fetching it: + +```python +# Check if user exists +user_exists = await crud_users.exists(db=db, email="john@example.com") +# Returns True or False + +# Check if username is available +username_taken = await crud_users.exists(db=db, username="john_doe") +``` + +**Real usage example:** + +```python +# From src/app/api/v1/users.py - checking before creating +email_row = await crud_users.exists(db=db, email=user.email) +if email_row: + raise DuplicateValueException("Email is already registered") +``` + +### Count Records + +Get count of records matching criteria: + +```python +# Count all users +total_users = await crud_users.count(db=db) + +# Count active users +active_count = await crud_users.count(db=db, is_deleted=False) + +# Count by specific criteria +admin_count = await crud_users.count(db=db, is_superuser=True) +``` + +## Create Operations + +### Basic Creation + +Create new records using Pydantic schemas: + +```python +# Create user +user_data = UserCreateInternal( + username="john_doe", + email="john@example.com", + hashed_password="hashed_password_here" +) + +created_user = await crud_users.create(db=db, object=user_data) +``` + +**Real creation example:** + +```python +# From src/app/api/v1/users.py +user_internal_dict = user.model_dump() +user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) +del user_internal_dict["password"] + +user_internal = UserCreateInternal(**user_internal_dict) +created_user = await crud_users.create(db=db, object=user_internal) +``` + +### Create with Relationships + +When creating records with foreign keys: + +```python +# Create post for a user +post_data = PostCreateInternal( + title="My First Post", + content="This is the content of my post", + created_by_user_id=user.id # Foreign key reference +) + +created_post = await crud_posts.create(db=db, object=post_data) +``` + +## Update Operations + +### Basic Updates + +Update records by any field: + +```python +# Update user by ID +update_data = UserUpdate(email="newemail@example.com") +await crud_users.update(db=db, object=update_data, id=user_id) + +# Update by username +await crud_users.update(db=db, object=update_data, username="john_doe") + +# Update multiple fields +update_data = UserUpdate( + email="newemail@example.com", + profile_image_url="https://newimage.com/photo.jpg" +) +await crud_users.update(db=db, object=update_data, id=user_id) +``` + +### Conditional Updates + +Update with validation: + +```python +# From real endpoint - check before updating +if values.username != db_user.username: + existing_username = await crud_users.exists(db=db, username=values.username) + if existing_username: + raise DuplicateValueException("Username not available") + +await crud_users.update(db=db, object=values, username=username) +``` + +### Bulk Updates + +Update multiple records at once: + +```python +# Update all users with specific criteria +update_data = {"is_active": False} +await crud_users.update(db=db, object=update_data, is_deleted=True) +``` + +## Delete Operations + +### Soft Delete + +For models with soft delete fields (like User, Post): + +```python +# Soft delete - sets is_deleted=True, deleted_at=now() +await crud_users.delete(db=db, username="john_doe") + +# The record stays in the database but is marked as deleted +user = await crud_users.get(db=db, username="john_doe", is_deleted=True) +``` + +### Hard Delete + +Permanently remove records from the database: + +```python +# Permanently delete from database +await crud_users.db_delete(db=db, username="john_doe") + +# The record is completely removed +``` + +**Real deletion example:** + +```python +# From src/app/api/v1/users.py +# Regular users get soft delete +await crud_users.delete(db=db, username=username) + +# Superusers can hard delete +await crud_users.db_delete(db=db, username=username) +``` + +## Advanced Operations + +### Joined Queries + +Get data from multiple related tables: + +```python +# Get posts with user information +posts_with_users = await crud_posts.get_multi_joined( + db=db, + join_model=User, + join_on=Post.created_by_user_id == User.id, + schema_to_select=PostRead, + join_schema_to_select=UserRead, + join_prefix="user_" +) +``` + +Result structure: +```python +{ + "id": 1, + "title": "My Post", + "content": "Post content", + "user_id": 123, + "user_username": "john_doe", + "user_email": "john@example.com" +} +``` + +### Custom Filtering + +Advanced filtering with SQLAlchemy expressions: + +```python +from sqlalchemy import and_, or_ + +# Complex filters +users = await crud_users.get_multi( + db=db, + filter_criteria=[ + and_( + User.is_deleted == False, + User.created_at > datetime(2024, 1, 1) + ) + ] +) +``` + +### Optimized Field Selection + +Select only needed fields for better performance: + +```python +# Only select id and username +users = await crud_users.get_multi( + db=db, + schema_to_select=UserRead, # Use schema to define fields + limit=100 +) + +# Or specify fields directly +users = await crud_users.get_multi( + db=db, + schema_to_select=["id", "username", "email"], + limit=100 +) +``` + +## Practical Examples + +### Complete CRUD Workflow + +Here's a complete example showing all CRUD operations: + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from app.crud.crud_users import crud_users +from app.schemas.user import UserCreateInternal, UserUpdate, UserRead + +async def user_management_example(db: AsyncSession): + # 1. CREATE + user_data = UserCreateInternal( + username="demo_user", + email="demo@example.com", + hashed_password="hashed_password" + ) + new_user = await crud_users.create(db=db, object=user_data) + print(f"Created user: {new_user.id}") + + # 2. READ + user = await crud_users.get( + db=db, + id=new_user.id, + schema_to_select=UserRead + ) + print(f"Retrieved user: {user.username}") + + # 3. UPDATE + update_data = UserUpdate(email="updated@example.com") + await crud_users.update(db=db, object=update_data, id=new_user.id) + print("User updated") + + # 4. DELETE (soft delete) + await crud_users.delete(db=db, id=new_user.id) + print("User soft deleted") + + # 5. VERIFY DELETION + deleted_user = await crud_users.get(db=db, id=new_user.id, is_deleted=True) + print(f"User deleted at: {deleted_user.deleted_at}") +``` + +### Pagination Helper + +Using FastCRUD's pagination utilities: + +```python +from fastcrud.paginated import compute_offset, paginated_response + +async def get_paginated_users( + db: AsyncSession, + page: int = 1, + items_per_page: int = 10 +): + users_data = await crud_users.get_multi( + db=db, + offset=compute_offset(page, items_per_page), + limit=items_per_page, + is_deleted=False, + schema_to_select=UserRead + ) + + return paginated_response( + crud_data=users_data, + page=page, + items_per_page=items_per_page + ) +``` + +### Error Handling + +Proper error handling with CRUD operations: + +```python +from app.core.exceptions.http_exceptions import NotFoundException, DuplicateValueException + +async def safe_user_creation(db: AsyncSession, user_data: UserCreate): + # Check for duplicates + if await crud_users.exists(db=db, email=user_data.email): + raise DuplicateValueException("Email already registered") + + if await crud_users.exists(db=db, username=user_data.username): + raise DuplicateValueException("Username not available") + + # Create user + try: + user_internal = UserCreateInternal(**user_data.model_dump()) + created_user = await crud_users.create(db=db, object=user_internal) + return created_user + except Exception as e: + # Handle database errors + await db.rollback() + raise e +``` + +## Performance Tips + +### 1. Use Schema Selection + +Always specify `schema_to_select` to avoid loading unnecessary data: + +```python +# Good - only loads needed fields +user = await crud_users.get(db=db, id=user_id, schema_to_select=UserRead) + +# Avoid - loads all fields +user = await crud_users.get(db=db, id=user_id) +``` + +### 2. Batch Operations + +For multiple operations, use transactions: + +```python +async def batch_user_updates(db: AsyncSession, updates: List[dict]): + try: + for update in updates: + await crud_users.update(db=db, object=update["data"], id=update["id"]) + await db.commit() + except Exception: + await db.rollback() + raise +``` + +### 3. Use Exists for Checks + +Use `exists()` instead of `get()` when you only need to check existence: + +```python +# Good - faster, doesn't load data +if await crud_users.exists(db=db, email=email): + raise DuplicateValueException("Email taken") + +# Avoid - slower, loads unnecessary data +user = await crud_users.get(db=db, email=email) +if user: + raise DuplicateValueException("Email taken") +``` + +## Next Steps + +- **[Database Migrations](migrations.md)** - Managing database schema changes +- **[API Development](../api/index.md)** - Using CRUD in API endpoints +- **[Caching](../caching/index.md)** - Optimizing CRUD with caching \ No newline at end of file diff --git a/docs/user-guide/database/index.md b/docs/user-guide/database/index.md new file mode 100644 index 0000000..aa941ba --- /dev/null +++ b/docs/user-guide/database/index.md @@ -0,0 +1,235 @@ +# Database Layer + +Learn how to work with the database layer in the FastAPI Boilerplate. This section covers everything you need to store and retrieve data effectively. + +## What You'll Learn + +- **[Models](models.md)** - Define database tables with SQLAlchemy models +- **[Schemas](schemas.md)** - Validate and serialize data with Pydantic schemas +- **[CRUD Operations](crud.md)** - Perform database operations with FastCRUD +- **[Migrations](migrations.md)** - Manage database schema changes with Alembic + +## Quick Overview + +The boilerplate uses a layered architecture that separates concerns: + +```python +# API Endpoint +@router.post("/", response_model=UserRead) +async def create_user(user_data: UserCreate, db: AsyncSession): + return await crud_users.create(db=db, object=user_data) + +# The layers work together: +# 1. UserCreate schema validates the input +# 2. crud_users handles the database operation +# 3. User model defines the database table +# 4. UserRead schema formats the response +``` + +## Architecture + +The database layer follows a clear separation: + +``` +API Request + โ†“ +Pydantic Schema (validation & serialization) + โ†“ +CRUD Layer (business logic & database operations) + โ†“ +SQLAlchemy Model (database table definition) + โ†“ +PostgreSQL Database +``` + +## Key Features + +### ๐Ÿ—„๏ธ **SQLAlchemy 2.0 Models** +Modern async SQLAlchemy with type hints: +```python +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(50), unique=True) + email: Mapped[str] = mapped_column(String(100), unique=True) + created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) +``` + +### โœ… **Pydantic Schemas** +Automatic validation and serialization: +```python +class UserCreate(BaseModel): + username: str = Field(min_length=2, max_length=50) + email: EmailStr + password: str = Field(min_length=8) + +class UserRead(BaseModel): + id: int + username: str + email: str + created_at: datetime + # Note: no password field in read schema +``` + +### ๐Ÿ”ง **FastCRUD Operations** +Consistent database operations: +```python +# Create +user = await crud_users.create(db=db, object=user_create) + +# Read +user = await crud_users.get(db=db, id=user_id) +users = await crud_users.get_multi(db=db, offset=0, limit=10) + +# Update +user = await crud_users.update(db=db, object=user_update, id=user_id) + +# Delete (soft delete) +await crud_users.delete(db=db, id=user_id) +``` + +### ๐Ÿ”„ **Database Migrations** +Track schema changes with Alembic: +```bash +# Generate migration +alembic revision --autogenerate -m "Add user table" + +# Apply migrations +alembic upgrade head + +# Rollback if needed +alembic downgrade -1 +``` + +## Database Setup + +The boilerplate is configured for PostgreSQL with async support: + +### Environment Configuration +```bash +# .env file +POSTGRES_USER=your_user +POSTGRES_PASSWORD=your_password +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=your_database +``` + +### Connection Management +```python +# Database session dependency +async def async_get_db() -> AsyncIterator[AsyncSession]: + async with async_session_maker() as session: + yield session + +# Use in endpoints +@router.get("/users/") +async def get_users(db: Annotated[AsyncSession, Depends(async_get_db)]): + return await crud_users.get_multi(db=db) +``` + +## Included Models + +The boilerplate includes four example models: + +### **User Model** - Authentication & user management +- Username, email, password (hashed) +- Soft delete support +- Tier-based access control + +### **Post Model** - Content with user relationships +- Title, content, creation metadata +- Foreign key to user (no SQLAlchemy relationships) +- Soft delete built-in + +### **Tier Model** - User subscription levels +- Name-based tiers (free, premium, etc.) +- Links to rate limiting system + +### **Rate Limit Model** - API access control +- Path-specific rate limits per tier +- Configurable limits and time periods + +## Directory Structure + +```text +src/app/ +โ”œโ”€โ”€ models/ # SQLAlchemy models (database tables) +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ user.py # User table definition +โ”‚ โ”œโ”€โ”€ post.py # Post table definition +โ”‚ โ””โ”€โ”€ ... +โ”œโ”€โ”€ schemas/ # Pydantic schemas (validation) +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ user.py # User validation schemas +โ”‚ โ”œโ”€โ”€ post.py # Post validation schemas +โ”‚ โ””โ”€โ”€ ... +โ”œโ”€โ”€ crud/ # Database operations +โ”‚ โ”œโ”€โ”€ __init__.py +โ”‚ โ”œโ”€โ”€ crud_users.py # User CRUD operations +โ”‚ โ”œโ”€โ”€ crud_posts.py # Post CRUD operations +โ”‚ โ””โ”€โ”€ ... +โ””โ”€โ”€ core/db/ # Database configuration + โ”œโ”€โ”€ database.py # Connection and session setup + โ””โ”€โ”€ models.py # Base classes and mixins +``` + +## Common Patterns + +### Create with Validation +```python +@router.post("/users/", response_model=UserRead) +async def create_user( + user_data: UserCreate, # Validates input automatically + db: Annotated[AsyncSession, Depends(async_get_db)] +): + # Check for duplicates + if await crud_users.exists(db=db, email=user_data.email): + raise DuplicateValueException("Email already exists") + + # Create user (password gets hashed automatically) + return await crud_users.create(db=db, object=user_data) +``` + +### Query with Filters +```python +# Get active users only +users = await crud_users.get_multi( + db=db, + is_active=True, + is_deleted=False, + offset=0, + limit=10 +) + +# Search users +users = await crud_users.get_multi( + db=db, + username__icontains="john", # Contains "john" + schema_to_select=UserRead +) +``` + +### Soft Delete Pattern +```python +# Soft delete (sets is_deleted=True) +await crud_users.delete(db=db, id=user_id) + +# Hard delete (actually removes from database) +await crud_users.db_delete(db=db, id=user_id) + +# Get only non-deleted records +users = await crud_users.get_multi(db=db, is_deleted=False) +``` + +## What's Next + +Each guide builds on the previous one with practical examples: + +1. **[Models](models.md)** - Define your database structure +2. **[Schemas](schemas.md)** - Add validation and serialization +3. **[CRUD Operations](crud.md)** - Implement business logic +4. **[Migrations](migrations.md)** - Deploy changes safely + +The boilerplate provides a solid foundation - just follow these patterns to build your data layer! \ No newline at end of file diff --git a/docs/user-guide/database/migrations.md b/docs/user-guide/database/migrations.md new file mode 100644 index 0000000..ed66b35 --- /dev/null +++ b/docs/user-guide/database/migrations.md @@ -0,0 +1,470 @@ +# Database Migrations + +This guide covers database migrations using Alembic, the migration tool for SQLAlchemy. Learn how to manage database schema changes safely and efficiently in development and production. + +## Overview + +The FastAPI Boilerplate uses [Alembic](https://alembic.sqlalchemy.org/) for database migrations. Alembic provides: + +- **Version-controlled schema changes** - Track every database modification +- **Automatic migration generation** - Generate migrations from model changes +- **Reversible migrations** - Upgrade and downgrade database versions +- **Environment-specific configurations** - Different settings for dev/staging/production +- **Safe schema evolution** - Apply changes incrementally + +## Simple Setup: Automatic Table Creation + +For simple projects or development, the boilerplate includes `create_tables_on_start` parameter that automatically creates all tables on application startup: + +```python +# This is enabled by default in create_application() +app = create_application( + router=router, + settings=settings, + create_tables_on_start=True # Default: True +) +``` + +**When to use:** + +- โœ… **Development** - Quick setup without migration management +- โœ… **Simple projects** - When you don't need migration history +- โœ… **Prototyping** - Fast iteration without migration complexity +- โœ… **Testing** - Clean database state for each test run + +**When NOT to use:** + +- โŒ **Production** - No migration history or rollback capability +- โŒ **Team development** - Can't track schema changes between developers +- โŒ **Data migrations** - Only handles schema, not data transformations +- โŒ **Complex deployments** - No control over when/how schema changes apply + +```python +# Disable for production environments +app = create_application( + router=router, + settings=settings, + create_tables_on_start=False # Use migrations instead +) +``` + +For production deployments and team development, use proper Alembic migrations as described below. + +## Configuration + +### Alembic Setup + +Alembic is configured in `src/alembic.ini`: + +```ini +[alembic] +# Path to migration files +script_location = migrations + +# Database URL with environment variable substitution +sqlalchemy.url = postgresql://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_SERVER)s:%(POSTGRES_PORT)s/%(POSTGRES_DB)s + +# Other configurations +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s +timezone = UTC +``` + +### Environment Configuration + +Migration environment is configured in `src/migrations/env.py`: + +```python +# src/migrations/env.py +from alembic import context +from sqlalchemy import engine_from_config, pool +from app.core.db.database import Base +from app.core.config import settings + +# Import all models to ensure they're registered +from app.models import * # This imports all models + +config = context.config + +# Override database URL from environment +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +target_metadata = Base.metadata +``` + +## Migration Workflow + +### 1. Creating Migrations + +Generate migrations automatically when you change models: + +```bash +# Navigate to src directory +cd src + +# Generate migration from model changes +uv run alembic revision --autogenerate -m "Add user profile fields" +``` + +**What happens:** +- Alembic compares current models with database schema +- Generates a new migration file in `src/migrations/versions/` +- Migration includes upgrade and downgrade functions + +### 2. Review Generated Migration + +Always review auto-generated migrations before applying: + +```python +# Example migration file: src/migrations/versions/20241215_1430_add_user_profile_fields.py +"""Add user profile fields + +Revision ID: abc123def456 +Revises: previous_revision_id +Create Date: 2024-12-15 14:30:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = 'abc123def456' +down_revision = 'previous_revision_id' +branch_labels = None +depends_on = None + +def upgrade() -> None: + # Add new columns + op.add_column('user', sa.Column('bio', sa.String(500), nullable=True)) + op.add_column('user', sa.Column('website', sa.String(255), nullable=True)) + + # Create index + op.create_index('ix_user_website', 'user', ['website']) + +def downgrade() -> None: + # Remove changes (reverse order) + op.drop_index('ix_user_website', 'user') + op.drop_column('user', 'website') + op.drop_column('user', 'bio') +``` + +### 3. Apply Migration + +Apply migrations to update database schema: + +```bash +# Apply all pending migrations +uv run alembic upgrade head + +# Apply specific number of migrations +uv run alembic upgrade +2 + +# Apply to specific revision +uv run alembic upgrade abc123def456 +``` + +### 4. Verify Migration + +Check migration status and current version: + +```bash +# Show current database version +uv run alembic current + +# Show migration history +uv run alembic history + +# Show pending migrations +uv run alembic show head +``` + +## Common Migration Scenarios + +### Adding New Model + +1. **Create the model** in `src/app/models/`: + +```python +# src/app/models/category.py +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime +from app.core.db.database import Base + +class Category(Base): + __tablename__ = "category" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, init=False) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + slug: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + description: Mapped[str] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) +``` + +2. **Import in __init__.py**: + +```python +# src/app/models/__init__.py +from .user import User +from .post import Post +from .tier import Tier +from .rate_limit import RateLimit +from .category import Category # Add new import +``` + +3. **Generate migration**: + +```bash +uv run alembic revision --autogenerate -m "Add category model" +``` + +### Adding Foreign Key + +1. **Update model with foreign key**: + +```python +# Add to Post model +category_id: Mapped[Optional[int]] = mapped_column(ForeignKey("category.id"), nullable=True) +``` + +2. **Generate migration**: + +```bash +uv run alembic revision --autogenerate -m "Add category_id to posts" +``` + +3. **Review and apply**: + +```python +# Generated migration will include: +def upgrade() -> None: + op.add_column('post', sa.Column('category_id', sa.Integer(), nullable=True)) + op.create_foreign_key('fk_post_category_id', 'post', 'category', ['category_id'], ['id']) + op.create_index('ix_post_category_id', 'post', ['category_id']) +``` + +### Data Migrations + +Sometimes you need to migrate data, not just schema: + +```python +# Example: Populate default category for existing posts +def upgrade() -> None: + # Add the column + op.add_column('post', sa.Column('category_id', sa.Integer(), nullable=True)) + + # Data migration + connection = op.get_bind() + + # Create default category + connection.execute( + "INSERT INTO category (name, slug, description) VALUES ('General', 'general', 'Default category')" + ) + + # Get default category ID + result = connection.execute("SELECT id FROM category WHERE slug = 'general'") + default_category_id = result.fetchone()[0] + + # Update existing posts + connection.execute( + f"UPDATE post SET category_id = {default_category_id} WHERE category_id IS NULL" + ) + + # Make column non-nullable after data migration + op.alter_column('post', 'category_id', nullable=False) +``` + +### Renaming Columns + +```python +def upgrade() -> None: + # Rename column + op.alter_column('user', 'full_name', new_column_name='name') + +def downgrade() -> None: + # Reverse the rename + op.alter_column('user', 'name', new_column_name='full_name') +``` + +### Dropping Tables + +```python +def upgrade() -> None: + # Drop table (be careful!) + op.drop_table('old_table') + +def downgrade() -> None: + # Recreate table structure + op.create_table('old_table', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(50), nullable=True), + sa.PrimaryKeyConstraint('id') + ) +``` + +## Production Migration Strategy + +### 1. Development Workflow + +```bash +# 1. Make model changes +# 2. Generate migration +uv run alembic revision --autogenerate -m "Descriptive message" + +# 3. Review migration file +# 4. Test migration +uv run alembic upgrade head + +# 5. Test downgrade (optional) +uv run alembic downgrade -1 +uv run alembic upgrade head +``` + +### 2. Staging Deployment + +```bash +# 1. Deploy code with migrations +# 2. Backup database +pg_dump -h staging-db -U user dbname > backup_$(date +%Y%m%d_%H%M%S).sql + +# 3. Apply migrations +uv run alembic upgrade head + +# 4. Verify application works +# 5. Run tests +``` + +### 3. Production Deployment + +```bash +# 1. Schedule maintenance window +# 2. Create database backup +pg_dump -h prod-db -U user dbname > prod_backup_$(date +%Y%m%d_%H%M%S).sql + +# 3. Apply migrations (with monitoring) +uv run alembic upgrade head + +# 4. Verify health checks pass +# 5. Monitor application metrics +``` + +## Docker Considerations + +### Development with Docker Compose + +For local development, migrations run automatically: + +```yaml +# docker-compose.yml +services: + web: + # ... other config + depends_on: + - db + command: | + sh -c " + uv run alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + " +``` + +### Production Docker + +In production, run migrations separately: + +```dockerfile +# Dockerfile migration stage +FROM python:3.11-slim as migration +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY src/ /app/ +WORKDIR /app +CMD ["alembic", "upgrade", "head"] +``` + +```yaml +# docker-compose.prod.yml +services: + migrate: + build: + context: . + target: migration + env_file: + - .env + depends_on: + - db + command: alembic upgrade head + + web: + # ... web service config + depends_on: + - migrate +``` + +## Migration Best Practices + +### 1. Always Review Generated Migrations + +```python +# Check for issues like: +# - Missing imports +# - Incorrect nullable settings +# - Missing indexes +# - Data loss operations +``` + +### 2. Use Descriptive Messages + +```bash +# Good +uv run alembic revision --autogenerate -m "Add user email verification fields" + +# Bad +uv run alembic revision --autogenerate -m "Update user model" +``` + +### 3. Handle Nullable Columns Carefully + +```python +# When adding non-nullable columns to existing tables: +def upgrade() -> None: + # 1. Add as nullable first + op.add_column('user', sa.Column('phone', sa.String(20), nullable=True)) + + # 2. Populate with default data + op.execute("UPDATE user SET phone = '' WHERE phone IS NULL") + + # 3. Make non-nullable + op.alter_column('user', 'phone', nullable=False) +``` + +### 4. Test Rollbacks + +```bash +# Test that your downgrade works +uv run alembic downgrade -1 +uv run alembic upgrade head +``` + +### 5. Use Transactions for Complex Migrations + +```python +def upgrade() -> None: + # Complex migration with transaction + connection = op.get_bind() + trans = connection.begin() + try: + # Multiple operations + op.create_table(...) + op.add_column(...) + connection.execute("UPDATE ...") + trans.commit() + except: + trans.rollback() + raise +``` + +## Next Steps + +- **[CRUD Operations](crud.md)** - Working with migrated database schema +- **[API Development](../api/index.md)** - Building endpoints for your models +- **[Testing](../testing.md)** - Testing database migrations \ No newline at end of file diff --git a/docs/user-guide/database/models.md b/docs/user-guide/database/models.md new file mode 100644 index 0000000..beea8b2 --- /dev/null +++ b/docs/user-guide/database/models.md @@ -0,0 +1,484 @@ +# Database Models + +This section explains how SQLAlchemy models are implemented in the boilerplate, how to create new models, and the patterns used for relationships, validation, and data integrity. + +## Model Structure + +Models are defined in `src/app/models/` using SQLAlchemy 2.0's declarative syntax with `Mapped` type annotations. + +### Base Model + +All models inherit from `Base` defined in `src/app/core/db/database.py`: + +```python +from sqlalchemy.orm import DeclarativeBase + +class Base(DeclarativeBase): + pass +``` + +**SQLAlchemy 2.0 Change**: Uses `DeclarativeBase` instead of the older `declarative_base()` function. This provides better type checking and IDE support. + +### Model File Structure + +Each model is in its own file: + +```text +src/app/models/ +โ”œโ”€โ”€ __init__.py # Imports all models for Alembic discovery +โ”œโ”€โ”€ user.py # User authentication model +โ”œโ”€โ”€ post.py # Example content model with relationships +โ”œโ”€โ”€ tier.py # User subscription tiers +โ””โ”€โ”€ rate_limit.py # API rate limiting configuration +``` + +**Import Requirement**: Models must be imported in `__init__.py` for Alembic to detect them during migration generation. + +## Design Decision: No SQLAlchemy Relationships + +The boilerplate deliberately avoids using SQLAlchemy's `relationship()` feature. This is an intentional architectural choice with specific benefits. + +### Why No Relationships + +**Performance Concerns**: + +- **N+1 Query Problem**: Relationships can trigger multiple queries when accessing related data +- **Lazy Loading**: Unpredictable when queries execute, making performance optimization difficult +- **Memory Usage**: Loading large object graphs consumes significant memory + +**Code Clarity**: + +- **Explicit Data Fetching**: Developers see exactly what data is being loaded and when +- **Predictable Queries**: No "magic" queries triggered by attribute access +- **Easier Debugging**: SQL queries are explicit in the code, not hidden in relationship configuration + +**Flexibility**: + +- **Query Optimization**: Can optimize each query for its specific use case +- **Selective Loading**: Load only the fields needed for each operation +- **Join Control**: Use FastCRUD's join methods when needed, skip when not + +### What This Means in Practice + +Instead of this (traditional SQLAlchemy): +```python +# Not used in the boilerplate +class User(Base): + posts: Mapped[List["Post"]] = relationship("Post", back_populates="created_by_user") + +class Post(Base): + created_by_user: Mapped["User"] = relationship("User", back_populates="posts") +``` + +The boilerplate uses this approach: +```python +# DO - Explicit and controlled +class User(Base): + # Only foreign key, no relationship + tier_id: Mapped[int | None] = mapped_column(ForeignKey("tier.id"), index=True, default=None) + +class Post(Base): + # Only foreign key, no relationship + created_by_user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True) + +# Explicit queries - you control exactly what's loaded +user = await crud_users.get(db=db, id=1) +posts = await crud_posts.get_multi(db=db, created_by_user_id=user.id) + +# Or use joins when needed +posts_with_users = await crud_posts.get_multi_joined( + db=db, + join_model=User, + schema_to_select=PostRead, + join_schema_to_select=UserRead +) +``` + +### Benefits of This Approach + +**Predictable Performance**: + +- Every database query is explicit in the code +- No surprise queries from accessing relationships +- Easier to identify and optimize slow operations + +**Better Caching**: + +- Can cache individual models without worrying about related data +- Cache invalidation is simpler and more predictable + +**API Design**: + +- Forces thinking about what data clients actually need +- Prevents over-fetching in API responses +- Encourages lean, focused endpoints + +**Testing**: + +- Easier to mock database operations +- No complex relationship setup in test fixtures +- More predictable test data requirements + +### When You Need Related Data + +Use FastCRUD's join capabilities: + +```python +# Single record with related data +post_with_author = await crud_posts.get_joined( + db=db, + join_model=User, + schema_to_select=PostRead, + join_schema_to_select=UserRead, + id=post_id +) + +# Multiple records with joins +posts_with_authors = await crud_posts.get_multi_joined( + db=db, + join_model=User, + offset=0, + limit=10 +) +``` + +### Alternative Approaches + +If you need relationships in your project, you can add them: + +```python +# Add relationships if needed for your use case +from sqlalchemy.orm import relationship + +class User(Base): + # ... existing fields ... + posts: Mapped[List["Post"]] = relationship("Post", back_populates="created_by_user") + +class Post(Base): + # ... existing fields ... + created_by_user: Mapped["User"] = relationship("User", back_populates="posts") +``` + +But consider the trade-offs and whether explicit queries might be better for your use case. + +## User Model Implementation + +The User model (`src/app/models/user.py`) demonstrates authentication patterns: + +```python +import uuid as uuid_pkg +from datetime import UTC, datetime +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column +from ..core.db.database import Base + +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column("id", autoincrement=True, nullable=False, unique=True, primary_key=True, init=False) + + # User data + name: Mapped[str] = mapped_column(String(30)) + username: Mapped[str] = mapped_column(String(20), unique=True, index=True) + email: Mapped[str] = mapped_column(String(50), unique=True, index=True) + hashed_password: Mapped[str] = mapped_column(String) + + # Profile + profile_image_url: Mapped[str] = mapped_column(String, default="https://profileimageurl.com") + + # UUID for external references + uuid: Mapped[uuid_pkg.UUID] = mapped_column(default_factory=uuid_pkg.uuid4, primary_key=True, unique=True) + + # Timestamps + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default_factory=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + + # Status flags + is_deleted: Mapped[bool] = mapped_column(default=False, index=True) + is_superuser: Mapped[bool] = mapped_column(default=False) + + # Foreign key to tier system (no relationship defined) + tier_id: Mapped[int | None] = mapped_column(ForeignKey("tier.id"), index=True, default=None, init=False) +``` + +### Key Implementation Details + +**Type Annotations**: `Mapped[type]` provides type hints for SQLAlchemy 2.0. IDE and mypy can validate types. + +**String Lengths**: Explicit lengths (`String(50)`) prevent database errors and define constraints clearly. + +**Nullable Fields**: Explicitly set `nullable=False` for required fields, `nullable=True` for optional ones. + +**Default Values**: Use `default=` for database-level defaults, Python functions for computed defaults. + +## Post Model with Relationships + +The Post model (`src/app/models/post.py`) shows relationships and soft deletion: + +```python +import uuid as uuid_pkg +from datetime import UTC, datetime +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column +from ..core.db.database import Base + +class Post(Base): + __tablename__ = "post" + + id: Mapped[int] = mapped_column("id", autoincrement=True, nullable=False, unique=True, primary_key=True, init=False) + + # Content + title: Mapped[str] = mapped_column(String(30)) + text: Mapped[str] = mapped_column(String(63206)) # Large text field + media_url: Mapped[str | None] = mapped_column(String, default=None) + + # UUID for external references + uuid: Mapped[uuid_pkg.UUID] = mapped_column(default_factory=uuid_pkg.uuid4, primary_key=True, unique=True) + + # Foreign key (no relationship defined) + created_by_user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True) + + # Timestamps (built-in soft delete pattern) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default_factory=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + is_deleted: Mapped[bool] = mapped_column(default=False, index=True) +``` + +### Soft Deletion Pattern + +Soft deletion is built directly into models: + +```python +# Built into each model that needs soft deletes +class Post(Base): + # ... other fields ... + + # Soft delete fields + is_deleted: Mapped[bool] = mapped_column(default=False, index=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) +``` + +**Usage**: When `crud_posts.delete()` is called, it sets `is_deleted=True` and `deleted_at=datetime.now(UTC)` instead of removing the database row. + +## Tier and Rate Limiting Models + +### Tier Model + +```python +# src/app/models/tier.py +class Tier(Base): + __tablename__ = "tier" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, init=False) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) +``` + +### Rate Limit Model + +```python +# src/app/models/rate_limit.py +class RateLimit(Base): + __tablename__ = "rate_limit" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, init=False) + tier_id: Mapped[int] = mapped_column(ForeignKey("tier.id"), nullable=False) + path: Mapped[str] = mapped_column(String(255), nullable=False) + limit: Mapped[int] = mapped_column(nullable=False) # requests allowed + period: Mapped[int] = mapped_column(nullable=False) # time period in seconds + name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) +``` + +**Purpose**: Links API endpoints (`path`) to rate limits (`limit` requests per `period` seconds) for specific user tiers. + +## Creating New Models + +### Step-by-Step Process + +1. **Create model file** in `src/app/models/your_model.py` +2. **Define model class** inheriting from `Base` +3. **Add to imports** in `src/app/models/__init__.py` +4. **Generate migration** with `alembic revision --autogenerate` +5. **Apply migration** with `alembic upgrade head` + +### Example: Creating a Category Model + +```python +# src/app/models/category.py +from datetime import datetime +from typing import List +from sqlalchemy import String, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.core.db.database import Base + +class Category(Base): + __tablename__ = "category" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, init=False) + name: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + description: Mapped[str] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) +``` + +If you want to relate Category to Post, just add the id reference in the model: + +```python +class Post(Base): + __tablename__ = "post" + ... + + # Foreign key (no relationship defined) + category_id: Mapped[int] = mapped_column(ForeignKey("category.id"), index=True) +``` + +### Import in __init__.py + +```python +# src/app/models/__init__.py +from .user import User +from .post import Post +from .tier import Tier +from .rate_limit import RateLimit +from .category import Category # Add new model +``` + +**Critical**: Without this import, Alembic won't detect the model for migrations. + +## Model Validation and Constraints + +### Database-Level Constraints + +```python +from sqlalchemy import CheckConstraint, Index + +class Product(Base): + __tablename__ = "product" + + price: Mapped[float] = mapped_column(nullable=False) + quantity: Mapped[int] = mapped_column(nullable=False) + + # Table-level constraints + __table_args__ = ( + CheckConstraint('price > 0', name='positive_price'), + CheckConstraint('quantity >= 0', name='non_negative_quantity'), + Index('idx_product_price', 'price'), + ) +``` + +### Unique Constraints + +```python +# Single column unique +email: Mapped[str] = mapped_column(String(100), unique=True) + +# Multi-column unique constraint +__table_args__ = ( + UniqueConstraint('user_id', 'category_id', name='unique_user_category'), +) +``` + +## Common Model Patterns + +### Timestamp Tracking + +```python +class TimestampedModel: + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False + ) + +# Use as mixin +class Post(Base, TimestampedModel, SoftDeleteMixin): + # Model automatically gets created_at, updated_at, is_deleted, deleted_at + __tablename__ = "post" + id: Mapped[int] = mapped_column(primary_key=True) +``` + +### Enumeration Fields + +```python +from enum import Enum +from sqlalchemy import Enum as SQLEnum + +class UserStatus(Enum): + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + +class User(Base): + status: Mapped[UserStatus] = mapped_column(SQLEnum(UserStatus), default=UserStatus.ACTIVE) +``` + +### JSON Fields + +```python +from sqlalchemy.dialects.postgresql import JSONB + +class UserProfile(Base): + preferences: Mapped[dict] = mapped_column(JSONB, nullable=True) + metadata: Mapped[dict] = mapped_column(JSONB, default=lambda: {}) +``` + +**PostgreSQL-specific**: Uses JSONB for efficient JSON storage and querying. + +## Model Testing + +### Basic Model Tests + +```python +# tests/test_models.py +import pytest +from sqlalchemy.exc import IntegrityError +from app.models.user import User + +def test_user_creation(): + user = User( + username="testuser", + email="test@example.com", + hashed_password="hashed123" + ) + assert user.username == "testuser" + assert user.is_active is True # Default value + +def test_user_unique_constraint(): + # Test that duplicate emails raise IntegrityError + with pytest.raises(IntegrityError): + # Create users with same email + pass +``` + +## Migration Considerations + +### Backwards Compatible Changes + +Safe changes that don't break existing code: + +- Adding nullable columns +- Adding new tables +- Adding indexes +- Increasing column lengths + +### Breaking Changes + +Changes requiring careful migration: + +- Making columns non-nullable +- Removing columns +- Changing column types +- Removing tables + +## Next Steps + +Now that you understand model implementation: + +1. **[Schemas](schemas.md)** - Learn Pydantic validation and serialization +2. **[CRUD Operations](crud.md)** - Implement database operations with FastCRUD +3. **[Migrations](migrations.md)** - Manage schema changes with Alembic + +The next section covers how Pydantic schemas provide validation and API contracts separate from database models. \ No newline at end of file diff --git a/docs/user-guide/database/schemas.md b/docs/user-guide/database/schemas.md new file mode 100644 index 0000000..69c7bb2 --- /dev/null +++ b/docs/user-guide/database/schemas.md @@ -0,0 +1,650 @@ +# Database Schemas + +This section explains how Pydantic schemas handle data validation, serialization, and API contracts in the boilerplate. Schemas are separate from SQLAlchemy models and define what data enters and exits your API. + +## Schema Purpose and Structure + +Schemas serve three main purposes: + +1. **Input Validation** - Validate incoming API request data +2. **Output Serialization** - Format database data for API responses +3. **API Contracts** - Define clear interfaces between frontend and backend + +### Schema File Organization + +Schemas are organized in `src/app/schemas/` with one file per model: + +```text +src/app/schemas/ +โ”œโ”€โ”€ __init__.py # Imports for easy access +โ”œโ”€โ”€ user.py # User-related schemas +โ”œโ”€โ”€ post.py # Post-related schemas +โ”œโ”€โ”€ tier.py # Tier schemas +โ”œโ”€โ”€ rate_limit.py # Rate limit schemas +โ””โ”€โ”€ job.py # Background job schemas +``` + +## User Schema Implementation + +The User schemas (`src/app/schemas/user.py`) demonstrate common validation patterns: + +```python +from datetime import datetime +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from ..core.schemas import PersistentDeletion, TimestampSchema, UUIDSchema + + +# Base schema with common fields +class UserBase(BaseModel): + name: Annotated[ + str, + Field( + min_length=2, + max_length=30, + examples=["User Userson"] + ) + ] + username: Annotated[ + str, + Field( + min_length=2, + max_length=20, + pattern=r"^[a-z0-9]+$", + examples=["userson"] + ) + ] + email: Annotated[EmailStr, Field(examples=["user.userson@example.com"])] + + +# Full User data +class User(TimestampSchema, UserBase, UUIDSchema, PersistentDeletion): + profile_image_url: Annotated[ + str, + Field(default="https://www.profileimageurl.com") + ] + hashed_password: str + is_superuser: bool = False + tier_id: int | None = None + + +# Schema for reading user data (API output) +class UserRead(BaseModel): + id: int + + name: Annotated[ + str, + Field( + min_length=2, + max_length=30, + examples=["User Userson"] + ) + ] + username: Annotated[ + str, + Field( + min_length=2, + max_length=20, + pattern=r"^[a-z0-9]+$", + examples=["userson"] + ) + ] + email: Annotated[EmailStr, Field(examples=["user.userson@example.com"])] + profile_image_url: str + tier_id: int | None + + +# Schema for creating new users (API input) +class UserCreate(UserBase): # Inherits from UserBase + model_config = ConfigDict(extra="forbid") + + password: Annotated[ + str, + Field( + pattern=r"^.{8,}|[0-9]+|[A-Z]+|[a-z]+|[^a-zA-Z0-9]+$", + examples=["Str1ngst!"] + ) + ] + + +# Schema that FastCRUD will use to store just the hash +class UserCreateInternal(UserBase): + hashed_password: str + + +# Schema for updating users +class UserUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Annotated[ + str | None, + Field( + min_length=2, + max_length=30, + examples=["User Userberg"], + default=None + ) + ] + username: Annotated[ + str | None, + Field( + min_length=2, + max_length=20, + pattern=r"^[a-z0-9]+$", + examples=["userberg"], + default=None + ) + ] + email: Annotated[ + EmailStr | None, + Field( + examples=["user.userberg@example.com"], + default=None + ) + ] + profile_image_url: Annotated[ + str | None, + Field( + pattern=r"^(https?|ftp)://[^\s/$.?#].[^\s]*$", + examples=["https://www.profileimageurl.com"], + default=None + ), + ] + + +# Internal update schema +class UserUpdateInternal(UserUpdate): + updated_at: datetime + + +# Schema to update tier id +class UserTierUpdate(BaseModel): + tier_id: int + + +# Schema for user deletion (soft delete timestamps) +class UserDelete(BaseModel): + model_config = ConfigDict(extra="forbid") + + is_deleted: bool + deleted_at: datetime + + +# User specific schema +class UserRestoreDeleted(BaseModel): + is_deleted: bool +``` + +### Key Implementation Details + +**Field Validation**: Uses `Annotated[type, Field(...)]` for validation rules. `Field` parameters include: + +- `min_length/max_length` - String length constraints +- `gt/ge/lt/le` - Numeric constraints +- `pattern` - Pattern matching (regex) +- `default` - Default values + +**EmailStr**: Validates email format and normalizes the value. + +**ConfigDict**: Replaces the old `Config` class. `from_attributes=True` allows creating schemas from SQLAlchemy model instances. + +**Internal vs External**: Separate schemas for internal operations (like password hashing) vs API exposure. + +## Schema Patterns + +### Base Schema Pattern + +```python +# Common fields shared across operations +class PostBase(BaseModel): + title: Annotated[ + str, + Field( + min_length=1, + max_length=100 + ) + ] + content: Annotated[ + str, + Field( + min_length=1, + max_length=10000 + ) + ] + +# Specific operation schemas inherit from base +class PostCreate(PostBase): + pass # Only title and content needed for creation + +class PostRead(PostBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + created_by_user_id: int + is_deleted: bool = False # From model's soft delete fields +``` + +**Purpose**: Reduces duplication and ensures consistency across related schemas. + +### Optional Fields in Updates + +```python +class PostUpdate(BaseModel): + title: Annotated[ + str | None, + Field( + min_length=1, + max_length=100, + default=None + ) + ] + content: Annotated[ + str | None, + Field( + min_length=1, + max_length=10000, + default=None + ) + ] +``` + +**Pattern**: All fields optional in update schemas. Only provided fields are updated in the database. + +### Nested Schemas + +```python +# Post schema with user information +class PostWithUser(PostRead): + created_by_user: UserRead # Nested user data + +# Alternative: Custom nested schema +class PostAuthor(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + # Only include fields needed for this context + +class PostRead(PostBase): + created_by_user: PostAuthor +``` + +**Usage**: Include related model data in responses without exposing all fields. + +## Validation Patterns + +### Custom Validators + +```python +from pydantic import field_validator, model_validator + +class UserCreateWithConfirm(UserBase): + password: str + confirm_password: str + + @field_validator('username') + @classmethod + def validate_username(cls, v): + if v.lower() in ['admin', 'root', 'system']: + raise ValueError('Username not allowed') + return v.lower() # Normalize to lowercase + + @model_validator(mode='after') + def validate_passwords_match(self): + if self.password != self.confirm_password: + raise ValueError('Passwords do not match') + return self +``` + +**field_validator**: Validates individual fields. Can transform values. + +**model_validator**: Validates across multiple fields. Access to full model data. + +### Computed Fields + +```python +from pydantic import computed_field + +class UserReadWithComputed(UserRead): + created_at: datetime # Would need to be added to actual UserRead + + @computed_field + @property + def age_days(self) -> int: + return (datetime.utcnow() - self.created_at).days + + @computed_field + @property + def display_name(self) -> str: + return f"@{self.username}" +``` + +**Purpose**: Add computed values to API responses without storing them in the database. + +### Conditional Validation + +```python +class PostCreate(BaseModel): + title: str + content: str + category: Optional[str] = None + is_premium: bool = False + + @model_validator(mode='after') + def validate_premium_content(self): + if self.is_premium and not self.category: + raise ValueError('Premium posts must have a category') + return self +``` + +## Schema Configuration + +### Model Config Options + +```python +class UserRead(BaseModel): + model_config = ConfigDict( + from_attributes=True, # Allow creation from SQLAlchemy models + extra="forbid", # Reject extra fields + str_strip_whitespace=True, # Strip whitespace from strings + validate_assignment=True, # Validate on field assignment + populate_by_name=True, # Allow field names and aliases + ) +``` + +### Field Aliases + +```python +class UserResponse(BaseModel): + user_id: Annotated[ + int, + Field(alias="id") + ] + username: str + email_address: Annotated[ + str, + Field(alias="email") + ] + + model_config = ConfigDict(populate_by_name=True) +``` + +**Usage**: API can accept both `id` and `user_id`, `email` and `email_address`. + +## Response Schema Patterns + +### Multi-Record Responses + +[FastCRUD's](https://benavlabs.github.io/fastcrud/) `get_multi` method returns a `GetMultiResponse`: + +```python +# Using get_multi directly +users = await crud_users.get_multi( + db=db, + offset=0, + limit=10, + schema_to_select=UserRead, + return_as_model=True, + return_total_count=True +) +# Returns GetMultiResponse structure: +# { +# "data": [UserRead, ...], +# "total_count": 150 +# } +``` + +### Paginated Responses + +For pagination with page numbers, use `PaginatedListResponse`: + +```python +from fastcrud.paginated import PaginatedListResponse + +# In API endpoint - ONLY for paginated list responses +@router.get("/users/", response_model=PaginatedListResponse[UserRead]) +async def get_users(page: int = 1, items_per_page: int = 10): + # Returns paginated structure with additional pagination fields: + # { + # "data": [UserRead, ...], + # "total_count": 150, + # "has_more": true, + # "page": 1, + # "items_per_page": 10 + # } + +# Single user endpoints return UserRead directly +@router.get("/users/{user_id}", response_model=UserRead) +async def get_user(user_id: int): + # Returns single UserRead object: + # { + # "id": 1, + # "name": "User Userson", + # "username": "userson", + # "email": "user.userson@example.com", + # "profile_image_url": "https://...", + # "tier_id": null + # } +``` + +### Error Response Schemas + +```python +class ErrorResponse(BaseModel): + detail: str + error_code: Optional[str] = None + +class ValidationErrorResponse(BaseModel): + detail: str + errors: list[dict] # Pydantic validation errors +``` + +### Success Response Wrapper + +```python +from typing import Generic, TypeVar + +T = TypeVar('T') + +class SuccessResponse(BaseModel, Generic[T]): + success: bool = True + data: T + message: Optional[str] = None + +# Usage in endpoint +@router.post("/users/", response_model=SuccessResponse[UserRead]) +async def create_user(user_data: UserCreate): + user = await crud_users.create(db=db, object=user_data) + return SuccessResponse(data=user, message="User created successfully") +``` + +## Creating New Schemas + +### Step-by-Step Process + +1. **Create schema file** in `src/app/schemas/your_model.py` +2. **Define base schema** with common fields +3. **Create operation-specific schemas** (Create, Read, Update, Delete) +4. **Add validation rules** as needed +5. **Import in __init__.py** for easy access + +### Example: Category Schemas + +```python +# src/app/schemas/category.py +from datetime import datetime +from typing import Annotated +from pydantic import BaseModel, Field, ConfigDict + +class CategoryBase(BaseModel): + name: Annotated[ + str, + Field( + min_length=1, + max_length=50 + ) + ] + description: Annotated[ + str | None, + Field( + max_length=255, + default=None + ) + ] + +class CategoryCreate(CategoryBase): + pass + +class CategoryRead(CategoryBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + +class CategoryUpdate(BaseModel): + name: Annotated[ + str | None, + Field( + min_length=1, + max_length=50, + default=None + ) + ] + description: Annotated[ + str | None, + Field( + max_length=255, + default=None + ) + ] + +class CategoryWithPosts(CategoryRead): + posts: list[PostRead] = [] # Include related posts +``` + +### Import in __init__.py + +```python +# src/app/schemas/__init__.py +from .user import UserCreate, UserRead, UserUpdate +from .post import PostCreate, PostRead, PostUpdate +from .category import CategoryCreate, CategoryRead, CategoryUpdate +``` + +## Schema Testing + +### Validation Testing + +```python +# tests/test_schemas.py +import pytest +from pydantic import ValidationError +from app.schemas.user import UserCreate + +def test_user_create_valid(): + user_data = { + "name": "Test User", + "username": "testuser", + "email": "test@example.com", + "password": "Str1ngst!" + } + user = UserCreate(**user_data) + assert user.username == "testuser" + assert user.name == "Test User" + +def test_user_create_invalid_email(): + with pytest.raises(ValidationError) as exc_info: + UserCreate( + name="Test User", + username="test", + email="invalid-email", + password="Str1ngst!" + ) + + errors = exc_info.value.errors() + assert any(error['type'] == 'value_error' for error in errors) + +def test_password_validation(): + with pytest.raises(ValidationError) as exc_info: + UserCreate( + name="Test User", + username="test", + email="test@example.com", + password="123" # Doesn't match pattern + ) +``` + +### Serialization Testing + +```python +from app.models.user import User +from app.schemas.user import UserRead + +def test_user_read_from_model(): + # Create model instance + user_model = User( + id=1, + name="Test User", + username="testuser", + email="test@example.com", + profile_image_url="https://example.com/image.jpg", + hashed_password="hashed123", + is_superuser=False, + tier_id=None, + created_at=datetime.utcnow() + ) + + # Convert to schema + user_schema = UserRead.model_validate(user_model) + assert user_schema.username == "testuser" + assert user_schema.id == 1 + assert user_schema.name == "Test User" + # hashed_password not included in UserRead +``` + +## Common Pitfalls + +### Model vs Schema Field Names + +```python +# DON'T - Exposing sensitive fields +class UserRead(BaseModel): + hashed_password: str # Never expose password hashes + +# DO - Only expose safe fields +class UserRead(BaseModel): + id: int + name: str + username: str + email: str + profile_image_url: str + tier_id: int | None +``` + +### Validation Performance + +```python +# DON'T - Complex validation in every request +@field_validator('email') +@classmethod +def validate_email_unique(cls, v): + # Database query in validator - slow! + if crud_users.exists(email=v): + raise ValueError('Email already exists') + +# DO - Handle uniqueness in business logic +# Let database unique constraints handle this +``` + +## Next Steps + +Now that you understand schema implementation: + +1. **[CRUD Operations](crud.md)** - Learn how schemas integrate with database operations +2. **[Migrations](migrations.md)** - Manage database schema changes +3. **[API Endpoints](../api/endpoints.md)** - Use schemas in FastAPI endpoints + +The next section covers CRUD operations and how they use these schemas for data validation and transformation. \ No newline at end of file diff --git a/docs/user-guide/development.md b/docs/user-guide/development.md new file mode 100644 index 0000000..b54f245 --- /dev/null +++ b/docs/user-guide/development.md @@ -0,0 +1,717 @@ +# Development Guide + +This guide covers everything you need to know about extending, customizing, and developing with the FastAPI boilerplate. + +## Extending the Boilerplate + +### Adding New Models + +Follow this step-by-step process to add new entities to your application: + +#### 1. Create SQLAlchemy Model + +Create a new file in `src/app/models/` (e.g., `category.py`): + +```python +from sqlalchemy import String, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ..core.db.database import Base + + +class Category(Base): + __tablename__ = "category" + + id: Mapped[int] = mapped_column( + "id", + autoincrement=True, + nullable=False, + unique=True, + primary_key=True, + init=False + ) + name: Mapped[str] = mapped_column(String(50)) + description: Mapped[str | None] = mapped_column(String(255), default=None) + + # Relationships + posts: Mapped[list["Post"]] = relationship(back_populates="category") +``` + +#### 2. Create Pydantic Schemas + +Create `src/app/schemas/category.py`: + +```python +from datetime import datetime +from typing import Annotated +from pydantic import BaseModel, Field, ConfigDict + + +class CategoryBase(BaseModel): + name: Annotated[str, Field(min_length=1, max_length=50)] + description: Annotated[str | None, Field(max_length=255, default=None)] + + +class CategoryCreate(CategoryBase): + model_config = ConfigDict(extra="forbid") + + +class CategoryRead(CategoryBase): + model_config = ConfigDict(from_attributes=True) + + id: int + created_at: datetime + + +class CategoryUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Annotated[str | None, Field(min_length=1, max_length=50, default=None)] + description: Annotated[str | None, Field(max_length=255, default=None)] + + +class CategoryUpdateInternal(CategoryUpdate): + updated_at: datetime + + +class CategoryDelete(BaseModel): + model_config = ConfigDict(extra="forbid") + + is_deleted: bool + deleted_at: datetime +``` + +#### 3. Create CRUD Operations + +Create `src/app/crud/crud_categories.py`: + +```python +from fastcrud import FastCRUD + +from ..models.category import Category +from ..schemas.category import CategoryCreate, CategoryUpdate, CategoryUpdateInternal, CategoryDelete + +CRUDCategory = FastCRUD[Category, CategoryCreate, CategoryUpdate, CategoryUpdateInternal, CategoryDelete] +crud_categories = CRUDCategory(Category) +``` + +#### 4. Update Model Imports + +Add your new model to `src/app/models/__init__.py`: + +```python +from .category import Category +from .user import User +from .post import Post +# ... other imports +``` + +#### 5. Create Database Migration + +Generate and apply the migration: + +```bash +# From the src/ directory +uv run alembic revision --autogenerate -m "Add category model" +uv run alembic upgrade head +``` + +#### 6. Create API Endpoints + +Create `src/app/api/v1/categories.py`: + +```python +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request +from fastcrud.paginated import PaginatedListResponse, compute_offset +from sqlalchemy.ext.asyncio import AsyncSession + +from ...api.dependencies import get_current_superuser, get_current_user +from ...core.db.database import async_get_db +from ...core.exceptions.http_exceptions import DuplicateValueException, NotFoundException +from ...crud.crud_categories import crud_categories +from ...schemas.category import CategoryCreate, CategoryRead, CategoryUpdate + +router = APIRouter(tags=["categories"]) + + +@router.post("/category", response_model=CategoryRead, status_code=201) +async def write_category( + request: Request, + category: CategoryCreate, + current_user: Annotated[dict, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(async_get_db)], +): + category_row = await crud_categories.exists(db=db, name=category.name) + if category_row: + raise DuplicateValueException("Category name already exists") + + return await crud_categories.create(db=db, object=category) + + +@router.get("/categories", response_model=PaginatedListResponse[CategoryRead]) +async def read_categories( + request: Request, + db: Annotated[AsyncSession, Depends(async_get_db)], + page: int = 1, + items_per_page: int = 10, +): + categories_data = await crud_categories.get_multi( + db=db, + offset=compute_offset(page, items_per_page), + limit=items_per_page, + schema_to_select=CategoryRead, + is_deleted=False, + ) + + return categories_data + + +@router.get("/category/{category_id}", response_model=CategoryRead) +async def read_category( + request: Request, + category_id: int, + db: Annotated[AsyncSession, Depends(async_get_db)], +): + db_category = await crud_categories.get( + db=db, + schema_to_select=CategoryRead, + id=category_id, + is_deleted=False + ) + if not db_category: + raise NotFoundException("Category not found") + + return db_category + + +@router.patch("/category/{category_id}", response_model=CategoryRead) +async def patch_category( + request: Request, + category_id: int, + values: CategoryUpdate, + current_user: Annotated[dict, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(async_get_db)], +): + db_category = await crud_categories.get(db=db, id=category_id, is_deleted=False) + if not db_category: + raise NotFoundException("Category not found") + + if values.name: + category_row = await crud_categories.exists(db=db, name=values.name) + if category_row and category_row["id"] != category_id: + raise DuplicateValueException("Category name already exists") + + return await crud_categories.update(db=db, object=values, id=category_id) + + +@router.delete("/category/{category_id}") +async def erase_category( + request: Request, + category_id: int, + current_user: Annotated[dict, Depends(get_current_superuser)], + db: Annotated[AsyncSession, Depends(async_get_db)], +): + db_category = await crud_categories.get(db=db, id=category_id, is_deleted=False) + if not db_category: + raise NotFoundException("Category not found") + + await crud_categories.delete(db=db, db_row=db_category, garbage_collection=False) + return {"message": "Category deleted"} +``` + +#### 7. Register Router + +Add your router to `src/app/api/v1/__init__.py`: + +```python +from fastapi import APIRouter +from .categories import router as categories_router +# ... other imports + +router = APIRouter() +router.include_router(categories_router, prefix="/categories") +# ... other router includes +``` + +### Creating Custom Middleware + +Create middleware in `src/app/middleware/`: + +```python +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + + +class CustomHeaderMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + # Pre-processing + start_time = time.time() + + # Process request + response = await call_next(request) + + # Post-processing + process_time = time.time() - start_time + response.headers["X-Process-Time"] = str(process_time) + + return response +``` + +Register in `src/app/main.py`: + +```python +from .middleware.custom_header_middleware import CustomHeaderMiddleware + +app.add_middleware(CustomHeaderMiddleware) +``` + +## Testing + +### Test Configuration + +The boilerplate uses pytest for testing. Test configuration is in `pytest.ini` and test dependencies in `pyproject.toml`. + +### Database Testing Setup + +Create test database fixtures in `tests/conftest.py`: + +```python +import asyncio +import pytest +import pytest_asyncio +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +from src.app.core.config import settings +from src.app.core.db.database import Base, async_get_db +from src.app.main import app + +# Test database URL +TEST_DATABASE_URL = "postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db" + +# Create test engine +test_engine = create_async_engine(TEST_DATABASE_URL, echo=True) +TestSessionLocal = sessionmaker( + test_engine, class_=AsyncSession, expire_on_commit=False +) + + +@pytest_asyncio.fixture +async def async_session(): + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with TestSessionLocal() as session: + yield session + + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest_asyncio.fixture +async def async_client(async_session): + def get_test_db(): + return async_session + + app.dependency_overrides[async_get_db] = get_test_db + + async with AsyncClient(app=app, base_url="http://test") as client: + yield client + + app.dependency_overrides.clear() +``` + +### Writing Tests + +#### Model Tests + +```python +# tests/test_models.py +import pytest +from src.app.models.user import User + + +@pytest_asyncio.fixture +async def test_user(async_session): + user = User( + name="Test User", + username="testuser", + email="test@example.com", + hashed_password="hashed_password" + ) + async_session.add(user) + await async_session.commit() + await async_session.refresh(user) + return user + + +async def test_user_creation(test_user): + assert test_user.name == "Test User" + assert test_user.username == "testuser" + assert test_user.email == "test@example.com" +``` + +#### API Endpoint Tests + +```python +# tests/test_api.py +import pytest +from httpx import AsyncClient + + +async def test_create_user(async_client: AsyncClient): + user_data = { + "name": "New User", + "username": "newuser", + "email": "new@example.com", + "password": "SecurePass123!" + } + + response = await async_client.post("/api/v1/users", json=user_data) + assert response.status_code == 201 + + data = response.json() + assert data["name"] == "New User" + assert data["username"] == "newuser" + assert "hashed_password" not in data # Ensure password not exposed + + +async def test_read_users(async_client: AsyncClient): + response = await async_client.get("/api/v1/users") + assert response.status_code == 200 + + data = response.json() + assert "data" in data + assert "total_count" in data +``` + +#### CRUD Tests + +```python +# tests/test_crud.py +import pytest +from src.app.crud.crud_users import crud_users +from src.app.schemas.user import UserCreate + + +async def test_crud_create_user(async_session): + user_data = UserCreate( + name="CRUD User", + username="cruduser", + email="crud@example.com", + password="password123" + ) + + user = await crud_users.create(db=async_session, object=user_data) + assert user["name"] == "CRUD User" + assert user["username"] == "cruduser" + + +async def test_crud_get_user(async_session, test_user): + retrieved_user = await crud_users.get( + db=async_session, + id=test_user.id + ) + assert retrieved_user["name"] == test_user.name +``` + +### Running Tests + +```bash +# Run all tests +uv run pytest + +# Run with coverage +uv run pytest --cov=src + +# Run specific test file +uv run pytest tests/test_api.py + +# Run with verbose output +uv run pytest -v + +# Run tests matching pattern +uv run pytest -k "test_user" +``` + +## Customization + +### Environment-Specific Configuration + +Create environment-specific settings: + +```python +# src/app/core/config.py +class LocalSettings(Settings): + ENVIRONMENT: str = "local" + DEBUG: bool = True + +class ProductionSettings(Settings): + ENVIRONMENT: str = "production" + DEBUG: bool = False + # Production-specific settings + +def get_settings(): + env = os.getenv("ENVIRONMENT", "local") + if env == "production": + return ProductionSettings() + return LocalSettings() + +settings = get_settings() +``` + +### Custom Logging + +Configure logging in `src/app/core/config.py`: + +```python +import logging +from pythonjsonlogger import jsonlogger + +def setup_logging(): + # JSON logging for production + if settings.ENVIRONMENT == "production": + logHandler = logging.StreamHandler() + formatter = jsonlogger.JsonFormatter() + logHandler.setFormatter(formatter) + logger = logging.getLogger() + logger.addHandler(logHandler) + logger.setLevel(logging.INFO) + else: + # Simple logging for development + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) +``` + +## Opting Out of Services + +### Disabling Redis Caching + +1. Remove cache decorators from endpoints +2. Update dependencies in `src/app/core/config.py`: + +```python +class Settings(BaseSettings): + # Comment out or remove Redis cache settings + # REDIS_CACHE_HOST: str = "localhost" + # REDIS_CACHE_PORT: int = 6379 + pass +``` + +3. Remove Redis cache imports and usage + +### Disabling Background Tasks (ARQ) + +1. Remove ARQ from `pyproject.toml` dependencies +2. Remove worker configuration from `docker-compose.yml` +3. Delete `src/app/core/worker/` directory +4. Remove task-related endpoints + +### Disabling Rate Limiting + +1. Remove rate limiting dependencies from endpoints: + +```python +# Remove this dependency +dependencies=[Depends(rate_limiter_dependency)] +``` + +2. Remove rate limiting models and schemas +3. Update database migrations to remove rate limit tables + +### Disabling Authentication + +1. Remove JWT dependencies from protected endpoints +2. Remove user-related models and endpoints +3. Update database to remove user tables +4. Remove authentication middleware + +### Minimal FastAPI Setup + +For a minimal setup with just basic FastAPI: + +```python +# src/app/main.py (minimal version) +from fastapi import FastAPI + +app = FastAPI( + title="Minimal API", + description="Basic FastAPI application", + version="1.0.0" +) + +@app.get("/") +async def root(): + return {"message": "Hello World"} + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} +``` + +## Best Practices + +### Code Organization + +- Keep models, schemas, and CRUD operations in separate files +- Use consistent naming conventions across the application +- Group related functionality in modules +- Follow FastAPI and Pydantic best practices + +### Database Operations + +- Always use transactions for multi-step operations +- Implement soft deletes for important data +- Use database constraints for data integrity +- Index frequently queried columns + +### API Design + +- Use consistent response formats +- Implement proper error handling +- Version your APIs from the start +- Document all endpoints with proper schemas + +### Security + +- Never expose sensitive data in API responses +- Use proper authentication and authorization +- Validate all input data +- Implement rate limiting for public endpoints +- Use HTTPS in production + +### Performance + +- Use async/await consistently +- Implement caching for expensive operations +- Use database connection pooling +- Monitor and optimize slow queries +- Use pagination for large datasets + +## Troubleshooting + +### Common Issues + +**Import Errors**: Ensure all new models are imported in `__init__.py` files + +**Migration Failures**: Check model definitions and relationships before generating migrations + +**Test Failures**: Verify test database configuration and isolation + +**Performance Issues**: Check for N+1 queries and missing database indexes + +**Authentication Problems**: Verify JWT configuration and token expiration settings + +### Debugging Tips + +- Use FastAPI's automatic interactive docs at `/docs` +- Enable SQL query logging in development +- Use proper logging throughout the application +- Test endpoints with realistic data volumes +- Monitor database performance with query analysis + +## Database Migrations + +!!! warning "Important Setup for Docker Users" + If you're using the database in Docker, you need to expose the port to run migrations. Change this in `docker-compose.yml`: + + ```yaml + db: + image: postgres:13 + env_file: + - ./src/.env + volumes: + - postgres-data:/var/lib/postgresql/data + # -------- replace with comment to run migrations with docker -------- + ports: + - 5432:5432 + # expose: + # - "5432" + ``` + +### Creating Migrations + +!!! warning "Model Import Requirement" + To create tables if you haven't created endpoints yet, ensure you import the models in `src/app/models/__init__.py`. This step is crucial for Alembic to detect new tables. + +While in the `src` folder, run Alembic migrations: + +```bash +# Generate migration file +uv run alembic revision --autogenerate -m "Description of changes" + +# Apply migrations +uv run alembic upgrade head +``` + +!!! note "Without uv" + If you don't have uv, run `pip install alembic` first, then use `alembic` commands directly. + +### Migration Workflow + +1. **Make Model Changes** - Modify your SQLAlchemy models +2. **Import Models** - Ensure models are imported in `src/app/models/__init__.py` +3. **Generate Migration** - Run `alembic revision --autogenerate` +4. **Review Migration** - Check the generated migration file in `src/migrations/versions/` +5. **Apply Migration** - Run `alembic upgrade head` +6. **Test Changes** - Verify your changes work as expected + +### Common Migration Tasks + +#### Adding a New Model + +```python +# 1. Create the model file (e.g., src/app/models/category.py) +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.database import Base + +class Category(Base): + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50)) + description: Mapped[str] = mapped_column(String(255), nullable=True) +``` + +```python +# 2. Import in src/app/models/__init__.py +from .user import User +from .post import Post +from .tier import Tier +from .rate_limit import RateLimit +from .category import Category # Add this line +``` + +```bash +# 3. Generate and apply migration +cd src +uv run alembic revision --autogenerate -m "Add categories table" +uv run alembic upgrade head +``` + +#### Modifying Existing Models + +```python +# 1. Modify your model +class User(Base): + # ... existing fields ... + bio: Mapped[str] = mapped_column(String(500), nullable=True) # New field +``` + +```bash +# 2. Generate migration +uv run alembic revision --autogenerate -m "Add bio field to users" + +# 3. Review the generated migration file +# 4. Apply migration +uv run alembic upgrade head +``` + +This guide provides the foundation for extending and customizing the FastAPI boilerplate. For specific implementation details, refer to the existing code examples throughout the boilerplate. \ No newline at end of file diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md new file mode 100644 index 0000000..2770266 --- /dev/null +++ b/docs/user-guide/index.md @@ -0,0 +1,86 @@ +# User Guide + +This user guide provides comprehensive information about using and understanding the FastAPI Boilerplate. Whether you're building your first API or looking to understand advanced features, this guide covers everything you need to know. + +## What You'll Learn + +This guide covers all aspects of working with the FastAPI Boilerplate: + +### Project Understanding +- **[Project Structure](project-structure.md)** - Navigate the codebase organization and understand architectural decisions +- **[Configuration](configuration/index.md)** - Configure your application for different environments + +### Core Components + +### Database Operations +- **[Database Overview](database/index.md)** - Understand the data layer architecture +- **[Models](database/models.md)** - Define and work with SQLAlchemy models +- **[Schemas](database/schemas.md)** - Create Pydantic schemas for data validation +- **[CRUD Operations](database/crud.md)** - Implement create, read, update, and delete operations +- **[Migrations](database/migrations.md)** - Manage database schema changes with Alembic + +### API Development +- **[API Overview](api/index.md)** - Build robust REST APIs with FastAPI +- **[Endpoints](api/endpoints.md)** - Create and organize API endpoints +- **[Pagination](api/pagination.md)** - Implement efficient data pagination +- **[Exception Handling](api/exceptions.md)** - Handle errors gracefully +- **[API Versioning](api/versioning.md)** - Manage API versions and backward compatibility + +### Security & Authentication +- **[Authentication Overview](authentication/index.md)** - Secure your API with JWT authentication +- **[JWT Tokens](authentication/jwt-tokens.md)** - Understand access and refresh token management +- **[User Management](authentication/user-management.md)** - Handle user registration, login, and profiles +- **[Permissions](authentication/permissions.md)** - Implement role-based access control + +### Admin Panel +Powered by [CRUDAdmin](https://github.com/benavlabs/crudadmin) - a modern admin interface generator for FastAPI. + +- **[Admin Panel Overview](admin-panel/index.md)** - Web-based database management interface +- **[Configuration](admin-panel/configuration.md)** - Setup, session backends, and environment variables +- **[Adding Models](admin-panel/adding-models.md)** - Register models, schemas, and customization +- **[User Management](admin-panel/user-management.md)** - Admin users, authentication, and security + +### Performance & Caching +- **[Caching Overview](caching/index.md)** - Improve performance with Redis caching +- **[Redis Cache](caching/redis-cache.md)** - Server-side caching with Redis +- **[Client Cache](caching/client-cache.md)** - HTTP caching headers and browser caching +- **[Cache Strategies](caching/cache-strategies.md)** - Advanced caching patterns and invalidation + +### Background Processing +- **[Background Tasks](background-tasks/index.md)** - Handle long-running operations with ARQ + +### Rate Limiting +- **[Rate Limiting](rate-limiting/index.md)** - Protect your API from abuse with Redis-based rate limiting + +## Prerequisites + +Before diving into this guide, ensure you have: + +- Completed the [Getting Started](../getting-started/index.md) section +- A running FastAPI Boilerplate instance +- Basic understanding of Python, FastAPI, and REST APIs +- Familiarity with SQL databases (PostgreSQL knowledge is helpful) + +## Next Steps + +Ready to dive in? Here are recommended learning paths: + +### For New Users +1. Start with [Project Structure](project-structure.md) to understand the codebase +2. Learn [Database Models](database/models.md) and [Schemas](database/schemas.md) +3. Create your first [API Endpoints](api/endpoints.md) +4. Add [Authentication](authentication/index.md) to secure your API + +### For Experienced Developers +1. Review [Database CRUD Operations](database/crud.md) for advanced patterns +2. Implement [Caching Strategies](caching/index.md) for performance +3. Set up [Background Tasks](background-tasks/index.md) for async processing +4. Configure [Rate Limiting](rate-limiting/index.md) for production use + +### For Production Deployment +1. Understand [Cache Strategies](caching/cache-strategies.md) patterns +2. Configure [Rate Limiting](rate-limiting/index.md) with user tiers +3. Set up [Background Task Processing](background-tasks/index.md) +4. Review the [Production Guide](production.md) for deployment considerations + +Choose your path based on your needs and experience level. Each section builds upon previous concepts while remaining self-contained for reference use. \ No newline at end of file diff --git a/docs/user-guide/production.md b/docs/user-guide/production.md new file mode 100644 index 0000000..53fecbf --- /dev/null +++ b/docs/user-guide/production.md @@ -0,0 +1,709 @@ +# Production Deployment + +This guide covers deploying the FastAPI boilerplate to production with proper performance, security, and reliability configurations. + +## Production Architecture + +The recommended production setup uses: + +- **Gunicorn** - WSGI server managing Uvicorn workers +- **Uvicorn Workers** - ASGI server handling FastAPI requests +- **NGINX** - Reverse proxy and load balancer +- **PostgreSQL** - Production database +- **Redis** - Caching and background tasks +- **Docker** - Containerization + +## Environment Configuration + +### Production Environment Variables + +Update your `.env` file for production: + +```bash +# ------------- environment ------------- +ENVIRONMENT="production" + +# ------------- app settings ------------- +APP_NAME="Your Production App" +DEBUG=false + +# ------------- database ------------- +POSTGRES_USER="prod_user" +POSTGRES_PASSWORD="secure_production_password" +POSTGRES_SERVER="db" # or your database host +POSTGRES_PORT=5432 +POSTGRES_DB="prod_database" + +# ------------- redis ------------- +REDIS_CACHE_HOST="redis" +REDIS_CACHE_PORT=6379 +REDIS_QUEUE_HOST="redis" +REDIS_QUEUE_PORT=6379 +REDIS_RATE_LIMIT_HOST="redis" +REDIS_RATE_LIMIT_PORT=6379 + +# ------------- security ------------- +SECRET_KEY="your-super-secure-secret-key-generate-with-openssl" +ALGORITHM="HS256" +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# ------------- logging ------------- +LOG_LEVEL="INFO" +``` + +### Docker Configuration + +#### Production Dockerfile + +```dockerfile +FROM python:3.11-slim + +WORKDIR /code + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install UV +RUN pip install uv + +# Copy dependency files +COPY pyproject.toml uv.lock ./ + +# Install dependencies +RUN uv sync --frozen --no-dev + +# Copy application code +COPY src/ ./src/ + +# Create non-root user +RUN useradd --create-home --shell /bin/bash app \ + && chown -R app:app /code +USER app + +# Production command with Gunicorn +CMD ["uv", "run", "gunicorn", "src.app.main:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:8000"] +``` + +#### Production Docker Compose + +```yaml +version: '3.8' + +services: + web: + build: . + ports: + - "8000:8000" + env_file: + - ./src/.env + depends_on: + - db + - redis + restart: unless-stopped + deploy: + resources: + limits: + memory: 1G + reservations: + memory: 512M + + worker: + build: . + command: uv run arq src.app.core.worker.settings.WorkerSettings + env_file: + - ./src/.env + depends_on: + - db + - redis + restart: unless-stopped + deploy: + replicas: 2 + + db: + image: postgres:15 + volumes: + - postgres_data:/var/lib/postgresql/data/ + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB} + restart: unless-stopped + deploy: + resources: + limits: + memory: 2G + reservations: + memory: 1G + + redis: + image: redis:7-alpine + restart: unless-stopped + volumes: + - redis_data:/data + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 256M + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/ssl:/etc/nginx/ssl + depends_on: + - web + restart: unless-stopped + +volumes: + postgres_data: + redis_data: +``` + +## Gunicorn Configuration + +### Basic Gunicorn Setup + +Create `gunicorn.conf.py`: + +```python +import multiprocessing + +# Server socket +bind = "0.0.0.0:8000" +backlog = 2048 + +# Worker processes +workers = multiprocessing.cpu_count() * 2 + 1 +worker_class = "uvicorn.workers.UvicornWorker" +worker_connections = 1000 +max_requests = 1000 +max_requests_jitter = 50 + +# Restart workers after this many requests, with up to 50 jitter +preload_app = True + +# Logging +accesslog = "-" +errorlog = "-" +loglevel = "info" +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s' + +# Process naming +proc_name = "fastapi-boilerplate" + +# Server mechanics +daemon = False +pidfile = "/tmp/gunicorn.pid" +user = None +group = None +tmp_upload_dir = None + +# SSL (if terminating SSL at application level) +# keyfile = "/path/to/keyfile" +# certfile = "/path/to/certfile" + +# Worker timeout +timeout = 30 +keepalive = 2 + +# Memory management +max_requests = 1000 +max_requests_jitter = 50 +preload_app = True +``` + +### Running with Gunicorn + +```bash +# Basic command +uv run gunicorn src.app.main:app -w 4 -k uvicorn.workers.UvicornWorker + +# With configuration file +uv run gunicorn src.app.main:app -c gunicorn.conf.py + +# With specific bind address +uv run gunicorn src.app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 +``` + +## NGINX Configuration + +### Single Server Setup + +Create `nginx/nginx.conf`: + +```nginx +events { + worker_connections 1024; +} + +http { + upstream fastapi_backend { + server web:8000; + } + + server { + listen 80; + server_name your-domain.com; + + # Redirect HTTP to HTTPS + return 301 https://$server_name$request_uri; + } + + server { + listen 443 ssl http2; + server_name your-domain.com; + + # SSL Configuration + ssl_certificate /etc/nginx/ssl/cert.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # Security headers + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 10240; + gzip_proxied expired no-cache no-store private must-revalidate auth; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/xml+rss + application/json; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + + location / { + limit_req zone=api burst=20 nodelay; + + proxy_pass http://fastapi_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # Buffer settings + proxy_buffering on; + proxy_buffer_size 8k; + proxy_buffers 8 8k; + } + + # Health check endpoint (no rate limiting) + location /health { + proxy_pass http://fastapi_backend; + proxy_set_header Host $host; + access_log off; + } + + # Static files (if any) + location /static/ { + alias /code/static/; + expires 1y; + add_header Cache-Control "public, immutable"; + } + } +} +``` + +### Simple Single Server (default.conf) + +For basic production setup, create `default.conf`: + +```nginx +# ---------------- Running With One Server ---------------- +server { + listen 80; + + location / { + proxy_pass http://web:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### Load Balancing Multiple Servers + +For horizontal scaling with multiple FastAPI instances: + +```nginx +# ---------------- To Run with Multiple Servers ---------------- +upstream fastapi_app { + server fastapi1:8000; # Replace with actual server names + server fastapi2:8000; + # Add more servers as needed +} + +server { + listen 80; + + location / { + proxy_pass http://fastapi_app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +### Advanced Load Balancing + +For production with advanced features: + +```nginx +upstream fastapi_backend { + least_conn; + server web1:8000 weight=3; + server web2:8000 weight=2; + server web3:8000 weight=1; + + # Health checks + keepalive 32; +} + +server { + listen 443 ssl http2; + server_name your-domain.com; + + location / { + proxy_pass http://fastapi_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Connection settings for load balancing + proxy_http_version 1.1; + proxy_set_header Connection ""; + } +} +``` + +### SSL Certificate Setup + +#### Using Let's Encrypt (Certbot) + +```bash +# Install certbot +sudo apt-get update +sudo apt-get install certbot python3-certbot-nginx + +# Obtain certificate +sudo certbot --nginx -d your-domain.com + +# Auto-renewal (add to crontab) +0 2 * * 1 /usr/bin/certbot renew --quiet +``` + +#### Manual SSL Setup + +```bash +# Generate self-signed certificate (development only) +mkdir -p nginx/ssl +openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout nginx/ssl/key.pem \ + -out nginx/ssl/cert.pem +``` + +## Production Best Practices + +### Database Optimization + +#### PostgreSQL Configuration + +```sql +-- Optimize PostgreSQL for production +ALTER SYSTEM SET shared_buffers = '256MB'; +ALTER SYSTEM SET effective_cache_size = '1GB'; +ALTER SYSTEM SET random_page_cost = 1.1; +ALTER SYSTEM SET effective_io_concurrency = 200; +SELECT pg_reload_conf(); +``` + +#### Connection Pooling + +```python +# src/app/core/db/database.py +from sqlalchemy.ext.asyncio import create_async_engine + +# Production database settings +engine = create_async_engine( + DATABASE_URL, + echo=False, # Disable in production + pool_size=20, + max_overflow=0, + pool_pre_ping=True, + pool_recycle=3600, +) +``` + +### Redis Configuration + +#### Redis Production Settings + +```bash +# redis.conf adjustments +maxmemory 512mb +maxmemory-policy allkeys-lru +save 900 1 +save 300 10 +save 60 10000 +``` + +### Application Optimization + +#### Logging Configuration + +```python +# src/app/core/config.py +import logging +from pythonjsonlogger import jsonlogger + +def setup_production_logging(): + logHandler = logging.StreamHandler() + formatter = jsonlogger.JsonFormatter( + "%(asctime)s %(name)s %(levelname)s %(message)s" + ) + logHandler.setFormatter(formatter) + + logger = logging.getLogger() + logger.addHandler(logHandler) + logger.setLevel(logging.INFO) + + # Reduce noise from third-party libraries + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) +``` + +#### Performance Monitoring + +```python +# src/app/middleware/monitoring.py +import time +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware + +class MonitoringMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + start_time = time.time() + + response = await call_next(request) + + process_time = time.time() - start_time + response.headers["X-Process-Time"] = str(process_time) + + # Log slow requests + if process_time > 1.0: + logger.warning(f"Slow request: {request.method} {request.url} - {process_time:.2f}s") + + return response +``` + +### Security Configuration + +#### Environment Security + +```python +# src/app/core/config.py +class ProductionSettings(Settings): + # Hide docs in production + ENVIRONMENT: str = "production" + + # Security settings + SECRET_KEY: str = Field(..., min_length=32) + ALLOWED_HOSTS: list[str] = ["your-domain.com", "api.your-domain.com"] + + # Database security + POSTGRES_PASSWORD: str = Field(..., min_length=16) + + class Config: + case_sensitive = True +``` + +#### Rate Limiting + +```python +# Adjust rate limits for production +DEFAULT_RATE_LIMIT_LIMIT = 100 # requests per period +DEFAULT_RATE_LIMIT_PERIOD = 3600 # 1 hour +``` + +### Health Checks + +#### Application Health Check + +```python +# src/app/api/v1/health.py +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from ...core.db.database import async_get_db +from ...core.utils.cache import redis_client + +router = APIRouter() + +@router.get("/health") +async def health_check(): + return {"status": "healthy", "timestamp": datetime.utcnow()} + +@router.get("/health/detailed") +async def detailed_health_check(db: AsyncSession = Depends(async_get_db)): + health_status = {"status": "healthy", "services": {}} + + # Check database + try: + await db.execute("SELECT 1") + health_status["services"]["database"] = "healthy" + except Exception: + health_status["services"]["database"] = "unhealthy" + health_status["status"] = "unhealthy" + + # Check Redis + try: + await redis_client.ping() + health_status["services"]["redis"] = "healthy" + except Exception: + health_status["services"]["redis"] = "unhealthy" + health_status["status"] = "unhealthy" + + if health_status["status"] == "unhealthy": + raise HTTPException(status_code=503, detail=health_status) + + return health_status +``` + +### Deployment Process + +#### CI/CD Pipeline (GitHub Actions) + +```yaml +# .github/workflows/deploy.yml +name: Deploy to Production + +on: + push: + branches: [main] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Build and push Docker image + env: + DOCKER_REGISTRY: your-registry.com + run: | + docker build -t $DOCKER_REGISTRY/fastapi-app:latest . + docker push $DOCKER_REGISTRY/fastapi-app:latest + + - name: Deploy to production + run: | + # Your deployment commands + ssh production-server "docker compose pull && docker compose up -d" +``` + +#### Zero-Downtime Deployment + +```bash +#!/bin/bash +# deploy.sh - Zero-downtime deployment script + +# Pull new images +docker compose pull + +# Start new containers +docker compose up -d --no-deps --scale web=2 web + +# Wait for health check +sleep 30 + +# Stop old containers +docker compose up -d --no-deps --scale web=1 web + +# Clean up +docker system prune -f +``` + +### Monitoring and Alerting + +#### Basic Monitoring Setup + +```python +# Basic metrics collection +import psutil +from fastapi import APIRouter + +router = APIRouter() + +@router.get("/metrics") +async def get_metrics(): + return { + "cpu_percent": psutil.cpu_percent(), + "memory_percent": psutil.virtual_memory().percent, + "disk_usage": psutil.disk_usage('/').percent + } +``` + +### Backup Strategy + +#### Database Backup + +```bash +#!/bin/bash +# backup-db.sh +BACKUP_DIR="/backups" +DATE=$(date +%Y%m%d_%H%M%S) + +pg_dump -h localhost -U $POSTGRES_USER $POSTGRES_DB | gzip > $BACKUP_DIR/backup_$DATE.sql.gz + +# Keep only last 7 days of backups +find $BACKUP_DIR -name "backup_*.sql.gz" -mtime +7 -delete +``` + +## Troubleshooting + +### Common Production Issues + +**High Memory Usage**: Check for memory leaks, optimize database queries, adjust worker counts + +**Slow Response Times**: Enable query logging, check database indexes, optimize N+1 queries + +**Connection Timeouts**: Adjust proxy timeouts, check database connection pool settings + +**SSL Certificate Issues**: Verify certificate paths, check renewal process + +### Performance Tuning + +- Monitor database query performance +- Implement proper caching strategies +- Use connection pooling +- Optimize Docker image layers +- Configure proper resource limits + +This production guide provides a solid foundation for deploying the FastAPI boilerplate to production environments with proper performance, security, and reliability configurations. \ No newline at end of file diff --git a/docs/user-guide/project-structure.md b/docs/user-guide/project-structure.md new file mode 100644 index 0000000..be47b72 --- /dev/null +++ b/docs/user-guide/project-structure.md @@ -0,0 +1,296 @@ +# Project Structure + +Understanding the project structure is essential for navigating the FastAPI Boilerplate effectively. This guide explains the organization of the codebase, the purpose of each directory, and how components interact with each other. + +## Overview + +The FastAPI Boilerplate follows a clean, modular architecture that separates concerns and promotes maintainability. The structure is designed to scale from simple APIs to complex applications while maintaining code organization and clarity. + +## Root Directory Structure + +```text +FastAPI-boilerplate/ +โ”œโ”€โ”€ Dockerfile # Container configuration +โ”œโ”€โ”€ docker-compose.yml # Multi-service orchestration +โ”œโ”€โ”€ pyproject.toml # Project configuration and dependencies +โ”œโ”€โ”€ uv.lock # Dependency lock file +โ”œโ”€โ”€ README.md # Project documentation +โ”œโ”€โ”€ LICENSE.md # License information +โ”œโ”€โ”€ tests/ # Test suite +โ”œโ”€โ”€ docs/ # Documentation +โ””โ”€โ”€ src/ # Source code +``` + +### Configuration Files + +| File | Purpose | +|------|---------| +| `Dockerfile` | Defines the container image for the application | +| `docker-compose.yml` | Orchestrates multiple services (API, database, Redis, worker) | +| `pyproject.toml` | Modern Python project configuration with dependencies and metadata | +| `uv.lock` | Locks exact dependency versions for reproducible builds | + +## Source Code Structure + +The `src/` directory contains all application code: + +```text +src/ +โ”œโ”€โ”€ app/ # Main application package +โ”‚ โ”œโ”€โ”€ main.py # Application entry point +โ”‚ โ”œโ”€โ”€ api/ # API layer +โ”‚ โ”œโ”€โ”€ core/ # Core utilities and configurations +โ”‚ โ”œโ”€โ”€ crud/ # Database operations +โ”‚ โ”œโ”€โ”€ models/ # SQLAlchemy models +โ”‚ โ”œโ”€โ”€ schemas/ # Pydantic schemas +โ”‚ โ”œโ”€โ”€ middleware/ # Custom middleware +โ”‚ โ””โ”€โ”€ logs/ # Application logs +โ”œโ”€โ”€ migrations/ # Database migrations +โ””โ”€โ”€ scripts/ # Utility scripts +``` + +## Core Application (`src/app/`) + +### Entry Point +- **`main.py`** - FastAPI application instance and configuration + +### API Layer (`api/`) +```text +api/ +โ”œโ”€โ”€ dependencies.py # Shared dependencies +โ””โ”€โ”€ v1/ # API version 1 + โ”œโ”€โ”€ login.py # Authentication endpoints + โ”œโ”€โ”€ logout.py # Logout functionality + โ”œโ”€โ”€ users.py # User management + โ”œโ”€โ”€ posts.py # Post operations + โ”œโ”€โ”€ tasks.py # Background task endpoints + โ”œโ”€โ”€ tiers.py # User tier management + โ””โ”€โ”€ rate_limits.py # Rate limiting endpoints +``` + +**Purpose**: Contains all API endpoints organized by functionality and version. + +### Core System (`core/`) +```text +core/ +โ”œโ”€โ”€ config.py # Application settings +โ”œโ”€โ”€ logger.py # Logging configuration +โ”œโ”€โ”€ schemas.py # Core Pydantic schemas +โ”œโ”€โ”€ security.py # Security utilities +โ”œโ”€โ”€ setup.py # Application factory +โ”œโ”€โ”€ db/ # Database core +โ”œโ”€โ”€ exceptions/ # Custom exceptions +โ”œโ”€โ”€ utils/ # Utility functions +โ””โ”€โ”€ worker/ # Background worker +``` + +**Purpose**: Houses core functionality, configuration, and shared utilities. + +#### Database Core (`core/db/`) +```text +db/ +โ”œโ”€โ”€ database.py # Database connection and session management +โ”œโ”€โ”€ models.py # Base models and mixins +โ”œโ”€โ”€ crud_token_blacklist.py # Token blacklist operations +โ””โ”€โ”€ token_blacklist.py # Token blacklist model +``` + +#### Exceptions (`core/exceptions/`) +```text +exceptions/ +โ”œโ”€โ”€ cache_exceptions.py # Cache-related exceptions +โ””โ”€โ”€ http_exceptions.py # HTTP exceptions +``` + +#### Utilities (`core/utils/`) +```text +utils/ +โ”œโ”€โ”€ cache.py # Caching utilities +โ”œโ”€โ”€ queue.py # Task queue management +โ””โ”€โ”€ rate_limit.py # Rate limiting utilities +``` + +#### Worker (`core/worker/`) +```text +worker/ +โ”œโ”€โ”€ settings.py # Worker configuration +โ””โ”€โ”€ functions.py # Background task definitions +``` + +### Data Layer + +#### Models (`models/`) +```text +models/ +โ”œโ”€โ”€ user.py # User model +โ”œโ”€โ”€ post.py # Post model +โ”œโ”€โ”€ tier.py # User tier model +โ””โ”€โ”€ rate_limit.py # Rate limit model +``` + +**Purpose**: SQLAlchemy ORM models defining database schema. + +#### Schemas (`schemas/`) +```text +schemas/ +โ”œโ”€โ”€ user.py # User validation schemas +โ”œโ”€โ”€ post.py # Post validation schemas +โ”œโ”€โ”€ tier.py # Tier validation schemas +โ”œโ”€โ”€ rate_limit.py # Rate limit schemas +โ””โ”€โ”€ job.py # Background job schemas +``` + +**Purpose**: Pydantic schemas for request/response validation and serialization. + +#### CRUD Operations (`crud/`) +```text +crud/ +โ”œโ”€โ”€ crud_base.py # Base CRUD class +โ”œโ”€โ”€ crud_users.py # User operations +โ”œโ”€โ”€ crud_posts.py # Post operations +โ”œโ”€โ”€ crud_tier.py # Tier operations +โ”œโ”€โ”€ crud_rate_limit.py # Rate limit operations +โ””โ”€โ”€ helper.py # CRUD helper functions +``` + +**Purpose**: Database operations using FastCRUD for consistent data access patterns. + +### Additional Components + +#### Middleware (`middleware/`) +```text +middleware/ +โ””โ”€โ”€ client_cache_middleware.py # Client-side caching middleware +``` + +#### Logs (`logs/`) +```text +logs/ +โ””โ”€โ”€ app.log # Application log file +``` + +## Database Migrations (`src/migrations/`) + +```text +migrations/ +โ”œโ”€โ”€ README # Migration instructions +โ”œโ”€โ”€ env.py # Alembic environment configuration +โ”œโ”€โ”€ script.py.mako # Migration template +โ””โ”€โ”€ versions/ # Individual migration files +``` + +**Purpose**: Alembic database migrations for schema version control. + +## Utility Scripts (`src/scripts/`) + +```text +scripts/ +โ”œโ”€โ”€ create_first_superuser.py # Create initial admin user +โ””โ”€โ”€ create_first_tier.py # Create initial user tier +``` + +**Purpose**: Initialization and maintenance scripts. + +## Testing Structure (`tests/`) + +```text +tests/ +โ”œโ”€โ”€ conftest.py # Pytest configuration and fixtures +โ”œโ”€โ”€ test_user_unit.py # User-related unit tests +โ””โ”€โ”€ helpers/ # Test utilities + โ”œโ”€โ”€ generators.py # Test data generators + โ””โ”€โ”€ mocks.py # Mock objects and functions +``` + +## Architectural Patterns + +### Layered Architecture + +The boilerplate implements a clean layered architecture: + +1. **API Layer** (`api/`) - Handles HTTP requests and responses +2. **Business Logic** (`crud/`) - Implements business rules and data operations +3. **Data Access** (`models/`) - Defines data structure and database interaction +4. **Core Services** (`core/`) - Provides shared functionality and configuration + +### Dependency Injection + +FastAPI's dependency injection system is used throughout: + +- **Database Sessions** - Injected into endpoints via `async_get_db` +- **Authentication** - User context provided by `get_current_user` +- **Rate Limiting** - Applied via `rate_limiter_dependency` +- **Caching** - Managed through decorators and middleware + +### Configuration Management + +All configuration is centralized in `core/config.py`: + +- **Environment Variables** - Loaded from `.env` file +- **Settings Classes** - Organized by functionality (database, security, etc.) +- **Type Safety** - Using Pydantic for validation + +### Error Handling + +Centralized exception handling: + +- **Custom Exceptions** - Defined in `core/exceptions/` +- **HTTP Status Codes** - Consistent error responses +- **Logging** - Automatic error logging and tracking + +## Design Principles + +### Single Responsibility + +Each module has a clear, single purpose: + +- Models define data structure +- Schemas handle validation +- CRUD manages data operations +- API endpoints handle requests + +### Separation of Concerns + +- Business logic separated from presentation +- Database operations isolated from API logic +- Configuration centralized and environment-aware + +### Modularity + +- Features can be added/removed independently +- Services can be disabled via configuration +- Clear interfaces between components + +### Scalability + +- Async/await throughout the application +- Connection pooling for database access +- Caching and background task support +- Horizontal scaling ready + +## Navigation Tips + +### Finding Code + +- **Models** โ†’ `src/app/models/` +- **API Endpoints** โ†’ `src/app/api/v1/` +- **Database Operations** โ†’ `src/app/crud/` +- **Configuration** โ†’ `src/app/core/config.py` +- **Business Logic** โ†’ Distributed across CRUD and API layers + +### Adding New Features + +1. **Model** โ†’ Define in `models/` +2. **Schema** โ†’ Create in `schemas/` +3. **CRUD** โ†’ Implement in `crud/` +4. **API** โ†’ Add endpoints in `api/v1/` +5. **Migration** โ†’ Generate with Alembic + +### Understanding Data Flow + +```text +Request โ†’ API Endpoint โ†’ Dependencies โ†’ CRUD โ†’ Model โ†’ Database +Response โ† API Response โ† Schema โ† CRUD โ† Query Result โ† Database +``` + +This structure provides a solid foundation for building scalable, maintainable APIs while keeping the codebase organized and easy to navigate. \ No newline at end of file diff --git a/docs/user-guide/rate-limiting/index.md b/docs/user-guide/rate-limiting/index.md new file mode 100644 index 0000000..8cba87a --- /dev/null +++ b/docs/user-guide/rate-limiting/index.md @@ -0,0 +1,481 @@ +# Rate Limiting + +The boilerplate includes a sophisticated rate limiting system built on Redis that protects your API from abuse while supporting user tiers with different access levels. This system provides flexible, scalable rate limiting for production applications. + +## Overview + +Rate limiting controls how many requests users can make within a specific time period. The boilerplate implements: + +- **Redis-Based Storage**: Fast, distributed rate limiting using Redis +- **User Tier System**: Different limits for different user types +- **Path-Specific Limits**: Granular control per API endpoint +- **Fallback Protection**: Default limits for unauthenticated users + +## Quick Example + +```python +from fastapi import Depends +from app.api.dependencies import rate_limiter_dependency + +@router.post("/api/v1/posts", dependencies=[Depends(rate_limiter_dependency)]) +async def create_post(post_data: PostCreate): + # This endpoint is automatically rate limited based on: + # - User's tier (basic, premium, enterprise) + # - Specific limits for the /posts endpoint + # - Default limits for unauthenticated users + return await crud_posts.create(db=db, object=post_data) +``` + +## Architecture + +### Rate Limiting Components + +**Rate Limiter Class**: Singleton Redis client for checking limits
+**User Tiers**: Database-stored user subscription levels
+**Rate Limit Rules**: Path-specific limits per tier
+**Dependency Injection**: Automatic enforcement via FastAPI dependencies
+ +### How It Works + +1. **Request Arrives**: User makes API request to protected endpoint +2. **User Identification**: System identifies user and their tier +3. **Limit Lookup**: Finds applicable rate limit for user tier + endpoint +4. **Redis Check**: Increments counter in Redis sliding window +5. **Allow/Deny**: Request proceeds or returns 429 Too Many Requests + +## User Tier System + +### Default Tiers + +The system supports flexible user tiers with different access levels: + +```python +# Example tier configuration +tiers = { + "free": { + "requests_per_minute": 10, + "requests_per_hour": 100, + "special_endpoints": { + "/api/v1/ai/generate": {"limit": 2, "period": 3600}, # 2 per hour + "/api/v1/exports": {"limit": 1, "period": 86400}, # 1 per day + } + }, + "premium": { + "requests_per_minute": 60, + "requests_per_hour": 1000, + "special_endpoints": { + "/api/v1/ai/generate": {"limit": 50, "period": 3600}, + "/api/v1/exports": {"limit": 10, "period": 86400}, + } + }, + "enterprise": { + "requests_per_minute": 300, + "requests_per_hour": 10000, + "special_endpoints": { + "/api/v1/ai/generate": {"limit": 500, "period": 3600}, + "/api/v1/exports": {"limit": 100, "period": 86400}, + } + } +} +``` + +### Rate Limit Database Structure + +```python +# Rate limits are stored per tier and path +class RateLimit: + id: int + tier_id: int # Links to user tier + name: str # Descriptive name + path: str # API path (sanitized) + limit: int # Number of requests allowed + period: int # Time period in seconds +``` + +## Implementation Details + +### Automatic Rate Limiting + +The system automatically applies rate limiting through dependency injection: + +```python +@router.post("/protected-endpoint", dependencies=[Depends(rate_limiter_dependency)]) +async def protected_endpoint(): + """This endpoint is automatically rate limited.""" + pass + +# The dependency: +# 1. Identifies the user and their tier +# 2. Looks up rate limits for this path +# 3. Checks Redis counter +# 4. Allows or blocks the request +``` +#### Example Dependency Implementation + +To make the rate limiting dependency functional, you must implement how user tiers and paths resolve to actual rate limits. +Below is a complete example using Redis and the database to determine per-tier and per-path restrictions. + +```python +async def rate_limiter_dependency( + request: Request, + db: AsyncSession = Depends(async_get_db), + user=Depends(get_current_user_optional), +): + """ + Enforces rate limits per user tier and API path. + + - Identifies user (or defaults to IP-based anonymous rate limit) + - Finds tier-specific limit for the request path + - Checks Redis counter to determine if request should be allowed + """ + path = sanitize_path(request.url.path) + user_id = getattr(user, "id", None) or request.client.host or "anonymous" + + # Determine user tier (default to "free" or anonymous) + if user and getattr(user, "tier_id", None): + tier = await crud_tiers.get(db=db, id=user.tier_id) + else: + tier = await crud_tiers.get(db=db, name="free") + + if not tier: + raise RateLimitException("Tier configuration not found") + + # Find specific rate limit rule for this path + tier + rate_limit_rule = await crud_rate_limits.get_by_path_and_tier( + db=db, path=path, tier_id=tier.id + ) + + # Use default limits if no specific rule is found + limit = getattr(rate_limit_rule, "limit", 100) + period = getattr(rate_limit_rule, "period", 3600) + + # Check rate limit in Redis + is_limited = await rate_limiter.is_rate_limited( + db=db, + user_id=user_id, + path=path, + limit=limit, + period=period, + ) + + if is_limited: + raise RateLimitException( + f"Rate limit exceeded for path '{path}'. Try again later." + ) +``` + +### Redis-Based Counting + +The rate limiter uses Redis for distributed, high-performance counting: + +```python +# Sliding window implementation +async def is_rate_limited(self, user_id: int, path: str, limit: int, period: int) -> bool: + current_timestamp = int(datetime.now(UTC).timestamp()) + window_start = current_timestamp - (current_timestamp % period) + + # Create unique key for this user/path/window + key = f"ratelimit:{user_id}:{sanitized_path}:{window_start}" + + # Increment counter + current_count = await redis_client.incr(key) + + # Set expiration on first increment + if current_count == 1: + await redis_client.expire(key, period) + + # Check if limit exceeded + return current_count > limit +``` + +### Path Sanitization + +API paths are sanitized for consistent Redis key generation: + +```python +def sanitize_path(path: str) -> str: + return path.strip("/").replace("/", "_") + +# Examples: +# "/api/v1/users" โ†’ "api_v1_users" +# "/posts/{id}" โ†’ "posts_{id}" +``` + +## Configuration + +### Environment Variables + +```bash +# Rate Limiting Settings +DEFAULT_RATE_LIMIT_LIMIT=100 # Default requests per period +DEFAULT_RATE_LIMIT_PERIOD=3600 # Default period (1 hour) + +# Redis Rate Limiter Settings +REDIS_RATE_LIMITER_HOST=localhost +REDIS_RATE_LIMITER_PORT=6379 +REDIS_RATE_LIMITER_DB=2 # Separate from cache/queue +``` + +### Creating User Tiers + +```python +# Create tiers via API (superuser only) +POST /api/v1/tiers +{ + "name": "premium", + "description": "Premium subscription with higher limits" +} + +# Assign tier to user +PUT /api/v1/users/{user_id}/tier +{ + "tier_id": 2 +} +``` + +### Setting Rate Limits + +```python +# Create rate limits per tier and endpoint +POST /api/v1/tier/premium/rate_limit +{ + "name": "premium_posts_limit", + "path": "/api/v1/posts", + "limit": 100, # 100 requests + "period": 3600 # per hour +} + +# Different limits for different endpoints +POST /api/v1/tier/free/rate_limit +{ + "name": "free_ai_limit", + "path": "/api/v1/ai/generate", + "limit": 5, # 5 requests + "period": 86400 # per day +} +``` + +## Usage Patterns + +### Basic Protection + +```python +# Protect all endpoints in a router +router = APIRouter(dependencies=[Depends(rate_limiter_dependency)]) + +@router.get("/users") +async def get_users(): + """Rate limited based on user tier.""" + pass + +@router.post("/posts") +async def create_post(): + """Rate limited based on user tier.""" + pass +``` + +### Selective Protection + +```python +# Protect only specific endpoints +@router.get("/public-data") +async def get_public_data(): + """No rate limiting - public endpoint.""" + pass + +@router.post("/premium-feature", dependencies=[Depends(rate_limiter_dependency)]) +async def premium_feature(): + """Rate limited - premium feature.""" + pass +``` + +### Custom Error Handling + +```python +from app.core.exceptions.http_exceptions import RateLimitException + +@app.exception_handler(RateLimitException) +async def rate_limit_handler(request: Request, exc: RateLimitException): + """Custom rate limit error response.""" + return JSONResponse( + status_code=429, + content={ + "error": "Rate limit exceeded", + "message": "Too many requests. Please try again later.", + "retry_after": 60 # Suggest retry time + }, + headers={"Retry-After": "60"} + ) +``` + +## Monitoring and Analytics + +### Rate Limit Metrics + +```python +@router.get("/admin/rate-limit-stats") +async def get_rate_limit_stats(): + """Monitor rate limiting effectiveness.""" + + # Get Redis statistics + redis_info = await rate_limiter.client.info() + + # Count current rate limit keys + pattern = "ratelimit:*" + keys = await rate_limiter.client.keys(pattern) + + # Analyze by endpoint + endpoint_stats = {} + for key in keys: + parts = key.split(":") + if len(parts) >= 3: + endpoint = parts[2] + endpoint_stats[endpoint] = endpoint_stats.get(endpoint, 0) + 1 + + return { + "total_active_limits": len(keys), + "redis_memory_usage": redis_info.get("used_memory_human"), + "endpoint_stats": endpoint_stats + } +``` + +### User Analytics + +```python +async def analyze_user_usage(user_id: int, days: int = 7): + """Analyze user's API usage patterns.""" + + # This would require additional logging/analytics + # implementation to track request patterns + + return { + "user_id": user_id, + "tier": "premium", + "requests_last_7_days": 2540, + "average_requests_per_day": 363, + "top_endpoints": [ + {"path": "/api/v1/posts", "count": 1200}, + {"path": "/api/v1/users", "count": 800}, + {"path": "/api/v1/ai/generate", "count": 540} + ], + "rate_limit_hits": 12, # Times user hit rate limits + "suggested_tier": "enterprise" # Based on usage patterns + } +``` + +## Best Practices + +### Rate Limit Design + +```python +# Design limits based on resource cost +expensive_endpoints = { + "/api/v1/ai/generate": {"limit": 10, "period": 3600}, # AI is expensive + "/api/v1/reports/export": {"limit": 3, "period": 86400}, # Export is heavy + "/api/v1/bulk/import": {"limit": 1, "period": 3600}, # Import is intensive +} + +# More generous limits for lightweight endpoints +lightweight_endpoints = { + "/api/v1/users/me": {"limit": 1000, "period": 3600}, # Profile access + "/api/v1/posts": {"limit": 300, "period": 3600}, # Content browsing + "/api/v1/search": {"limit": 500, "period": 3600}, # Search queries +} +``` + +### Production Considerations + +```python +# Use separate Redis database for rate limiting +REDIS_RATE_LIMITER_DB=2 # Isolate from cache and queues + +# Set appropriate Redis memory policies +# maxmemory-policy volatile-lru # Remove expired rate limit keys first + +# Monitor Redis memory usage +# Rate limit keys can accumulate quickly under high load + +# Consider rate limit key cleanup +async def cleanup_expired_rate_limits(): + """Clean up expired rate limit keys.""" + pattern = "ratelimit:*" + keys = await redis_client.keys(pattern) + + for key in keys: + ttl = await redis_client.ttl(key) + if ttl == -2: # Key expired but not cleaned up + await redis_client.delete(key) +``` + +### Security Considerations + +```python +# Rate limit by IP for unauthenticated users +if not user: + user_id = request.client.host if request.client else "unknown" + limit, period = DEFAULT_LIMIT, DEFAULT_PERIOD + +# Prevent rate limit enumeration attacks +# Don't expose exact remaining requests in error messages + +# Use progressive delays for repeated violations +# Consider temporary bans for severe abuse + +# Log rate limit violations for security monitoring +if is_limited: + logger.warning( + f"Rate limit exceeded", + extra={ + "user_id": user_id, + "path": path, + "ip": request.client.host if request.client else "unknown", + "user_agent": request.headers.get("user-agent") + } + ) +``` + +## Common Use Cases + +### API Monetization + +```python +# Different tiers for different pricing levels +tiers = { + "free": {"daily_requests": 1000, "cost": 0}, + "starter": {"daily_requests": 10000, "cost": 29}, + "professional": {"daily_requests": 100000, "cost": 99}, + "enterprise": {"daily_requests": 1000000, "cost": 499} +} +``` + +### Resource Protection + +```python +# Protect expensive operations +@router.post("/ai/generate-image", dependencies=[Depends(rate_limiter_dependency)]) +async def generate_image(): + """Expensive AI operation - heavily rate limited.""" + pass + +@router.get("/data/export", dependencies=[Depends(rate_limiter_dependency)]) +async def export_data(): + """Database-intensive operation - rate limited.""" + pass +``` + +### Abuse Prevention + +```python +# Strict limits on user-generated content +@router.post("/posts", dependencies=[Depends(rate_limiter_dependency)]) +async def create_post(): + """Prevent spam posting.""" + pass + +@router.post("/comments", dependencies=[Depends(rate_limiter_dependency)]) +async def create_comment(): + """Prevent comment spam.""" + pass +``` + +This comprehensive rate limiting system provides robust protection against API abuse while supporting flexible business models through user tiers and granular endpoint controls. \ No newline at end of file diff --git a/docs/user-guide/testing.md b/docs/user-guide/testing.md new file mode 100644 index 0000000..8c16480 --- /dev/null +++ b/docs/user-guide/testing.md @@ -0,0 +1,810 @@ +# Testing Guide + +This guide covers comprehensive testing strategies for the FastAPI boilerplate, including unit tests, integration tests, and API testing. + +## Test Setup + +### Testing Dependencies + +The boilerplate uses these testing libraries: + +- **pytest** - Testing framework +- **pytest-asyncio** - Async test support +- **httpx** - Async HTTP client for API tests +- **pytest-cov** - Coverage reporting +- **faker** - Test data generation + +### Test Configuration + +#### pytest.ini + +```ini +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --strict-markers + --strict-config + --cov=src + --cov-report=term-missing + --cov-report=html + --cov-report=xml + --cov-fail-under=80 +markers = + unit: Unit tests + integration: Integration tests + api: API tests + slow: Slow tests +asyncio_mode = auto +``` + +#### Test Database Setup + +Create `tests/conftest.py`: + +```python +import asyncio +import pytest +import pytest_asyncio +from typing import AsyncGenerator +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from faker import Faker + +from src.app.core.config import settings +from src.app.core.db.database import Base, async_get_db +from src.app.main import app +from src.app.models.user import User +from src.app.models.post import Post +from src.app.core.security import get_password_hash + +# Test database configuration +TEST_DATABASE_URL = "postgresql+asyncpg://test_user:test_pass@localhost:5432/test_db" + +# Create test engine and session +test_engine = create_async_engine(TEST_DATABASE_URL, echo=False) +TestSessionLocal = sessionmaker( + test_engine, class_=AsyncSession, expire_on_commit=False +) + +fake = Faker() + + +@pytest_asyncio.fixture +async def async_session() -> AsyncGenerator[AsyncSession, None]: + """Create a fresh database session for each test.""" + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with TestSessionLocal() as session: + yield session + + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest_asyncio.fixture +async def async_client(async_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + """Create an async HTTP client for testing.""" + def get_test_db(): + return async_session + + app.dependency_overrides[async_get_db] = get_test_db + + async with AsyncClient(app=app, base_url="http://test") as client: + yield client + + app.dependency_overrides.clear() + + +@pytest_asyncio.fixture +async def test_user(async_session: AsyncSession) -> User: + """Create a test user.""" + user = User( + name=fake.name(), + username=fake.user_name(), + email=fake.email(), + hashed_password=get_password_hash("testpassword123"), + is_superuser=False + ) + async_session.add(user) + await async_session.commit() + await async_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def test_superuser(async_session: AsyncSession) -> User: + """Create a test superuser.""" + user = User( + name="Super Admin", + username="superadmin", + email="admin@test.com", + hashed_password=get_password_hash("superpassword123"), + is_superuser=True + ) + async_session.add(user) + await async_session.commit() + await async_session.refresh(user) + return user + + +@pytest_asyncio.fixture +async def test_post(async_session: AsyncSession, test_user: User) -> Post: + """Create a test post.""" + post = Post( + title=fake.sentence(), + content=fake.text(), + created_by_user_id=test_user.id + ) + async_session.add(post) + await async_session.commit() + await async_session.refresh(post) + return post + + +@pytest_asyncio.fixture +async def auth_headers(async_client: AsyncClient, test_user: User) -> dict: + """Get authentication headers for a test user.""" + login_data = { + "username": test_user.username, + "password": "testpassword123" + } + + response = await async_client.post("/api/v1/auth/login", data=login_data) + token = response.json()["access_token"] + + return {"Authorization": f"Bearer {token}"} + + +@pytest_asyncio.fixture +async def superuser_headers(async_client: AsyncClient, test_superuser: User) -> dict: + """Get authentication headers for a test superuser.""" + login_data = { + "username": test_superuser.username, + "password": "superpassword123" + } + + response = await async_client.post("/api/v1/auth/login", data=login_data) + token = response.json()["access_token"] + + return {"Authorization": f"Bearer {token}"} +``` + +## Unit Tests + +### Model Tests + +```python +# tests/test_models.py +import pytest +from datetime import datetime +from src.app.models.user import User +from src.app.models.post import Post + + +@pytest.mark.unit +class TestUserModel: + """Test User model functionality.""" + + async def test_user_creation(self, async_session): + """Test creating a user.""" + user = User( + name="Test User", + username="testuser", + email="test@example.com", + hashed_password="hashed_password" + ) + + async_session.add(user) + await async_session.commit() + await async_session.refresh(user) + + assert user.id is not None + assert user.name == "Test User" + assert user.username == "testuser" + assert user.email == "test@example.com" + assert user.created_at is not None + assert user.is_superuser is False + assert user.is_deleted is False + + async def test_user_relationships(self, async_session, test_user): + """Test user relationships.""" + post = Post( + title="Test Post", + content="Test content", + created_by_user_id=test_user.id + ) + + async_session.add(post) + await async_session.commit() + + # Test relationship + await async_session.refresh(test_user) + assert len(test_user.posts) == 1 + assert test_user.posts[0].title == "Test Post" + + +@pytest.mark.unit +class TestPostModel: + """Test Post model functionality.""" + + async def test_post_creation(self, async_session, test_user): + """Test creating a post.""" + post = Post( + title="Test Post", + content="This is test content", + created_by_user_id=test_user.id + ) + + async_session.add(post) + await async_session.commit() + await async_session.refresh(post) + + assert post.id is not None + assert post.title == "Test Post" + assert post.content == "This is test content" + assert post.created_by_user_id == test_user.id + assert post.created_at is not None + assert post.is_deleted is False +``` + +### Schema Tests + +```python +# tests/test_schemas.py +import pytest +from pydantic import ValidationError +from src.app.schemas.user import UserCreate, UserRead, UserUpdate +from src.app.schemas.post import PostCreate, PostRead, PostUpdate + + +@pytest.mark.unit +class TestUserSchemas: + """Test User schema validation.""" + + def test_user_create_valid(self): + """Test valid user creation schema.""" + user_data = { + "name": "John Doe", + "username": "johndoe", + "email": "john@example.com", + "password": "SecurePass123!" + } + + user = UserCreate(**user_data) + assert user.name == "John Doe" + assert user.username == "johndoe" + assert user.email == "john@example.com" + assert user.password == "SecurePass123!" + + def test_user_create_invalid_email(self): + """Test invalid email validation.""" + with pytest.raises(ValidationError) as exc_info: + UserCreate( + name="John Doe", + username="johndoe", + email="invalid-email", + password="SecurePass123!" + ) + + errors = exc_info.value.errors() + assert any(error['type'] == 'value_error' for error in errors) + + def test_user_create_short_password(self): + """Test password length validation.""" + with pytest.raises(ValidationError) as exc_info: + UserCreate( + name="John Doe", + username="johndoe", + email="john@example.com", + password="123" + ) + + errors = exc_info.value.errors() + assert any(error['type'] == 'value_error' for error in errors) + + def test_user_update_partial(self): + """Test partial user update.""" + update_data = {"name": "Jane Doe"} + user_update = UserUpdate(**update_data) + + assert user_update.name == "Jane Doe" + assert user_update.username is None + assert user_update.email is None + + +@pytest.mark.unit +class TestPostSchemas: + """Test Post schema validation.""" + + def test_post_create_valid(self): + """Test valid post creation.""" + post_data = { + "title": "Test Post", + "content": "This is a test post content" + } + + post = PostCreate(**post_data) + assert post.title == "Test Post" + assert post.content == "This is a test post content" + + def test_post_create_empty_title(self): + """Test empty title validation.""" + with pytest.raises(ValidationError): + PostCreate( + title="", + content="This is a test post content" + ) + + def test_post_create_long_title(self): + """Test title length validation.""" + with pytest.raises(ValidationError): + PostCreate( + title="x" * 101, # Exceeds max length + content="This is a test post content" + ) +``` + +### CRUD Tests + +```python +# tests/test_crud.py +import pytest +from src.app.crud.crud_users import crud_users +from src.app.crud.crud_posts import crud_posts +from src.app.schemas.user import UserCreate, UserUpdate +from src.app.schemas.post import PostCreate, PostUpdate + + +@pytest.mark.unit +class TestUserCRUD: + """Test User CRUD operations.""" + + async def test_create_user(self, async_session): + """Test creating a user.""" + user_data = UserCreate( + name="CRUD User", + username="cruduser", + email="crud@example.com", + password="password123" + ) + + user = await crud_users.create(db=async_session, object=user_data) + assert user["name"] == "CRUD User" + assert user["username"] == "cruduser" + assert user["email"] == "crud@example.com" + assert "id" in user + + async def test_get_user(self, async_session, test_user): + """Test getting a user.""" + retrieved_user = await crud_users.get( + db=async_session, + id=test_user.id + ) + + assert retrieved_user is not None + assert retrieved_user["id"] == test_user.id + assert retrieved_user["name"] == test_user.name + assert retrieved_user["username"] == test_user.username + + async def test_get_user_by_email(self, async_session, test_user): + """Test getting a user by email.""" + retrieved_user = await crud_users.get( + db=async_session, + email=test_user.email + ) + + assert retrieved_user is not None + assert retrieved_user["email"] == test_user.email + + async def test_update_user(self, async_session, test_user): + """Test updating a user.""" + update_data = UserUpdate(name="Updated Name") + + updated_user = await crud_users.update( + db=async_session, + object=update_data, + id=test_user.id + ) + + assert updated_user["name"] == "Updated Name" + assert updated_user["id"] == test_user.id + + async def test_delete_user(self, async_session, test_user): + """Test soft deleting a user.""" + await crud_users.delete(db=async_session, id=test_user.id) + + # User should be soft deleted + deleted_user = await crud_users.get( + db=async_session, + id=test_user.id, + is_deleted=True + ) + + assert deleted_user is not None + assert deleted_user["is_deleted"] is True + + async def test_get_multi_users(self, async_session): + """Test getting multiple users.""" + # Create multiple users + for i in range(5): + user_data = UserCreate( + name=f"User {i}", + username=f"user{i}", + email=f"user{i}@example.com", + password="password123" + ) + await crud_users.create(db=async_session, object=user_data) + + # Get users with pagination + result = await crud_users.get_multi( + db=async_session, + offset=0, + limit=3 + ) + + assert len(result["data"]) == 3 + assert result["total_count"] == 5 + assert result["has_more"] is True + + +@pytest.mark.unit +class TestPostCRUD: + """Test Post CRUD operations.""" + + async def test_create_post(self, async_session, test_user): + """Test creating a post.""" + post_data = PostCreate( + title="Test Post", + content="This is test content" + ) + + post = await crud_posts.create( + db=async_session, + object=post_data, + created_by_user_id=test_user.id + ) + + assert post["title"] == "Test Post" + assert post["content"] == "This is test content" + assert post["created_by_user_id"] == test_user.id + + async def test_get_posts_by_user(self, async_session, test_user): + """Test getting posts by user.""" + # Create multiple posts + for i in range(3): + post_data = PostCreate( + title=f"Post {i}", + content=f"Content {i}" + ) + await crud_posts.create( + db=async_session, + object=post_data, + created_by_user_id=test_user.id + ) + + # Get posts by user + result = await crud_posts.get_multi( + db=async_session, + created_by_user_id=test_user.id + ) + + assert len(result["data"]) == 3 + assert result["total_count"] == 3 +``` + +## Integration Tests + +### API Endpoint Tests + +```python +# tests/test_api_users.py +import pytest +from httpx import AsyncClient + + +@pytest.mark.integration +class TestUserAPI: + """Test User API endpoints.""" + + async def test_create_user(self, async_client: AsyncClient): + """Test user creation endpoint.""" + user_data = { + "name": "New User", + "username": "newuser", + "email": "new@example.com", + "password": "SecurePass123!" + } + + response = await async_client.post("/api/v1/users", json=user_data) + assert response.status_code == 201 + + data = response.json() + assert data["name"] == "New User" + assert data["username"] == "newuser" + assert data["email"] == "new@example.com" + assert "hashed_password" not in data + assert "id" in data + + async def test_create_user_duplicate_email(self, async_client: AsyncClient, test_user): + """Test creating user with duplicate email.""" + user_data = { + "name": "Duplicate User", + "username": "duplicateuser", + "email": test_user.email, # Use existing email + "password": "SecurePass123!" + } + + response = await async_client.post("/api/v1/users", json=user_data) + assert response.status_code == 409 # Conflict + + async def test_get_users(self, async_client: AsyncClient): + """Test getting users list.""" + response = await async_client.get("/api/v1/users") + assert response.status_code == 200 + + data = response.json() + assert "data" in data + assert "total_count" in data + assert "has_more" in data + assert isinstance(data["data"], list) + + async def test_get_user_by_id(self, async_client: AsyncClient, test_user): + """Test getting specific user.""" + response = await async_client.get(f"/api/v1/users/{test_user.id}") + assert response.status_code == 200 + + data = response.json() + assert data["id"] == test_user.id + assert data["name"] == test_user.name + assert data["username"] == test_user.username + + async def test_get_user_not_found(self, async_client: AsyncClient): + """Test getting non-existent user.""" + response = await async_client.get("/api/v1/users/99999") + assert response.status_code == 404 + + async def test_update_user_authorized(self, async_client: AsyncClient, test_user, auth_headers): + """Test updating user with proper authorization.""" + update_data = {"name": "Updated Name"} + + response = await async_client.patch( + f"/api/v1/users/{test_user.id}", + json=update_data, + headers=auth_headers + ) + assert response.status_code == 200 + + data = response.json() + assert data["name"] == "Updated Name" + assert data["id"] == test_user.id + + async def test_update_user_unauthorized(self, async_client: AsyncClient, test_user): + """Test updating user without authorization.""" + update_data = {"name": "Updated Name"} + + response = await async_client.patch( + f"/api/v1/users/{test_user.id}", + json=update_data + ) + assert response.status_code == 401 + + async def test_delete_user_superuser(self, async_client: AsyncClient, test_user, superuser_headers): + """Test deleting user as superuser.""" + response = await async_client.delete( + f"/api/v1/users/{test_user.id}", + headers=superuser_headers + ) + assert response.status_code == 200 + + async def test_delete_user_forbidden(self, async_client: AsyncClient, test_user, auth_headers): + """Test deleting user without superuser privileges.""" + response = await async_client.delete( + f"/api/v1/users/{test_user.id}", + headers=auth_headers + ) + assert response.status_code == 403 + + +@pytest.mark.integration +class TestAuthAPI: + """Test Authentication API endpoints.""" + + async def test_login_success(self, async_client: AsyncClient, test_user): + """Test successful login.""" + login_data = { + "username": test_user.username, + "password": "testpassword123" + } + + response = await async_client.post("/api/v1/auth/login", data=login_data) + assert response.status_code == 200 + + data = response.json() + assert "access_token" in data + assert "refresh_token" in data + assert data["token_type"] == "bearer" + + async def test_login_invalid_credentials(self, async_client: AsyncClient, test_user): + """Test login with invalid credentials.""" + login_data = { + "username": test_user.username, + "password": "wrongpassword" + } + + response = await async_client.post("/api/v1/auth/login", data=login_data) + assert response.status_code == 401 + + async def test_get_current_user(self, async_client: AsyncClient, test_user, auth_headers): + """Test getting current user information.""" + response = await async_client.get("/api/v1/auth/me", headers=auth_headers) + assert response.status_code == 200 + + data = response.json() + assert data["id"] == test_user.id + assert data["username"] == test_user.username + + async def test_refresh_token(self, async_client: AsyncClient, test_user): + """Test token refresh.""" + # First login to get refresh token + login_data = { + "username": test_user.username, + "password": "testpassword123" + } + + login_response = await async_client.post("/api/v1/auth/login", data=login_data) + refresh_token = login_response.json()["refresh_token"] + + # Use refresh token to get new access token + refresh_response = await async_client.post( + "/api/v1/auth/refresh", + headers={"Authorization": f"Bearer {refresh_token}"} + ) + + assert refresh_response.status_code == 200 + data = refresh_response.json() + assert "access_token" in data +``` + +## Running Tests + +### Basic Test Commands + +```bash +# Run all tests +uv run pytest + +# Run specific test categories +uv run pytest -m unit +uv run pytest -m integration +uv run pytest -m api + +# Run tests with coverage +uv run pytest --cov=src --cov-report=html + +# Run tests in parallel +uv run pytest -n auto + +# Run specific test file +uv run pytest tests/test_api_users.py + +# Run with verbose output +uv run pytest -v + +# Run tests matching pattern +uv run pytest -k "test_user" + +# Run tests and stop on first failure +uv run pytest -x + +# Run slow tests +uv run pytest -m slow +``` + +### Test Environment Setup + +```bash +# Set up test database +createdb test_db + +# Run tests with specific environment +ENVIRONMENT=testing uv run pytest + +# Run tests with debug output +uv run pytest -s --log-cli-level=DEBUG +``` + +## Testing Best Practices + +### Test Organization + +- **Separate concerns**: Unit tests for business logic, integration tests for API endpoints +- **Use fixtures**: Create reusable test data and setup +- **Test isolation**: Each test should be independent +- **Clear naming**: Test names should describe what they're testing + +### Test Data + +- **Use factories**: Create test data programmatically +- **Avoid hardcoded values**: Use variables and constants +- **Clean up**: Ensure tests don't leave data behind +- **Realistic data**: Use faker or similar libraries for realistic test data + +### Assertions + +- **Specific assertions**: Test specific behaviors, not just "it works" +- **Multiple assertions**: Test all relevant aspects of the response +- **Error cases**: Test error conditions and edge cases +- **Performance**: Include performance tests for critical paths + +### Mocking + +```python +# Example of mocking external dependencies +from unittest.mock import patch, AsyncMock + +@pytest.mark.unit +async def test_external_api_call(): + """Test function that calls external API.""" + with patch('src.app.services.external_api.make_request') as mock_request: + mock_request.return_value = {"status": "success"} + + result = await some_function_that_calls_external_api() + + assert result["status"] == "success" + mock_request.assert_called_once() +``` + +### Continuous Integration + +```yaml +# .github/workflows/test.yml +name: Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_pass + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Install dependencies + run: | + pip install uv + uv sync + + - name: Run tests + run: uv run pytest --cov=src --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml +``` + +This testing guide provides comprehensive coverage of testing strategies for the FastAPI boilerplate, ensuring reliable and maintainable code. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..3abdf42 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,159 @@ +site_name: FastAPI Boilerplate +site_description: A production-ready FastAPI boilerplate with async support, JWT authentication, Redis caching, and more. +site_author: Benav Labs +site_url: https://github.com/benavlabs/fastapi-boilerplate + +theme: + name: material + font: + text: Ubuntu + logo: assets/FastAPI-boilerplate.png + favicon: assets/FastAPI-boilerplate.png + features: + - navigation.instant + - navigation.instant.prefetch + - navigation.tabs + - navigation.indexes + - search.suggest + - content.code.copy + - content.code.annotate + - navigation.top + - navigation.footer + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: custom + accent: custom + toggle: + icon: material/brightness-7 + name: Switch to dark mode + + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: custom + accent: custom + toggle: + icon: material/brightness-4 + name: Switch to light mode + +plugins: + - search + - mkdocstrings: + handlers: + python: + rendering: + show_source: true + +nav: + - Home: index.md + - Getting Started: + - Overview: getting-started/index.md + - Installation: getting-started/installation.md + - Configuration: getting-started/configuration.md + - First Run: getting-started/first-run.md + - User Guide: + - Overview: user-guide/index.md + - Project Structure: user-guide/project-structure.md + - Configuration: + - Overview: user-guide/configuration/index.md + - Environment Variables: user-guide/configuration/environment-variables.md + - Settings Classes: user-guide/configuration/settings-classes.md + - Docker Setup: user-guide/configuration/docker-setup.md + - Environment-Specific: user-guide/configuration/environment-specific.md + - Database: + - Overview: user-guide/database/index.md + - Models: user-guide/database/models.md + - Schemas: user-guide/database/schemas.md + - CRUD Operations: user-guide/database/crud.md + - Migrations: user-guide/database/migrations.md + - API: + - Overview: user-guide/api/index.md + - Endpoints: user-guide/api/endpoints.md + - Pagination: user-guide/api/pagination.md + - Exceptions: user-guide/api/exceptions.md + - Versioning: user-guide/api/versioning.md + - Authentication: + - Overview: user-guide/authentication/index.md + - JWT Tokens: user-guide/authentication/jwt-tokens.md + - User Management: user-guide/authentication/user-management.md + - Permissions: user-guide/authentication/permissions.md + - Admin Panel: + - user-guide/admin-panel/index.md + - Configuration: user-guide/admin-panel/configuration.md + - Adding Models: user-guide/admin-panel/adding-models.md + - User Management: user-guide/admin-panel/user-management.md + - Caching: + - Overview: user-guide/caching/index.md + - Redis Cache: user-guide/caching/redis-cache.md + - Client Cache: user-guide/caching/client-cache.md + - Cache Strategies: user-guide/caching/cache-strategies.md + - Background Tasks: user-guide/background-tasks/index.md + - Rate Limiting: user-guide/rate-limiting/index.md + - Development: user-guide/development.md + - Production: user-guide/production.md + - Testing: user-guide/testing.md + - Community: community.md + # - Examples: + # - Overview: examples/index.md + # - Basic CRUD: examples/basic-crud.md + # - Authentication Flow: examples/authentication-flow.md + # - Background Job Workflow: examples/background-job-workflow.md + # - Caching Patterns: examples/caching-patterns.md + # - Production Setup: examples/production-setup.md + # - Reference: + # - Overview: reference/index.md + # - API Reference: reference/api-reference.md + # - Configuration Reference: reference/configuration-reference.md + # - Database Schema: reference/database-schema.md + # - Middleware Reference: reference/middleware-reference.md + # - Dependencies Reference: reference/dependencies-reference.md + # - Contributing: + # - Overview: contributing/index.md + # - Development Setup: contributing/development-setup.md + # - Coding Standards: contributing/coding-standards.md + # - Pull Request Process: contributing/pull-request-process.md + # - Testing Guidelines: contributing/testing-guidelines.md + # - Migration Guides: + # - Overview: migration-guides/index.md + # - Version Migrations: migration-guides/from-v1-to-v2.md + # - From Other Frameworks: migration-guides/from-other-frameworks.md + # - FAQ: faq.md + +markdown_extensions: + - admonition + - codehilite + - toc: + permalink: true + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - attr_list + - md_in_html + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/benavlabs/fastapi-boilerplate + - icon: fontawesome/brands/python + link: https://pypi.org/project/fastapi/ + version: + provider: mike + analytics: + provider: google + property: !ENV [GOOGLE_ANALYTICS_KEY, ""] + +extra_css: + - stylesheets/extra.css + +repo_name: benavlabs/fastapi-boilerplate +repo_url: https://github.com/benavlabs/fastapi-boilerplate +edit_uri: edit/main/docs/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b86202d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,123 @@ +[project] +name = "fastapi-boilerplate" +version = "0.1.0" +description = "A fully Async FastAPI boilerplate using SQLAlchemy and Pydantic 2" +authors = [{ name = "Igor Magalhaes", email = "igor.magalhaes.r@gmail.com" }] +license = { text = "MIT" } +readme = "README.md" +requires-python = "~=3.11" +dependencies = [ + "python-dotenv>=1.0.0", + "pydantic[email]>=2.6.1", + "fastapi>=0.109.1", + "uvicorn>=0.27.0", + "uvloop>=0.19.0", + "httptools>=0.6.1", + "uuid>=1.30", + "uuid6>=2024.1.12", + "alembic>=1.13.1", + "asyncpg>=0.29.0", + "SQLAlchemy-Utils>=0.41.1", + "python-jose>=3.3.0", + "SQLAlchemy>=2.0.25", + "python-multipart>=0.0.9", + "greenlet>=2.0.2", + "httpx>=0.26.0", + "pydantic-settings>=2.0.3", + "redis>=5.0.1", + "arq>=0.25.0", + "bcrypt>=4.1.1", + "psycopg2-binary>=2.9.9", + "fastcrud>=0.15.5", + "crudadmin>=0.4.2", + "gunicorn>=23.0.0", + "ruff>=0.11.13", + "mypy>=1.16.0", + "niquests>=3.15.2", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.2", + "pytest-mock>=3.14.0", + "faker>=26.0.0", + "mypy>=1.8.0", + "types-redis>=4.6.0", + "ruff>=0.1.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.sdist] +include = ["src/"] + +[tool.hatch.build.targets.wheel] +include = ["src/"] +packages = ["src"] + +[tool.ruff] +target-version = "py311" +line-length = 120 +fix = true + +[tool.ruff.lint] +select = [ + # https://docs.astral.sh/ruff/rules/#pyflakes-f + "F", # Pyflakes + # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w + "E", # pycodestyle + "W", # Warning + # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4 + # https://docs.astral.sh/ruff/rules/#mccabe-c90 + "C", # Complexity (mccabe+) & comprehensions + # https://docs.astral.sh/ruff/rules/#pyupgrade-up + "UP", # pyupgrade + # https://docs.astral.sh/ruff/rules/#isort-i + "I", # isort +] +ignore = [ + # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w + "E402", # module level import not at top of file + # https://docs.astral.sh/ruff/rules/#pyupgrade-up + "UP006", # use-pep585-annotation + "UP007", # use-pep604-annotation + "E741", # Ambiguous variable name + # "UP035", # deprecated-assertion +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = [ + "F401", # unused import + "F403", # star imports +] + +[tool.ruff.lint.mccabe] +max-complexity = 24 + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.pytest.ini_options] +filterwarnings = [ + "ignore::PendingDeprecationWarning:starlette.formparsers", +] + +[dependency-groups] +dev = [ + "openapi-generator-cli>=7.16.0", + "pytest-asyncio>=1.0.0", +] + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true +mypy_path = "src" +explicit_package_bases = true + +[[tool.mypy.overrides]] +module = "src.app.*" +disallow_untyped_defs = true diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/alembic.ini b/src/alembic.ini new file mode 100644 index 0000000..07489da --- /dev/null +++ b/src/alembic.ini @@ -0,0 +1,116 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/src/app/__init__.py b/src/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/admin/__init__.py b/src/app/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/admin/initialize.py b/src/app/admin/initialize.py new file mode 100644 index 0000000..d1531d8 --- /dev/null +++ b/src/app/admin/initialize.py @@ -0,0 +1,53 @@ +from typing import Optional + +from crudadmin import CRUDAdmin + +from ..core.config import EnvironmentOption, settings +from ..core.db.database import async_get_db +from .views import register_admin_views + + +def create_admin_interface() -> Optional[CRUDAdmin]: + """Create and configure the admin interface.""" + if not settings.CRUD_ADMIN_ENABLED: + return None + + session_backend = "memory" + redis_config = None + + if settings.CRUD_ADMIN_REDIS_ENABLED: + session_backend = "redis" + redis_config = { + "host": settings.CRUD_ADMIN_REDIS_HOST, + "port": settings.CRUD_ADMIN_REDIS_PORT, + "db": settings.CRUD_ADMIN_REDIS_DB, + "password": settings.CRUD_ADMIN_REDIS_PASSWORD if settings.CRUD_ADMIN_REDIS_PASSWORD != "None" else None, + } + + admin = CRUDAdmin( + session=async_get_db, + SECRET_KEY=settings.SECRET_KEY.get_secret_value(), + mount_path=settings.CRUD_ADMIN_MOUNT_PATH, + session_backend=session_backend, + redis_config=redis_config, + allowed_ips=settings.CRUD_ADMIN_ALLOWED_IPS_LIST if settings.CRUD_ADMIN_ALLOWED_IPS_LIST else None, + allowed_networks=settings.CRUD_ADMIN_ALLOWED_NETWORKS_LIST + if settings.CRUD_ADMIN_ALLOWED_NETWORKS_LIST + else None, + max_sessions_per_user=settings.CRUD_ADMIN_MAX_SESSIONS, + session_timeout_minutes=settings.CRUD_ADMIN_SESSION_TIMEOUT, + secure_cookies=settings.SESSION_SECURE_COOKIES, + enforce_https=settings.ENVIRONMENT == EnvironmentOption.PRODUCTION, + track_events=settings.CRUD_ADMIN_TRACK_EVENTS, + track_sessions_in_db=settings.CRUD_ADMIN_TRACK_SESSIONS, + initial_admin={ + "username": settings.ADMIN_USERNAME, + "password": settings.ADMIN_PASSWORD, + } + if settings.ADMIN_USERNAME and settings.ADMIN_PASSWORD + else None, + ) + + register_admin_views(admin) + + return admin diff --git a/src/app/admin/views.py b/src/app/admin/views.py new file mode 100644 index 0000000..d899934 --- /dev/null +++ b/src/app/admin/views.py @@ -0,0 +1,44 @@ +from typing import Annotated + +from crudadmin import CRUDAdmin +from crudadmin.admin_interface.model_view import PasswordTransformer +from pydantic import BaseModel, Field + +from ..core.security import get_password_hash +from ..models.user import User +from ..schemas.user import UserCreate, UserCreateInternal, UserUpdate + + +class PostCreateAdmin(BaseModel): + title: Annotated[str, Field(min_length=2, max_length=30, examples=["This is my post"])] + text: Annotated[str, Field(min_length=1, max_length=63206, examples=["This is the content of my post."])] + created_by_user_id: int + media_url: Annotated[ + str | None, + Field(pattern=r"^(https?|ftp)://[^\s/$.?#].[^\s]*$", examples=["https://www.postimageurl.com"], default=None), + ] + + +def register_admin_views(admin: CRUDAdmin) -> None: + """Register all models and their schemas with the admin interface. + + This function adds all available models to the admin interface with appropriate + schemas and permissions. + """ + + password_transformer = PasswordTransformer( + password_field="password", + hashed_field="hashed_password", + hash_function=get_password_hash, + required_fields=["name", "username", "email"], + ) + + admin.add_view( + model=User, + create_schema=UserCreate, + update_schema=UserUpdate, + update_internal_schema=UserCreateInternal, + password_transformer=password_transformer, + allowed_actions={"view", "create", "update"}, + ) + diff --git a/src/app/api/__init__.py b/src/app/api/__init__.py new file mode 100644 index 0000000..0b6c856 --- /dev/null +++ b/src/app/api/__init__.py @@ -0,0 +1,6 @@ +from fastapi import APIRouter + +from ..api.v1 import router as v1_router + +router = APIRouter(prefix="/api") +router.include_router(v1_router) diff --git a/src/app/api/dependencies.py b/src/app/api/dependencies.py new file mode 100644 index 0000000..2043207 --- /dev/null +++ b/src/app/api/dependencies.py @@ -0,0 +1,80 @@ +from typing import Annotated, Any + +from fastapi import Depends, HTTPException, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from ..core.config import settings +from ..core.db.database import async_get_db +from ..core.exceptions.http_exceptions import ForbiddenException, UnauthorizedException +from ..core.logger import logging +from ..core.security import TokenType, oauth2_scheme, verify_token +from ..crud.crud_users import crud_users +from ..lib.tbank_client.client import TBankClient + +logger = logging.getLogger(__name__) + +DEFAULT_LIMIT = settings.DEFAULT_RATE_LIMIT_LIMIT +DEFAULT_PERIOD = settings.DEFAULT_RATE_LIMIT_PERIOD + + +async def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], db: Annotated[AsyncSession, Depends(async_get_db)] +) -> dict[str, Any] | None: + token_data = await verify_token(token, TokenType.ACCESS, db) + if token_data is None: + raise UnauthorizedException("User not authenticated.") + + if "@" in token_data.username_or_email: + user = await crud_users.get(db=db, email=token_data.username_or_email, is_deleted=False) + else: + user = await crud_users.get(db=db, username=token_data.username_or_email, is_deleted=False) + + if user: + if hasattr(user, 'model_dump'): + return user.model_dump() + else: + return user + + raise UnauthorizedException("User not authenticated.") + + +async def get_optional_user(request: Request, db: AsyncSession = Depends(async_get_db)) -> dict | None: + token = request.headers.get("Authorization") + if not token: + return None + + try: + token_type, _, token_value = token.partition(" ") + if token_type.lower() != "bearer" or not token_value: + return None + + token_data = await verify_token(token_value, TokenType.ACCESS, db) + if token_data is None: + return None + + return await get_current_user(token_value, db=db) + + except HTTPException as http_exc: + if http_exc.status_code != 401: + logger.error(f"Unexpected HTTPException in get_optional_user: {http_exc.detail}") + return None + + except Exception as exc: + logger.error(f"Unexpected error in get_optional_user: {exc}") + return None + + +async def get_current_superuser(current_user: Annotated[dict, Depends(get_current_user)]) -> dict: + if not current_user["is_superuser"]: + raise ForbiddenException("You do not have enough privileges.") + + return current_user + + +async def get_tbank_client(): + + api_key = settings.TBANK_API_KEY + if not api_key: + raise HTTPException(status_code=500, detail="TBank API key is not configured") + client = TBankClient(api_key=api_key) + return client diff --git a/src/app/api/v1/__init__.py b/src/app/api/v1/__init__.py new file mode 100644 index 0000000..2d9bf63 --- /dev/null +++ b/src/app/api/v1/__init__.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +from .login import router as login_router +from .logout import router as logout_router +from .tasks import router as tasks_router +from .users import router as users_router +from .counterparty import router as counterparty_router + +router = APIRouter(prefix="/v1") +router.include_router(login_router) +router.include_router(logout_router) +router.include_router(users_router) +router.include_router(counterparty_router) diff --git a/src/app/api/v1/counterparty.py b/src/app/api/v1/counterparty.py new file mode 100644 index 0000000..5f8709a --- /dev/null +++ b/src/app/api/v1/counterparty.py @@ -0,0 +1,20 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from ..dependencies import get_tbank_client +from ...lib import TBankClient + +router = APIRouter(tags=["counterparty"], prefix="/counterparty") + +tbank_client_dependency = Annotated[TBankClient, Depends(get_tbank_client)] + + +@router.get( + "/excerpt/by-inn", +) +async def get_counterparty_excerpt_by_inn( + inn: str, + tbank_client: tbank_client_dependency, +): + return await tbank_client.get_counterparty_excerpt_by_inn(inn) \ No newline at end of file diff --git a/src/app/api/v1/login.py b/src/app/api/v1/login.py new file mode 100644 index 0000000..e784731 --- /dev/null +++ b/src/app/api/v1/login.py @@ -0,0 +1,58 @@ +from datetime import timedelta +from typing import Annotated + +from fastapi import APIRouter, Depends, Request, Response +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.config import settings +from ...core.db.database import async_get_db +from ...core.exceptions.http_exceptions import UnauthorizedException +from ...core.schemas import Token +from ...core.security import ( + ACCESS_TOKEN_EXPIRE_MINUTES, + TokenType, + authenticate_user, + create_access_token, + create_refresh_token, + verify_token, +) + +router = APIRouter(tags=["login"]) + + +@router.post("/login", response_model=Token) +async def login_for_access_token( + response: Response, + form_data: Annotated[OAuth2PasswordRequestForm, Depends()], + db: Annotated[AsyncSession, Depends(async_get_db)], +) -> dict[str, str]: + user = await authenticate_user(username_or_email=form_data.username, password=form_data.password, db=db) + if not user: + raise UnauthorizedException("Wrong username, email or password.") + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = await create_access_token(data={"sub": user["username"]}, expires_delta=access_token_expires) + + refresh_token = await create_refresh_token(data={"sub": user["username"]}) + max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60 + + response.set_cookie( + key="refresh_token", value=refresh_token, httponly=True, secure=True, samesite="lax", max_age=max_age + ) + + return {"access_token": access_token, "token_type": "bearer"} + + +@router.post("/refresh") +async def refresh_access_token(request: Request, db: AsyncSession = Depends(async_get_db)) -> dict[str, str]: + refresh_token = request.cookies.get("refresh_token") + if not refresh_token: + raise UnauthorizedException("Refresh token missing.") + + user_data = await verify_token(refresh_token, TokenType.REFRESH, db) + if not user_data: + raise UnauthorizedException("Invalid refresh token.") + + new_access_token = await create_access_token(data={"sub": user_data.username_or_email}) + return {"access_token": new_access_token, "token_type": "bearer"} diff --git a/src/app/api/v1/logout.py b/src/app/api/v1/logout.py new file mode 100644 index 0000000..b4dafc8 --- /dev/null +++ b/src/app/api/v1/logout.py @@ -0,0 +1,31 @@ +from typing import Optional + +from fastapi import APIRouter, Cookie, Depends, Response +from jose import JWTError +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.db.database import async_get_db +from ...core.exceptions.http_exceptions import UnauthorizedException +from ...core.security import blacklist_tokens, oauth2_scheme + +router = APIRouter(tags=["login"]) + + +@router.post("/logout") +async def logout( + response: Response, + access_token: str = Depends(oauth2_scheme), + refresh_token: Optional[str] = Cookie(None, alias="refresh_token"), + db: AsyncSession = Depends(async_get_db), +) -> dict[str, str]: + try: + if not refresh_token: + raise UnauthorizedException("Refresh token not found") + + await blacklist_tokens(access_token=access_token, refresh_token=refresh_token, db=db) + response.delete_cookie(key="refresh_token") + + return {"message": "Logged out successfully"} + + except JWTError: + raise UnauthorizedException("Invalid token.") diff --git a/src/app/api/v1/tasks.py b/src/app/api/v1/tasks.py new file mode 100644 index 0000000..6e03495 --- /dev/null +++ b/src/app/api/v1/tasks.py @@ -0,0 +1,58 @@ +from typing import Any + +from arq.jobs import Job as ArqJob +from fastapi import APIRouter, HTTPException + +from ...core.utils import queue +from ...schemas.job import Job + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.post("/task", response_model=Job, status_code=201) +async def create_task(message: str) -> dict[str, str]: + """Create a new background task. + + Parameters + ---------- + message: str + The message or data to be processed by the task. + + Returns + ------- + dict[str, str] + A dictionary containing the ID of the created task. + """ + if queue.pool is None: + raise HTTPException(status_code=503, detail="Queue is not available") + + job = await queue.pool.enqueue_job("sample_background_task", message) + if job is None: + raise HTTPException(status_code=500, detail="Failed to create task") + + return {"id": job.job_id} + + +@router.get("/task/{task_id}") +async def get_task(task_id: str) -> dict[str, Any] | None: + """Get information about a specific background task. + + Parameters + ---------- + task_id: str + The ID of the task. + + Returns + ------- + Optional[dict[str, Any]] + A dictionary containing information about the task if found, or None otherwise. + """ + if queue.pool is None: + raise HTTPException(status_code=503, detail="Queue is not available") + + job = ArqJob(task_id, queue.pool) + job_info = await job.info() + if job_info is None: + return None + + return job_info.__dict__ diff --git a/src/app/api/v1/users.py b/src/app/api/v1/users.py new file mode 100644 index 0000000..3ae8173 --- /dev/null +++ b/src/app/api/v1/users.py @@ -0,0 +1,145 @@ +from typing import Annotated, Any, cast + +from fastapi import APIRouter, Depends, Request +from fastcrud.paginated import PaginatedListResponse, compute_offset, paginated_response +from sqlalchemy.ext.asyncio import AsyncSession + +from ...api.dependencies import get_current_superuser, get_current_user +from ...core.db.database import async_get_db +from ...core.exceptions.http_exceptions import DuplicateValueException, ForbiddenException, NotFoundException +from ...core.security import blacklist_token, get_password_hash, oauth2_scheme +from ...crud.crud_users import crud_users +from ...schemas.user import UserCreate, UserCreateInternal, UserRead, UserUpdate + +router = APIRouter(tags=["users"]) + + +@router.post("/user", response_model=UserRead, status_code=201) +async def write_user( + request: Request, user: UserCreate, db: Annotated[AsyncSession, Depends(async_get_db)] +) -> UserRead: + email_row = await crud_users.exists(db=db, email=user.email) + if email_row: + raise DuplicateValueException("Email is already registered") + + username_row = await crud_users.exists(db=db, username=user.username) + if username_row: + raise DuplicateValueException("Username not available") + + user_internal_dict = user.model_dump() + user_internal_dict["hashed_password"] = get_password_hash(password=user_internal_dict["password"]) + del user_internal_dict["password"] + + user_internal = UserCreateInternal(**user_internal_dict) + created_user = await crud_users.create(db=db, object=user_internal) + + user_read = await crud_users.get(db=db, id=created_user.id, schema_to_select=UserRead) + if user_read is None: + raise NotFoundException("Created user not found") + + return cast(UserRead, user_read) + + +@router.get("/users", response_model=PaginatedListResponse[UserRead]) +async def read_users( + request: Request, db: Annotated[AsyncSession, Depends(async_get_db)], page: int = 1, items_per_page: int = 10 +) -> dict: + users_data = await crud_users.get_multi( + db=db, + offset=compute_offset(page, items_per_page), + limit=items_per_page, + is_deleted=False, + ) + + response: dict[str, Any] = paginated_response(crud_data=users_data, page=page, items_per_page=items_per_page) + return response + + +@router.get("/user/me/", response_model=UserRead) +async def read_users_me(request: Request, current_user: Annotated[dict, Depends(get_current_user)]) -> dict: + return current_user + + +@router.get("/user/{username}", response_model=UserRead) +async def read_user(request: Request, username: str, db: Annotated[AsyncSession, Depends(async_get_db)]) -> UserRead: + db_user = await crud_users.get(db=db, username=username, is_deleted=False, schema_to_select=UserRead) + if db_user is None: + raise NotFoundException("User not found") + + return cast(UserRead, db_user) + + + +@router.patch("/user/{username}") +async def patch_user( + request: Request, + values: UserUpdate, + username: str, + current_user: Annotated[dict, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(async_get_db)], +) -> dict[str, str]: + db_user = await crud_users.get(db=db, username=username) + if db_user is None: + raise NotFoundException("User not found") + + if isinstance(db_user, dict): + db_username = db_user["username"] + db_email = db_user["email"] + else: + db_username = db_user.username + db_email = db_user.email + + if db_username != current_user["username"]: + raise ForbiddenException() + + if values.email is not None and values.email != db_email: + if await crud_users.exists(db=db, email=values.email): + raise DuplicateValueException("Email is already registered") + + if values.username is not None and values.username != db_username: + if await crud_users.exists(db=db, username=values.username): + raise DuplicateValueException("Username not available") + + await crud_users.update(db=db, object=values, username=username) + return {"message": "User updated"} + + +@router.delete("/user/{username}") +async def erase_user( + request: Request, + username: str, + current_user: Annotated[dict, Depends(get_current_user)], + db: Annotated[AsyncSession, Depends(async_get_db)], + token: str = Depends(oauth2_scheme), +) -> dict[str, str]: + db_user = await crud_users.get(db=db, username=username, schema_to_select=UserRead) + if not db_user: + raise NotFoundException("User not found") + + if username != current_user["username"]: + raise ForbiddenException() + + await crud_users.delete(db=db, username=username) + await blacklist_token(token=token, db=db) + return {"message": "User deleted"} + + +@router.delete("/db_user/{username}", dependencies=[Depends(get_current_superuser)]) +async def erase_db_user( + request: Request, + username: str, + db: Annotated[AsyncSession, Depends(async_get_db)], + token: str = Depends(oauth2_scheme), +) -> dict[str, str]: + db_user = await crud_users.exists(db=db, username=username) + if not db_user: + raise NotFoundException("User not found") + + await crud_users.db_delete(db=db, username=username) + await blacklist_token(token=token, db=db) + return {"message": "User deleted from the database"} + + + + + 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..cc30855 --- /dev/null +++ b/src/app/core/config.py @@ -0,0 +1,154 @@ +import os +from enum import Enum + +from pydantic import SecretStr +from pydantic_settings import BaseSettings +from starlette.config import Config + +current_file_dir = os.path.dirname(os.path.realpath(__file__)) +env_path = os.path.join(current_file_dir, "..", "..", ".env") +config = Config(env_path) + + +class AppSettings(BaseSettings): + APP_NAME: str = config("APP_NAME", default="FastAPI app") + APP_DESCRIPTION: str | None = config("APP_DESCRIPTION", default=None) + APP_VERSION: str | None = config("APP_VERSION", default=None) + LICENSE_NAME: str | None = config("LICENSE", default=None) + CONTACT_NAME: str | None = config("CONTACT_NAME", default=None) + CONTACT_EMAIL: str | None = config("CONTACT_EMAIL", default=None) + + +class CryptSettings(BaseSettings): + SECRET_KEY: SecretStr = config("SECRET_KEY", cast=SecretStr) + ALGORITHM: str = config("ALGORITHM", default="HS256") + ACCESS_TOKEN_EXPIRE_MINUTES: int = config("ACCESS_TOKEN_EXPIRE_MINUTES", default=30) + REFRESH_TOKEN_EXPIRE_DAYS: int = config("REFRESH_TOKEN_EXPIRE_DAYS", default=7) + + +class DatabaseSettings(BaseSettings): + pass + + +class SQLiteSettings(DatabaseSettings): + SQLITE_URI: str = config("SQLITE_URI", default="./sql_app.db") + SQLITE_SYNC_PREFIX: str = config("SQLITE_SYNC_PREFIX", default="sqlite:///") + SQLITE_ASYNC_PREFIX: str = config("SQLITE_ASYNC_PREFIX", default="sqlite+aiosqlite:///") + + +class MySQLSettings(DatabaseSettings): + MYSQL_USER: str = config("MYSQL_USER", default="username") + MYSQL_PASSWORD: str = config("MYSQL_PASSWORD", default="password") + MYSQL_SERVER: str = config("MYSQL_SERVER", default="localhost") + MYSQL_PORT: int = config("MYSQL_PORT", default=5432) + MYSQL_DB: str = config("MYSQL_DB", default="dbname") + MYSQL_URI: str = f"{MYSQL_USER}:{MYSQL_PASSWORD}@{MYSQL_SERVER}:{MYSQL_PORT}/{MYSQL_DB}" + MYSQL_SYNC_PREFIX: str = config("MYSQL_SYNC_PREFIX", default="mysql://") + MYSQL_ASYNC_PREFIX: str = config("MYSQL_ASYNC_PREFIX", default="mysql+aiomysql://") + MYSQL_URL: str | None = config("MYSQL_URL", default=None) + + +class PostgresSettings(DatabaseSettings): + POSTGRES_USER: str = config("POSTGRES_USER", default="postgres") + POSTGRES_PASSWORD: str = config("POSTGRES_PASSWORD", default="postgres") + POSTGRES_SERVER: str = config("POSTGRES_SERVER", default="localhost") + POSTGRES_PORT: int = config("POSTGRES_PORT", default=5432) + POSTGRES_DB: str = config("POSTGRES_DB", default="postgres") + POSTGRES_SYNC_PREFIX: str = config("POSTGRES_SYNC_PREFIX", default="postgresql://") + POSTGRES_ASYNC_PREFIX: str = config("POSTGRES_ASYNC_PREFIX", default="postgresql+asyncpg://") + POSTGRES_URI: str = f"{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_SERVER}:{POSTGRES_PORT}/{POSTGRES_DB}" + POSTGRES_URL: str | None = config("POSTGRES_URL", default=None) + + +class FirstUserSettings(BaseSettings): + ADMIN_NAME: str = config("ADMIN_NAME", default="admin") + ADMIN_EMAIL: str = config("ADMIN_EMAIL", default="admin@admin.com") + ADMIN_USERNAME: str = config("ADMIN_USERNAME", default="admin") + ADMIN_PASSWORD: str = config("ADMIN_PASSWORD", default="!Ch4ng3Th1sP4ssW0rd!") + + +class TestSettings(BaseSettings): ... + + +class RedisCacheSettings(BaseSettings): + REDIS_CACHE_HOST: str = config("REDIS_CACHE_HOST", default="localhost") + REDIS_CACHE_PORT: int = config("REDIS_CACHE_PORT", default=6379) + REDIS_CACHE_URL: str = f"redis://{REDIS_CACHE_HOST}:{REDIS_CACHE_PORT}" + + +class ClientSideCacheSettings(BaseSettings): + CLIENT_CACHE_MAX_AGE: int = config("CLIENT_CACHE_MAX_AGE", default=60) + + +class RedisQueueSettings(BaseSettings): + REDIS_QUEUE_HOST: str = config("REDIS_QUEUE_HOST", default="localhost") + REDIS_QUEUE_PORT: int = config("REDIS_QUEUE_PORT", default=6379) + + +class RedisRateLimiterSettings(BaseSettings): + REDIS_RATE_LIMIT_HOST: str = config("REDIS_RATE_LIMIT_HOST", default="localhost") + REDIS_RATE_LIMIT_PORT: int = config("REDIS_RATE_LIMIT_PORT", default=6379) + REDIS_RATE_LIMIT_URL: str = f"redis://{REDIS_RATE_LIMIT_HOST}:{REDIS_RATE_LIMIT_PORT}" + + +class DefaultRateLimitSettings(BaseSettings): + DEFAULT_RATE_LIMIT_LIMIT: int = config("DEFAULT_RATE_LIMIT_LIMIT", default=10) + DEFAULT_RATE_LIMIT_PERIOD: int = config("DEFAULT_RATE_LIMIT_PERIOD", default=3600) + + +class CRUDAdminSettings(BaseSettings): + CRUD_ADMIN_ENABLED: bool = config("CRUD_ADMIN_ENABLED", default=True) + CRUD_ADMIN_MOUNT_PATH: str = config("CRUD_ADMIN_MOUNT_PATH", default="/admin") + + CRUD_ADMIN_ALLOWED_IPS_LIST: list[str] | None = None + CRUD_ADMIN_ALLOWED_NETWORKS_LIST: list[str] | None = None + CRUD_ADMIN_MAX_SESSIONS: int = config("CRUD_ADMIN_MAX_SESSIONS", default=10) + CRUD_ADMIN_SESSION_TIMEOUT: int = config("CRUD_ADMIN_SESSION_TIMEOUT", default=1440) + SESSION_SECURE_COOKIES: bool = config("SESSION_SECURE_COOKIES", default=True) + + CRUD_ADMIN_TRACK_EVENTS: bool = config("CRUD_ADMIN_TRACK_EVENTS", default=True) + CRUD_ADMIN_TRACK_SESSIONS: bool = config("CRUD_ADMIN_TRACK_SESSIONS", default=True) + + CRUD_ADMIN_REDIS_ENABLED: bool = config("CRUD_ADMIN_REDIS_ENABLED", default=False) + CRUD_ADMIN_REDIS_HOST: str = config("CRUD_ADMIN_REDIS_HOST", default="localhost") + CRUD_ADMIN_REDIS_PORT: int = config("CRUD_ADMIN_REDIS_PORT", default=6379) + CRUD_ADMIN_REDIS_DB: int = config("CRUD_ADMIN_REDIS_DB", default=0) + CRUD_ADMIN_REDIS_PASSWORD: str | None = config("CRUD_ADMIN_REDIS_PASSWORD", default="None") + CRUD_ADMIN_REDIS_SSL: bool = config("CRUD_ADMIN_REDIS_SSL", default=False) + + +class EnvironmentOption(Enum): + LOCAL = "local" + STAGING = "staging" + PRODUCTION = "production" + + +class EnvironmentSettings(BaseSettings): + ENVIRONMENT: EnvironmentOption = config("ENVIRONMENT", default=EnvironmentOption.LOCAL) + + +class TBankSettings(BaseSettings): + TBANK_API_KEY: str = config("TBANK_API_KEY", default="") + TBANK_API_URL: str = config("TBANK_API_URL", default="https://business.tbank.ru/openapi/api") + + +class Settings( + AppSettings, + SQLiteSettings, + PostgresSettings, + CryptSettings, + FirstUserSettings, + TestSettings, + RedisCacheSettings, + ClientSideCacheSettings, + RedisQueueSettings, + RedisRateLimiterSettings, + DefaultRateLimitSettings, + CRUDAdminSettings, + EnvironmentSettings, + TBankSettings, +): + pass + + +settings = Settings() diff --git a/src/app/core/db/__init__.py b/src/app/core/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/core/db/crud_token_blacklist.py b/src/app/core/db/crud_token_blacklist.py new file mode 100644 index 0000000..79e0c9e --- /dev/null +++ b/src/app/core/db/crud_token_blacklist.py @@ -0,0 +1,14 @@ +from fastcrud import FastCRUD + +from ..db.token_blacklist import TokenBlacklist +from ..schemas import TokenBlacklistCreate, TokenBlacklistRead, TokenBlacklistUpdate + +CRUDTokenBlacklist = FastCRUD[ + TokenBlacklist, + TokenBlacklistCreate, + TokenBlacklistUpdate, + TokenBlacklistUpdate, + TokenBlacklistUpdate, + TokenBlacklistRead, +] +crud_token_blacklist = CRUDTokenBlacklist(TokenBlacklist) diff --git a/src/app/core/db/database.py b/src/app/core/db/database.py new file mode 100644 index 0000000..65df22b --- /dev/null +++ b/src/app/core/db/database.py @@ -0,0 +1,26 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio.session import AsyncSession +from sqlalchemy.orm import DeclarativeBase, MappedAsDataclass + +from ..config import settings + + +class Base(DeclarativeBase, MappedAsDataclass): + pass + + +DATABASE_URI = settings.POSTGRES_URI +DATABASE_PREFIX = settings.POSTGRES_ASYNC_PREFIX +DATABASE_URL = f"{DATABASE_PREFIX}{DATABASE_URI}" + + +async_engine = create_async_engine(DATABASE_URL, echo=False, future=True) + +local_session = async_sessionmaker(bind=async_engine, class_=AsyncSession, expire_on_commit=False) + + +async def async_get_db() -> AsyncGenerator[AsyncSession, None]: + async with local_session() as db: + yield db diff --git a/src/app/core/db/models.py b/src/app/core/db/models.py new file mode 100644 index 0000000..ea77406 --- /dev/null +++ b/src/app/core/db/models.py @@ -0,0 +1,27 @@ +import uuid as uuid_pkg +from uuid6 import uuid7 +from datetime import UTC, datetime + +from sqlalchemy import Boolean, DateTime, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + + +class UUIDMixin: + uuid: Mapped[uuid_pkg.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid7, server_default=text("gen_random_uuid()") + ) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime, default=datetime.now(UTC), server_default=text("current_timestamp(0)") + ) + updated_at: Mapped[datetime | None] = mapped_column( + DateTime, nullable=True, onupdate=datetime.now(UTC), server_default=text("current_timestamp(0)") + ) + + +class SoftDeleteMixin: + deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False) diff --git a/src/app/core/db/token_blacklist.py b/src/app/core/db/token_blacklist.py new file mode 100644 index 0000000..6a387ae --- /dev/null +++ b/src/app/core/db/token_blacklist.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from .database import Base + + +class TokenBlacklist(Base): + __tablename__ = "token_blacklist" + + id: Mapped[int] = mapped_column("id", autoincrement=True, nullable=False, unique=True, primary_key=True, init=False) + token: Mapped[str] = mapped_column(String, unique=True, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime) diff --git a/src/app/core/exceptions/__init__.py b/src/app/core/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/core/exceptions/cache_exceptions.py b/src/app/core/exceptions/cache_exceptions.py new file mode 100644 index 0000000..761b887 --- /dev/null +++ b/src/app/core/exceptions/cache_exceptions.py @@ -0,0 +1,16 @@ +class CacheIdentificationInferenceError(Exception): + def __init__(self, message: str = "Could not infer id for resource being cached.") -> None: + self.message = message + super().__init__(self.message) + + +class InvalidRequestError(Exception): + def __init__(self, message: str = "Type of request not supported.") -> None: + self.message = message + super().__init__(self.message) + + +class MissingClientError(Exception): + def __init__(self, message: str = "Client is None.") -> None: + self.message = message + super().__init__(self.message) diff --git a/src/app/core/exceptions/http_exceptions.py b/src/app/core/exceptions/http_exceptions.py new file mode 100644 index 0000000..9afe66a --- /dev/null +++ b/src/app/core/exceptions/http_exceptions.py @@ -0,0 +1,11 @@ +# ruff: noqa +from fastcrud.exceptions.http_exceptions import ( + CustomException, + BadRequestException, + NotFoundException, + ForbiddenException, + UnauthorizedException, + UnprocessableEntityException, + DuplicateValueException, + RateLimitException, +) diff --git a/src/app/core/logger.py b/src/app/core/logger.py new file mode 100644 index 0000000..91b35a1 --- /dev/null +++ b/src/app/core/logger.py @@ -0,0 +1,20 @@ +import logging +import os +from logging.handlers import RotatingFileHandler + +LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs") +if not os.path.exists(LOG_DIR): + os.makedirs(LOG_DIR) + +LOG_FILE_PATH = os.path.join(LOG_DIR, "app.log") + +LOGGING_LEVEL = logging.INFO +LOGGING_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + +logging.basicConfig(level=LOGGING_LEVEL, format=LOGGING_FORMAT) + +file_handler = RotatingFileHandler(LOG_FILE_PATH, maxBytes=10485760, backupCount=5) +file_handler.setLevel(LOGGING_LEVEL) +file_handler.setFormatter(logging.Formatter(LOGGING_FORMAT)) + +logging.getLogger("").addHandler(file_handler) diff --git a/src/app/core/schemas.py b/src/app/core/schemas.py new file mode 100644 index 0000000..28f7ed0 --- /dev/null +++ b/src/app/core/schemas.py @@ -0,0 +1,75 @@ +import uuid as uuid_pkg +from uuid6 import uuid7 +from datetime import UTC, datetime +from typing import Any + +from pydantic import BaseModel, Field, field_serializer + + +class HealthCheck(BaseModel): + name: str + version: str + description: str + + +# -------------- mixins -------------- +class UUIDSchema(BaseModel): + uuid: uuid_pkg.UUID = Field(default_factory=uuid7) + + +class TimestampSchema(BaseModel): + created_at: datetime = Field(default_factory=lambda: datetime.now(UTC).replace(tzinfo=None)) + updated_at: datetime | None = Field(default=None) + + @field_serializer("created_at") + def serialize_dt(self, created_at: datetime | None, _info: Any) -> str | None: + if created_at is not None: + return created_at.isoformat() + + return None + + @field_serializer("updated_at") + def serialize_updated_at(self, updated_at: datetime | None, _info: Any) -> str | None: + if updated_at is not None: + return updated_at.isoformat() + + return None + + +class PersistentDeletion(BaseModel): + deleted_at: datetime | None = Field(default=None) + is_deleted: bool = False + + @field_serializer("deleted_at") + def serialize_dates(self, deleted_at: datetime | None, _info: Any) -> str | None: + if deleted_at is not None: + return deleted_at.isoformat() + + return None + + +# -------------- token -------------- +class Token(BaseModel): + access_token: str + token_type: str + + +class TokenData(BaseModel): + username_or_email: str + + +class TokenBlacklistBase(BaseModel): + token: str + expires_at: datetime + + +class TokenBlacklistRead(TokenBlacklistBase): + id: int + + +class TokenBlacklistCreate(TokenBlacklistBase): + pass + + +class TokenBlacklistUpdate(TokenBlacklistBase): + pass diff --git a/src/app/core/security.py b/src/app/core/security.py new file mode 100644 index 0000000..cca070a --- /dev/null +++ b/src/app/core/security.py @@ -0,0 +1,137 @@ +from datetime import UTC, datetime, timedelta +from enum import Enum +from typing import Any, Literal, cast + +import bcrypt +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from pydantic import SecretStr +from sqlalchemy.ext.asyncio import AsyncSession + +from ..crud.crud_users import crud_users +from .config import settings +from .db.crud_token_blacklist import crud_token_blacklist +from .schemas import TokenBlacklistCreate, TokenData + +SECRET_KEY: SecretStr = settings.SECRET_KEY +ALGORITHM = settings.ALGORITHM +ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES +REFRESH_TOKEN_EXPIRE_DAYS = settings.REFRESH_TOKEN_EXPIRE_DAYS + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/login") + + +class TokenType(str, Enum): + ACCESS = "access" + REFRESH = "refresh" + + +async def verify_password(plain_password: str, hashed_password: str) -> bool: + correct_password: bool = bcrypt.checkpw(plain_password.encode(), hashed_password.encode()) + return correct_password + + +def get_password_hash(password: str) -> str: + hashed_password: str = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + return hashed_password + + +async def authenticate_user(username_or_email: str, password: str, db: AsyncSession) -> dict[str, Any] | Literal[False]: + if "@" in username_or_email: + db_user = await crud_users.get(db=db, email=username_or_email, is_deleted=False) + else: + db_user = await crud_users.get(db=db, username=username_or_email, is_deleted=False) + + if not db_user: + return False + + db_user = cast(dict[str, Any], db_user) + if not await verify_password(password, db_user["hashed_password"]): + return False + + return db_user + + +async def create_access_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + if expires_delta: + expire = datetime.now(UTC).replace(tzinfo=None) + expires_delta + else: + expire = datetime.now(UTC).replace(tzinfo=None) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire, "token_type": TokenType.ACCESS}) + encoded_jwt: str = jwt.encode(to_encode, SECRET_KEY.get_secret_value(), algorithm=ALGORITHM) + return encoded_jwt + + +async def create_refresh_token(data: dict[str, Any], expires_delta: timedelta | None = None) -> str: + to_encode = data.copy() + if expires_delta: + expire = datetime.now(UTC).replace(tzinfo=None) + expires_delta + else: + expire = datetime.now(UTC).replace(tzinfo=None) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire, "token_type": TokenType.REFRESH}) + encoded_jwt: str = jwt.encode(to_encode, SECRET_KEY.get_secret_value(), algorithm=ALGORITHM) + return encoded_jwt + + +async def verify_token(token: str, expected_token_type: TokenType, db: AsyncSession) -> TokenData | None: + """Verify a JWT token and return TokenData if valid. + + Parameters + ---------- + token: str + The JWT token to be verified. + expected_token_type: TokenType + The expected type of token (access or refresh) + db: AsyncSession + Database session for performing database operations. + + Returns + ------- + TokenData | None + TokenData instance if the token is valid, None otherwise. + """ + is_blacklisted = await crud_token_blacklist.exists(db, token=token) + if is_blacklisted: + return None + + try: + payload = jwt.decode(token, SECRET_KEY.get_secret_value(), algorithms=[ALGORITHM]) + username_or_email: str | None = payload.get("sub") + token_type: str | None = payload.get("token_type") + + if username_or_email is None or token_type != expected_token_type: + return None + + return TokenData(username_or_email=username_or_email) + + except JWTError: + return None + + +async def blacklist_tokens(access_token: str, refresh_token: str, db: AsyncSession) -> None: + """Blacklist both access and refresh tokens. + + Parameters + ---------- + access_token: str + The access token to blacklist + refresh_token: str + The refresh token to blacklist + db: AsyncSession + Database session for performing database operations. + """ + for token in [access_token, refresh_token]: + payload = jwt.decode(token, SECRET_KEY.get_secret_value(), algorithms=[ALGORITHM]) + exp_timestamp = payload.get("exp") + if exp_timestamp is not None: + expires_at = datetime.fromtimestamp(exp_timestamp) + await crud_token_blacklist.create(db, object=TokenBlacklistCreate(token=token, expires_at=expires_at)) + + +async def blacklist_token(token: str, db: AsyncSession) -> None: + payload = jwt.decode(token, SECRET_KEY.get_secret_value(), algorithms=[ALGORITHM]) + exp_timestamp = payload.get("exp") + if exp_timestamp is not None: + expires_at = datetime.fromtimestamp(exp_timestamp) + await crud_token_blacklist.create(db, object=TokenBlacklistCreate(token=token, expires_at=expires_at)) diff --git a/src/app/core/setup.py b/src/app/core/setup.py new file mode 100644 index 0000000..1be09ef --- /dev/null +++ b/src/app/core/setup.py @@ -0,0 +1,229 @@ +from collections.abc import AsyncGenerator, Callable +from contextlib import _AsyncGeneratorContextManager, asynccontextmanager +from typing import Any + +import anyio +import anyio.to_thread +import fastapi +import redis.asyncio as redis +from arq import create_pool +from arq.connections import RedisSettings +from fastapi import APIRouter, Depends, FastAPI +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html +from fastapi.openapi.utils import get_openapi + +from ..api.dependencies import get_current_superuser +from ..core.utils.rate_limit import rate_limiter +from ..middleware.client_cache_middleware import ClientCacheMiddleware +from ..models import * # noqa: F403 +from .config import ( + AppSettings, + ClientSideCacheSettings, + DatabaseSettings, + EnvironmentOption, + EnvironmentSettings, + RedisCacheSettings, + RedisQueueSettings, + RedisRateLimiterSettings, + settings, +) +from .db.database import Base +from .db.database import async_engine as engine +from .utils import cache, queue + + +# -------------- database -------------- +async def create_tables_on_startcreate_tables() -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +# -------------- cache -------------- +async def create_redis_cache_pool() -> None: + cache.pool = redis.ConnectionPool.from_url(settings.REDIS_CACHE_URL) + cache.client = redis.Redis.from_pool(cache.pool) # type: ignore + + +async def close_redis_cache_pool() -> None: + if cache.client is not None: + await cache.client.aclose() # type: ignore + + +# -------------- queue -------------- +async def create_redis_queue_pool() -> None: + queue.pool = await create_pool(RedisSettings(host=settings.REDIS_QUEUE_HOST, port=settings.REDIS_QUEUE_PORT)) + + +async def close_redis_queue_pool() -> None: + if queue.pool is not None: + await queue.pool.aclose() # type: ignore + + +# -------------- rate limit -------------- +async def create_redis_rate_limit_pool() -> None: + rate_limiter.initialize(settings.REDIS_RATE_LIMIT_URL) # type: ignore + + +async def close_redis_rate_limit_pool() -> None: + if rate_limiter.client is not None: + await rate_limiter.client.aclose() # type: ignore + + +# -------------- application -------------- +async def set_threadpool_tokens(number_of_tokens: int = 100) -> None: + limiter = anyio.to_thread.current_default_thread_limiter() + limiter.total_tokens = number_of_tokens + + +def lifespan_factory( + settings: ( + DatabaseSettings + | RedisCacheSettings + | AppSettings + | ClientSideCacheSettings + | RedisQueueSettings + | RedisRateLimiterSettings + | EnvironmentSettings + ), + create_tables_on_start: bool = True, +) -> Callable[[FastAPI], _AsyncGeneratorContextManager[Any]]: + """Factory to create a lifespan async context manager for a FastAPI app.""" + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncGenerator: + from asyncio import Event + + initialization_complete = Event() + app.state.initialization_complete = initialization_complete + + await set_threadpool_tokens() + + try: + if isinstance(settings, RedisCacheSettings): + await create_redis_cache_pool() + + if isinstance(settings, RedisQueueSettings): + await create_redis_queue_pool() + + if isinstance(settings, RedisRateLimiterSettings): + await create_redis_rate_limit_pool() + + + initialization_complete.set() + + yield + + finally: + if isinstance(settings, RedisCacheSettings): + await close_redis_cache_pool() + + if isinstance(settings, RedisQueueSettings): + await close_redis_queue_pool() + + if isinstance(settings, RedisRateLimiterSettings): + await close_redis_rate_limit_pool() + + return lifespan + + +# -------------- application -------------- +def create_application( + router: APIRouter, + settings: ( + DatabaseSettings + | RedisCacheSettings + | AppSettings + | ClientSideCacheSettings + | RedisQueueSettings + | RedisRateLimiterSettings + | EnvironmentSettings + ), + create_tables_on_start: bool = True, + lifespan: Callable[[FastAPI], _AsyncGeneratorContextManager[Any]] | None = None, + **kwargs: Any, +) -> FastAPI: + """Creates and configures a FastAPI application based on the provided settings. + + This function initializes a FastAPI application and configures it with various settings + and handlers based on the type of the `settings` object provided. + + Parameters + ---------- + router : APIRouter + The APIRouter object containing the routes to be included in the FastAPI application. + + settings + An instance representing the settings for configuring the FastAPI application. + It determines the configuration applied: + + - AppSettings: Configures basic app metadata like name, description, contact, and license info. + - DatabaseSettings: Adds event handlers for initializing database tables during startup. + - RedisCacheSettings: Sets up event handlers for creating and closing a Redis cache pool. + - ClientSideCacheSettings: Integrates middleware for client-side caching. + - RedisQueueSettings: Sets up event handlers for creating and closing a Redis queue pool. + - RedisRateLimiterSettings: Sets up event handlers for creating and closing a Redis rate limiter pool. + - EnvironmentSettings: Conditionally sets documentation URLs and integrates custom routes for API documentation + based on the environment type. + + create_tables_on_start : bool + A flag to indicate whether to create database tables on application startup. + Defaults to True. + + **kwargs + Additional keyword arguments passed directly to the FastAPI constructor. + + Returns + ------- + FastAPI + A fully configured FastAPI application instance. + + The function configures the FastAPI application with different features and behaviors + based on the provided settings. It includes setting up database connections, Redis pools + for caching, queue, and rate limiting, client-side caching, and customizing the API documentation + based on the environment settings. + """ + # --- before creating application --- + if isinstance(settings, AppSettings): + to_update = { + "title": settings.APP_NAME, + "description": settings.APP_DESCRIPTION, + "contact": {"name": settings.CONTACT_NAME, "email": settings.CONTACT_EMAIL}, + "license_info": {"name": settings.LICENSE_NAME}, + } + kwargs.update(to_update) + + if isinstance(settings, EnvironmentSettings): + kwargs.update({"docs_url": None, "redoc_url": None, "openapi_url": None}) + + # Use custom lifespan if provided, otherwise use default factory + if lifespan is None: + lifespan = lifespan_factory(settings, create_tables_on_start=create_tables_on_start) + + application = FastAPI(lifespan=lifespan, **kwargs) + application.include_router(router) + + if isinstance(settings, ClientSideCacheSettings): + application.add_middleware(ClientCacheMiddleware, max_age=settings.CLIENT_CACHE_MAX_AGE) + + if isinstance(settings, EnvironmentSettings): + if settings.ENVIRONMENT != EnvironmentOption.PRODUCTION: + docs_router = APIRouter() + if settings.ENVIRONMENT != EnvironmentOption.LOCAL: + docs_router = APIRouter(dependencies=[Depends(get_current_superuser)]) + + @docs_router.get("/docs", include_in_schema=False) + async def get_swagger_documentation() -> fastapi.responses.HTMLResponse: + return get_swagger_ui_html(openapi_url="/openapi.json", title="docs") + + @docs_router.get("/redoc", include_in_schema=False) + async def get_redoc_documentation() -> fastapi.responses.HTMLResponse: + return get_redoc_html(openapi_url="/openapi.json", title="docs") + + @docs_router.get("/openapi.json", include_in_schema=False) + async def openapi() -> dict[str, Any]: + out: dict = get_openapi(title=application.title, version=application.version, routes=application.routes) + return out + + application.include_router(docs_router) + + return application diff --git a/src/app/core/utils/__init__.py b/src/app/core/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/core/utils/cache.py b/src/app/core/utils/cache.py new file mode 100644 index 0000000..0d0167f --- /dev/null +++ b/src/app/core/utils/cache.py @@ -0,0 +1,337 @@ +import functools +import json +import re +from collections.abc import Callable +from typing import Any + +from fastapi import Request +from fastapi.encoders import jsonable_encoder +from redis.asyncio import ConnectionPool, Redis + +from ..exceptions.cache_exceptions import CacheIdentificationInferenceError, InvalidRequestError, MissingClientError + +pool: ConnectionPool | None = None +client: Redis | None = None + + +def _infer_resource_id(kwargs: dict[str, Any], resource_id_type: type | tuple[type, ...]) -> int | str: + """Infer the resource ID from a dictionary of keyword arguments. + + Parameters + ---------- + kwargs: Dict[str, Any] + A dictionary of keyword arguments. + resource_id_type: Union[type, Tuple[type, ...]] + The expected type of the resource ID, which can be integer (int) or a string (str). + + Returns + ------- + Union[None, int, str] + The inferred resource ID. If it cannot be inferred or does not match the expected type, it returns None. + + Note + ---- + - When `resource_id_type` is `int`, the function looks for an argument with the key 'id'. + - When `resource_id_type` is `str`, it attempts to infer the resource ID as a string. + """ + resource_id: int | str | None = None + for arg_name, arg_value in kwargs.items(): + if isinstance(arg_value, resource_id_type): + if (resource_id_type is int) and ("id" in arg_name): + resource_id = arg_value + + elif (resource_id_type is int) and ("id" not in arg_name): + pass + + elif resource_id_type is str: + resource_id = arg_value + + if resource_id is None: + raise CacheIdentificationInferenceError + + return resource_id + + +def _extract_data_inside_brackets(input_string: str) -> list[str]: + """Extract data inside curly brackets from a given string using regular expressions. + + Parameters + ---------- + input_string: str + The input string in which to find data enclosed within curly brackets. + + Returns + ------- + List[str] + A list of strings containing the data found inside the curly brackets within the input string. + + Example + ------- + >>> _extract_data_inside_brackets("The {quick} brown {fox} jumps over the {lazy} dog.") + ['quick', 'fox', 'lazy'] + """ + data_inside_brackets = re.findall(r"{(.*?)}", input_string) + return data_inside_brackets + + +def _construct_data_dict(data_inside_brackets: list[str], kwargs: dict[str, Any]) -> dict[str, Any]: + """Construct a dictionary based on data inside brackets and keyword arguments. + + Parameters + ---------- + data_inside_brackets: List[str] + A list of keys inside brackets. + kwargs: Dict[str, Any] + A dictionary of keyword arguments. + + Returns + ------- + Dict[str, Any]: A dictionary with keys from data_inside_brackets and corresponding values from kwargs. + """ + data_dict = {} + for key in data_inside_brackets: + data_dict[key] = kwargs[key] + return data_dict + + +def _format_prefix(prefix: str, kwargs: dict[str, Any]) -> str: + """Format a prefix using keyword arguments. + + Parameters + ---------- + prefix: str + The prefix template to be formatted. + kwargs: Dict[str, Any] + A dictionary of keyword arguments. + + Returns + ------- + str: The formatted prefix. + """ + data_inside_brackets = _extract_data_inside_brackets(prefix) + data_dict = _construct_data_dict(data_inside_brackets, kwargs) + formatted_prefix = prefix.format(**data_dict) + return formatted_prefix + + +def _format_extra_data(to_invalidate_extra: dict[str, str], kwargs: dict[str, Any]) -> dict[str, Any]: + """Format extra data based on provided templates and keyword arguments. + + This function takes a dictionary of templates and their associated values and a dictionary of keyword arguments. + It formats the templates with the corresponding values from the keyword arguments and returns a dictionary + where keys are the formatted templates and values are the associated keyword argument values. + + Parameters + ---------- + to_invalidate_extra: Dict[str, str] + A dictionary where keys are templates and values are the associated values. + kwargs: Dict[str, Any] + A dictionary of keyword arguments. + + Returns + ------- + Dict[str, Any]: A dictionary where keys are formatted templates and values + are associated keyword argument values. + """ + formatted_extra = {} + for prefix, id_template in to_invalidate_extra.items(): + formatted_prefix = _format_prefix(prefix, kwargs) + id = _extract_data_inside_brackets(id_template)[0] + formatted_extra[formatted_prefix] = kwargs[id] + + return formatted_extra + + +async def _delete_keys_by_pattern(pattern: str) -> None: + """Delete keys from Redis that match a given pattern using the SCAN command. + + This function iteratively scans the Redis key space for keys that match a specific pattern + and deletes them. It uses the SCAN command to efficiently find keys, which is more + performance-friendly compared to the KEYS command, especially for large datasets. + + The function scans the key space in an iterative manner using a cursor-based approach. + It retrieves a batch of keys matching the pattern on each iteration and deletes them + until no matching keys are left. + + Parameters + ---------- + pattern: str + The pattern to match keys against. The pattern can include wildcards, + such as '*' for matching any character sequence. Example: 'user:*' + + Notes + ----- + - The SCAN command is used with a count of 100 to retrieve keys in batches. + This count can be adjusted based on the size of your dataset and Redis performance. + + - The function uses the delete command to remove keys in bulk. If the dataset + is extremely large, consider implementing additional logic to handle bulk deletion + more efficiently. + + - Be cautious with patterns that could match a large number of keys, as deleting + many keys simultaneously may impact the performance of the Redis server. + """ + if client is None: + return + + cursor = 0 + while True: + cursor, keys = await client.scan(cursor, match=pattern, count=100) + if keys: + await client.delete(*keys) + if cursor == 0: + break + + +def cache( + key_prefix: str, + resource_id_name: Any = None, + expiration: int = 3600, + resource_id_type: type | tuple[type, ...] = int, + to_invalidate_extra: dict[str, Any] | None = None, + pattern_to_invalidate_extra: list[str] | None = None, +) -> Callable: + """Cache decorator for FastAPI endpoints. + + This decorator enables caching the results of FastAPI endpoint functions to improve response times + and reduce the load on the application by storing and retrieving data in a cache. + + Parameters + ---------- + key_prefix: str + A unique prefix to identify the cache key. + resource_id_name: Any, optional + The name of the resource ID argument in the decorated function. If provided, it is used directly; + otherwise, the resource ID is inferred from the function's arguments. + expiration: int, optional + The expiration time for the cached data in seconds. Defaults to 3600 seconds (1 hour). + resource_id_type: Union[type, Tuple[type, ...]], default int + The expected type of the resource ID. + This can be a single type (e.g., int) or a tuple of types (e.g., (int, str)). + Defaults to int. This is used only if resource_id_name is not provided. + to_invalidate_extra: Dict[str, Any] | None, optional + A dictionary where keys are cache key prefixes and values are templates for cache key suffixes. + These keys are invalidated when the decorated function is called with a method other than GET. + pattern_to_invalidate_extra: List[str] | None, optional + A list of string patterns for cache keys that should be invalidated when the decorated function is called. + This allows for bulk invalidation of cache keys based on a matching pattern. + + Returns + ------- + Callable + A decorator function that can be applied to FastAPI endpoint functions. + + Example usage + ------------- + + ```python + from fastapi import FastAPI, Request + from my_module import cache # Replace with your actual module and imports + + app = FastAPI() + + # Define a sample endpoint with caching + @app.get("/sample/{resource_id}") + @cache(key_prefix="sample_data", expiration=3600, resource_id_type=int) + async def sample_endpoint(request: Request, resource_id: int): + # Your endpoint logic here + return {"data": "your_data"} + ``` + + This decorator caches the response data of the endpoint function using a unique cache key. + The cached data is retrieved for GET requests, and the cache is invalidated for other types of requests. + + Advanced Example Usage + ------------- + ```python + from fastapi import FastAPI, Request + from my_module import cache + + app = FastAPI() + + + @app.get("/users/{user_id}/items") + @cache(key_prefix="user_items", resource_id_name="user_id", expiration=1200) + async def read_user_items(request: Request, user_id: int): + # Endpoint logic to fetch user's items + return {"items": "user specific items"} + + + @app.put("/items/{item_id}") + @cache( + key_prefix="item_data", + resource_id_name="item_id", + to_invalidate_extra={"user_items": "{user_id}"}, + pattern_to_invalidate_extra=["user_*_items:*"], + ) + async def update_item(request: Request, item_id: int, data: dict, user_id: int): + # Update logic for an item + # Invalidate both the specific item cache and all user-specific item lists + return {"status": "updated"} + ``` + + In this example: + - When reading user items, the response is cached under a key formed with 'user_items' prefix and 'user_id'. + - When updating an item, the cache for this specific item (under 'item_data:item_id') and all caches with keys + starting with 'user_{user_id}_items:' are invalidated. The `to_invalidate_extra` parameter specifically targets + the cache for user-specific item lists, while `pattern_to_invalidate_extra` allows bulk invalidation of all keys + matching the pattern 'user_*_items:*', covering all users. + + Note + ---- + - resource_id_type is used only if resource_id is not passed. + - `to_invalidate_extra` and `pattern_to_invalidate_extra` are used for cache invalidation on methods other than GET. + - Using `pattern_to_invalidate_extra` can be resource-intensive on large datasets. Use it judiciously and + consider the potential impact on Redis performance. + """ + + def wrapper(func: Callable) -> Callable: + @functools.wraps(func) + async def inner(request: Request, *args: Any, **kwargs: Any) -> Any: + if client is None: + raise MissingClientError + + if resource_id_name: + resource_id = kwargs[resource_id_name] + else: + resource_id = _infer_resource_id(kwargs=kwargs, resource_id_type=resource_id_type) + + formatted_key_prefix = _format_prefix(key_prefix, kwargs) + cache_key = f"{formatted_key_prefix}:{resource_id}" + if request.method == "GET": + if to_invalidate_extra is not None or pattern_to_invalidate_extra is not None: + raise InvalidRequestError + + cached_data = await client.get(cache_key) + if cached_data: + return json.loads(cached_data.decode()) + + result = await func(request, *args, **kwargs) + + if request.method == "GET": + serializable_data = jsonable_encoder(result) + serialized_data = json.dumps(serializable_data) + + await client.set(cache_key, serialized_data) + await client.expire(cache_key, expiration) + + return json.loads(serialized_data) + + else: + await client.delete(cache_key) + if to_invalidate_extra is not None: + formatted_extra = _format_extra_data(to_invalidate_extra, kwargs) + for prefix, id in formatted_extra.items(): + extra_cache_key = f"{prefix}:{id}" + await client.delete(extra_cache_key) + + if pattern_to_invalidate_extra is not None: + for pattern in pattern_to_invalidate_extra: + formatted_pattern = _format_prefix(pattern, kwargs) + await _delete_keys_by_pattern(formatted_pattern + "*") + + return result + + return inner + + return wrapper diff --git a/src/app/core/utils/queue.py b/src/app/core/utils/queue.py new file mode 100644 index 0000000..7084037 --- /dev/null +++ b/src/app/core/utils/queue.py @@ -0,0 +1,3 @@ +from arq.connections import ArqRedis + +pool: ArqRedis | None = None diff --git a/src/app/core/utils/rate_limit.py b/src/app/core/utils/rate_limit.py new file mode 100644 index 0000000..d15f3ce --- /dev/null +++ b/src/app/core/utils/rate_limit.py @@ -0,0 +1,61 @@ +from datetime import UTC, datetime +from typing import Optional + +from redis.asyncio import ConnectionPool, Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.logger import logging + +logger = logging.getLogger(__name__) + + +class RateLimiter: + _instance: Optional["RateLimiter"] = None + pool: Optional[ConnectionPool] = None + client: Optional[Redis] = None + + def __new__(cls) -> "RateLimiter": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @classmethod + def initialize(cls, redis_url: str) -> None: + instance = cls() + if instance.pool is None: + instance.pool = ConnectionPool.from_url(redis_url) + instance.client = Redis(connection_pool=instance.pool) + + @classmethod + def get_client(cls) -> Redis: + instance = cls() + if instance.client is None: + logger.error("Redis client is not initialized.") + raise Exception("Redis client is not initialized.") + return instance.client + + async def is_rate_limited(self, db: AsyncSession, user_id: int, path: str, limit: int, period: int) -> bool: + return False + # client = self.get_client() + # current_timestamp = int(datetime.now(UTC).timestamp()) + # window_start = current_timestamp - (current_timestamp % period) + # + # sanitized_path = sanitize_path(path) + # key = f"ratelimit:{user_id}:{sanitized_path}:{window_start}" + # + # try: + # current_count = await client.incr(key) + # if current_count == 1: + # await client.expire(key, period) + # + # if current_count > limit: + # return True + # + # except Exception as e: + # logger.exception(f"Error checking rate limit for user {user_id} on path {path}: {e}") + # raise e + + return False + + +rate_limiter = RateLimiter() diff --git a/src/app/core/worker/__init__.py b/src/app/core/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/core/worker/functions.py b/src/app/core/worker/functions.py new file mode 100644 index 0000000..fcad946 --- /dev/null +++ b/src/app/core/worker/functions.py @@ -0,0 +1,24 @@ +import asyncio +import logging + +import uvloop +from arq.worker import Worker + +asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") + + +# -------- background tasks -------- +async def sample_background_task(ctx: Worker, name: str) -> str: + await asyncio.sleep(5) + return f"Task {name} is complete!" + + +# -------- base functions -------- +async def startup(ctx: Worker) -> None: + logging.info("Worker Started") + + +async def shutdown(ctx: Worker) -> None: + logging.info("Worker end") diff --git a/src/app/core/worker/settings.py b/src/app/core/worker/settings.py new file mode 100644 index 0000000..bb21894 --- /dev/null +++ b/src/app/core/worker/settings.py @@ -0,0 +1,15 @@ +from arq.connections import RedisSettings + +from ...core.config import settings +from .functions import sample_background_task, shutdown, startup + +REDIS_QUEUE_HOST = settings.REDIS_QUEUE_HOST +REDIS_QUEUE_PORT = settings.REDIS_QUEUE_PORT + + +class WorkerSettings: + functions = [sample_background_task] + redis_settings = RedisSettings(host=REDIS_QUEUE_HOST, port=REDIS_QUEUE_PORT) + on_startup = startup + on_shutdown = shutdown + handle_signals = False diff --git a/src/app/crud/__init__.py b/src/app/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/crud/crud_users.py b/src/app/crud/crud_users.py new file mode 100644 index 0000000..462b23c --- /dev/null +++ b/src/app/crud/crud_users.py @@ -0,0 +1,7 @@ +from fastcrud import FastCRUD + +from ..models.user import User +from ..schemas.user import UserCreateInternal, UserDelete, UserRead, UserUpdate, UserUpdateInternal + +CRUDUser = FastCRUD[User, UserCreateInternal, UserUpdate, UserUpdateInternal, UserDelete, UserRead] +crud_users = CRUDUser(User) diff --git a/src/app/main.py b/src/app/main.py new file mode 100644 index 0000000..3ae6f40 --- /dev/null +++ b/src/app/main.py @@ -0,0 +1,39 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from .admin.initialize import create_admin_interface +from .api import router +from .core.config import settings +from .core.setup import create_application, lifespan_factory + +admin = create_admin_interface() + + +@asynccontextmanager +async def lifespan_with_admin(app: FastAPI) -> AsyncGenerator[None, None]: + """Custom lifespan that includes admin initialization.""" + # Get the default lifespan + default_lifespan = lifespan_factory(settings) + + # Run the default lifespan initialization and our admin initialization + async with default_lifespan(app): + # Initialize admin interface if it exists + if admin: + # Initialize admin database and setup + await admin.initialize() + + yield + + +app = create_application( + router=router, + settings=settings, + lifespan=lifespan_with_admin, + create_tables_on_start=False +) + +# Mount admin interface if enabled +if admin: + app.mount(settings.CRUD_ADMIN_MOUNT_PATH, admin.app) diff --git a/src/app/middleware/client_cache_middleware.py b/src/app/middleware/client_cache_middleware.py new file mode 100644 index 0000000..4b2ef63 --- /dev/null +++ b/src/app/middleware/client_cache_middleware.py @@ -0,0 +1,56 @@ +from fastapi import FastAPI, Request, Response +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint + + +class ClientCacheMiddleware(BaseHTTPMiddleware): + """Middleware to set the `Cache-Control` header for client-side caching on all responses. + + Parameters + ---------- + app: FastAPI + The FastAPI application instance. + max_age: int, optional + Duration (in seconds) for which the response should be cached. Defaults to 60 seconds. + + Attributes + ---------- + max_age: int + Duration (in seconds) for which the response should be cached. + + Methods + ------- + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + Process the request and set the `Cache-Control` header in the response. + + Note + ---- + - The `Cache-Control` header instructs clients (e.g., browsers) + to cache the response for the specified duration. + """ + + def __init__(self, app: FastAPI, max_age: int = 60) -> None: + super().__init__(app) + self.max_age = max_age + + async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response: + """Process the request and set the `Cache-Control` header in the response. + + Parameters + ---------- + request: Request + The incoming request. + call_next: RequestResponseEndpoint + The next middleware or route handler in the processing chain. + + Returns + ------- + Response + The response object with the `Cache-Control` header set. + + Note + ---- + - This method is automatically called by Starlette for processing the request-response cycle. + """ + response: Response = await call_next(request) + response.headers["Cache-Control"] = f"public, max-age={self.max_age}" + return response diff --git a/src/app/models/__init__.py b/src/app/models/__init__.py new file mode 100644 index 0000000..ee4c00b --- /dev/null +++ b/src/app/models/__init__.py @@ -0,0 +1 @@ +from .user import User diff --git a/src/app/models/user.py b/src/app/models/user.py new file mode 100644 index 0000000..7bea2eb --- /dev/null +++ b/src/app/models/user.py @@ -0,0 +1,28 @@ +from uuid6 import uuid7 +from datetime import UTC, datetime +import uuid as uuid_pkg + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from ..core.db.database import Base + + +class User(Base): + __tablename__ = "user" + + id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True, init=False) + + name: Mapped[str] = mapped_column(String(30)) + username: Mapped[str] = mapped_column(String(20), unique=True, index=True) + email: Mapped[str] = mapped_column(String(50), unique=True, index=True) + hashed_password: Mapped[str] = mapped_column(String) + + profile_image_url: Mapped[str] = mapped_column(String, default="https://profileimageurl.com") + uuid: Mapped[uuid_pkg.UUID] = mapped_column(UUID(as_uuid=True), default_factory=uuid7, unique=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default_factory=lambda: datetime.now(UTC)) + updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + is_deleted: Mapped[bool] = mapped_column(default=False, index=True) + is_superuser: Mapped[bool] = mapped_column(default=False) \ No newline at end of file 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/job.py b/src/app/schemas/job.py new file mode 100644 index 0000000..13f7596 --- /dev/null +++ b/src/app/schemas/job.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class Job(BaseModel): + id: str diff --git a/src/app/schemas/user.py b/src/app/schemas/user.py new file mode 100644 index 0000000..ff13d16 --- /dev/null +++ b/src/app/schemas/user.py @@ -0,0 +1,70 @@ +from datetime import datetime +from typing import Annotated + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + +from ..core.schemas import PersistentDeletion, TimestampSchema, UUIDSchema + + +class UserBase(BaseModel): + name: Annotated[str, Field(min_length=2, max_length=30, examples=["User Userson"])] + username: Annotated[str, Field(min_length=2, max_length=20, pattern=r"^[a-z0-9]+$", examples=["userson"])] + email: Annotated[EmailStr, Field(examples=["user.userson@example.com"])] + + +class User(TimestampSchema, UserBase, UUIDSchema, PersistentDeletion): + profile_image_url: Annotated[str, Field(default="https://www.profileimageurl.com")] + hashed_password: str + is_superuser: bool = False + tier_id: int | None = None + + +class UserRead(BaseModel): + id: int + + name: Annotated[str, Field(min_length=2, max_length=30, examples=["User Userson"])] + username: Annotated[str, Field(min_length=2, max_length=20, pattern=r"^[a-z0-9]+$", examples=["userson"])] + email: Annotated[EmailStr, Field(examples=["user.userson@example.com"])] + profile_image_url: str + tier_id: int | None + + +class UserCreate(UserBase): + model_config = ConfigDict(extra="forbid") + + password: Annotated[str, Field(pattern=r"^.{8,}|[0-9]+|[A-Z]+|[a-z]+|[^a-zA-Z0-9]+$", examples=["Str1ngst!"])] + + +class UserCreateInternal(UserBase): + hashed_password: str + + +class UserUpdate(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: Annotated[str | None, Field(min_length=2, max_length=30, examples=["User Userberg"], default=None)] + username: Annotated[ + str | None, Field(min_length=2, max_length=20, pattern=r"^[a-z0-9]+$", examples=["userberg"], default=None) + ] + email: Annotated[EmailStr | None, Field(examples=["user.userberg@example.com"], default=None)] + profile_image_url: Annotated[ + str | None, + Field( + pattern=r"^(https?|ftp)://[^\s/$.?#].[^\s]*$", examples=["https://www.profileimageurl.com"], default=None + ), + ] + + +class UserUpdateInternal(UserUpdate): + updated_at: datetime + + +class UserDelete(BaseModel): + model_config = ConfigDict(extra="forbid") + + is_deleted: bool + deleted_at: datetime + + +class UserRestoreDeleted(BaseModel): + is_deleted: bool diff --git a/src/app/test.py b/src/app/test.py new file mode 100644 index 0000000..82e042c --- /dev/null +++ b/src/app/test.py @@ -0,0 +1,16 @@ +from src.app.core.config import settings +from src.app.lib import TBankClient + + +async def main(): + api_key = settings.TBANK_API_KEY + client = TBankClient(api_key=api_key) + + data = await client.get_counterparty_excerpt_by_inn('9726096281') + print(data) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/src/migrations/README b/src/migrations/README new file mode 100644 index 0000000..2500aa1 --- /dev/null +++ b/src/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/src/migrations/env.py b/src/migrations/env.py new file mode 100644 index 0000000..94fae78 --- /dev/null +++ b/src/migrations/env.py @@ -0,0 +1,88 @@ +import asyncio +import importlib +import pkgutil +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.core.config import settings +from app.core.db.database import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config +postgresql_url = f"{settings.POSTGRES_ASYNC_PREFIX}{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@localhost:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" +config.set_main_option( + "sqlalchemy.url", + postgresql_url, +) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + + +def import_models(package_name): + package = importlib.import_module(package_name) + for _, module_name, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."): + importlib.import_module(module_name) + + +import_models("app.models") +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By + skipping the Engine creation we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the script output. + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine and associate a connection with the context.""" + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/migrations/script.py.mako b/src/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/src/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/src/migrations/versions/README.MD b/src/migrations/versions/README.MD new file mode 100644 index 0000000..e69de29 diff --git a/src/migrations/versions/a1de33a00c8f_add_user_profile_fields.py b/src/migrations/versions/a1de33a00c8f_add_user_profile_fields.py new file mode 100644 index 0000000..f99012c --- /dev/null +++ b/src/migrations/versions/a1de33a00c8f_add_user_profile_fields.py @@ -0,0 +1,37 @@ +"""Add user profile fields + +Revision ID: a1de33a00c8f +Revises: d315dba4434b +Create Date: 2025-10-19 19:33:31.722010 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'a1de33a00c8f' +down_revision: Union[str, None] = 'd315dba4434b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('tier') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tier', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('tier_pkey')), + sa.UniqueConstraint('name', name=op.f('tier_name_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + # ### end Alembic commands ### diff --git a/src/migrations/versions/d315dba4434b_add_user_profile_fields.py b/src/migrations/versions/d315dba4434b_add_user_profile_fields.py new file mode 100644 index 0000000..ba64d9f --- /dev/null +++ b/src/migrations/versions/d315dba4434b_add_user_profile_fields.py @@ -0,0 +1,89 @@ +"""Add user profile fields + +Revision ID: d315dba4434b +Revises: +Create Date: 2025-10-19 19:31:43.179430 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = 'd315dba4434b' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_rate_limit_tier_id'), table_name='rate_limit') + op.drop_table('rate_limit') + op.drop_index(op.f('ix_token_blacklist_token'), table_name='token_blacklist') + op.drop_table('token_blacklist') + op.drop_table('tier') + op.drop_index(op.f('ix_post_created_by_user_id'), table_name='post') + op.drop_index(op.f('ix_post_is_deleted'), table_name='post') + op.drop_table('post') + op.drop_index(op.f('ix_user_tier_id'), table_name='user') + op.drop_constraint(op.f('user_tier_id_fkey'), 'user', type_='foreignkey') + op.drop_column('user', 'tier_id') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('tier_id', sa.INTEGER(), autoincrement=False, nullable=True)) + op.create_foreign_key(op.f('user_tier_id_fkey'), 'user', 'tier', ['tier_id'], ['id']) + op.create_index(op.f('ix_user_tier_id'), 'user', ['tier_id'], unique=False) + op.create_table('post', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('created_by_user_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('title', sa.VARCHAR(length=30), autoincrement=False, nullable=False), + sa.Column('text', sa.VARCHAR(length=63206), autoincrement=False, nullable=False), + sa.Column('uuid', sa.UUID(), autoincrement=False, nullable=False), + sa.Column('media_url', sa.VARCHAR(), autoincrement=False, nullable=True), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.Column('deleted_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.Column('is_deleted', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.ForeignKeyConstraint(['created_by_user_id'], ['user.id'], name=op.f('post_created_by_user_id_fkey')), + sa.PrimaryKeyConstraint('id', name=op.f('post_pkey')), + sa.UniqueConstraint('uuid', name=op.f('post_uuid_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + op.create_index(op.f('ix_post_is_deleted'), 'post', ['is_deleted'], unique=False) + op.create_index(op.f('ix_post_created_by_user_id'), 'post', ['created_by_user_id'], unique=False) + op.create_table('tier', + sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('tier_id_seq'::regclass)"), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name='tier_pkey'), + sa.UniqueConstraint('name', name='tier_name_key', postgresql_include=[], postgresql_nulls_not_distinct=False), + postgresql_ignore_search_path=False + ) + op.create_table('token_blacklist', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('token', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('expires_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('token_blacklist_pkey')) + ) + op.create_index(op.f('ix_token_blacklist_token'), 'token_blacklist', ['token'], unique=True) + op.create_table('rate_limit', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('tier_id', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('path', sa.VARCHAR(), autoincrement=False, nullable=False), + sa.Column('limit', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('period', sa.INTEGER(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True), + sa.ForeignKeyConstraint(['tier_id'], ['tier.id'], name=op.f('rate_limit_tier_id_fkey')), + sa.PrimaryKeyConstraint('id', name=op.f('rate_limit_pkey')), + sa.UniqueConstraint('name', name=op.f('rate_limit_name_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + op.create_index(op.f('ix_rate_limit_tier_id'), 'rate_limit', ['tier_id'], unique=False) + # ### end Alembic commands ### diff --git a/src/scripts/__init__.py b/src/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/scripts/create_first_superuser.py b/src/scripts/create_first_superuser.py new file mode 100644 index 0000000..d1f6d93 --- /dev/null +++ b/src/scripts/create_first_superuser.py @@ -0,0 +1,78 @@ +import asyncio +import logging +from uuid6 import uuid7 #126 +from datetime import UTC, datetime + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, MetaData, String, Table, insert, select +from sqlalchemy.dialects.postgresql import UUID + +from ..app.core.config import settings +from ..app.core.db.database import AsyncSession, async_engine, local_session +from ..app.core.security import get_password_hash +from ..app.models.user import User + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def create_first_user(session: AsyncSession) -> None: + try: + name = settings.ADMIN_NAME + email = settings.ADMIN_EMAIL + username = settings.ADMIN_USERNAME + hashed_password = get_password_hash(settings.ADMIN_PASSWORD) + + query = select(User).filter_by(email=email) + result = await session.execute(query) + user = result.scalar_one_or_none() + + if user is None: + metadata = MetaData() + user_table = Table( + "user", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True, nullable=False), + Column("name", String(30), nullable=False), + Column("username", String(20), nullable=False, unique=True, index=True), + Column("email", String(50), nullable=False, unique=True, index=True), + Column("hashed_password", String, nullable=False), + Column("profile_image_url", String, default="https://profileimageurl.com"), + Column("uuid", UUID(as_uuid=True), default=uuid7, unique=True), + Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False), + Column("updated_at", DateTime), + Column("deleted_at", DateTime), + Column("is_deleted", Boolean, default=False, index=True), + Column("is_superuser", Boolean, default=False), + Column("tier_id", Integer, ForeignKey("tier.id"), index=True), + ) + + data = { + "name": name, + "email": email, + "username": username, + "hashed_password": hashed_password, + "is_superuser": True, + } + + stmt = insert(user_table).values(data) + async with async_engine.connect() as conn: + await conn.execute(stmt) + await conn.commit() + + logger.info(f"Admin user {username} created successfully.") + + else: + logger.info(f"Admin user {username} already exists.") + + except Exception as e: + logger.error(f"Error creating admin user: {e}") + + +async def main(): + async with local_session() as session: + await create_first_user(session) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/src/scripts/create_first_tier.py b/src/scripts/create_first_tier.py new file mode 100644 index 0000000..baceb9f --- /dev/null +++ b/src/scripts/create_first_tier.py @@ -0,0 +1,41 @@ +import asyncio +import logging + +from sqlalchemy import select + +from ..app.core.config import config +from ..app.core.db.database import AsyncSession, local_session +from ..app.models.tier import Tier + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def create_first_tier(session: AsyncSession) -> None: + try: + tier_name = config("TIER_NAME", default="free") + + query = select(Tier).where(Tier.name == tier_name) + result = await session.execute(query) + tier = result.scalar_one_or_none() + + if tier is None: + session.add(Tier(name=tier_name)) + await session.commit() + logger.info(f"Tier '{tier_name}' created successfully.") + + else: + logger.info(f"Tier '{tier_name}' already exists.") + + except Exception as e: + logger.error(f"Error creating tier: {e}") + + +async def main(): + async with local_session() as session: + await create_first_tier(session) + + +if __name__ == "__main__": + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..22038f1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,102 @@ +from collections.abc import Callable, Generator +from typing import Any +from unittest.mock import AsyncMock, Mock + +import pytest +from faker import Faker +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm.session import Session + +from src.app.core.config import settings +from src.app.main import app + +DATABASE_URI = settings.POSTGRES_URI +DATABASE_PREFIX = settings.POSTGRES_SYNC_PREFIX + +sync_engine = create_engine(DATABASE_PREFIX + DATABASE_URI) +local_session = sessionmaker(autocommit=False, autoflush=False, bind=sync_engine) + + +fake = Faker() + + +@pytest.fixture(scope="session") +def client() -> Generator[TestClient, Any, None]: + with TestClient(app) as _client: + yield _client + app.dependency_overrides = {} + sync_engine.dispose() + + +@pytest.fixture +def db() -> Generator[Session, Any, None]: + session = local_session() + yield session + session.close() + + +def override_dependency(dependency: Callable[..., Any], mocked_response: Any) -> None: + app.dependency_overrides[dependency] = lambda: mocked_response + + +@pytest.fixture +def mock_db(): + """Mock database session for unit tests.""" + return Mock(spec=AsyncSession) + + +@pytest.fixture +def mock_redis(): + """Mock Redis connection for unit tests.""" + mock_redis = Mock() + mock_redis.get = AsyncMock(return_value=None) + mock_redis.set = AsyncMock(return_value=True) + mock_redis.delete = AsyncMock(return_value=True) + return mock_redis + + +@pytest.fixture +def sample_user_data(): + """Generate sample user data for tests.""" + return { + "name": fake.name(), + "username": fake.user_name(), + "email": fake.email(), + "password": fake.password(), + } + + +@pytest.fixture +def sample_user_read(): + """Generate a sample UserRead object.""" + from uuid6 import uuid7 + + from src.app.schemas.user import UserRead + + return UserRead( + id=1, + uuid=uuid7(), + name=fake.name(), + username=fake.user_name(), + email=fake.email(), + profile_image_url=fake.image_url(), + is_superuser=False, + created_at=fake.date_time(), + updated_at=fake.date_time(), + tier_id=None, + ) + + +@pytest.fixture +def current_user_dict(): + """Mock current user from auth dependency.""" + return { + "id": 1, + "username": fake.user_name(), + "email": fake.email(), + "name": fake.name(), + "is_superuser": False, + } diff --git a/tests/helpers/generators.py b/tests/helpers/generators.py new file mode 100644 index 0000000..0304003 --- /dev/null +++ b/tests/helpers/generators.py @@ -0,0 +1,25 @@ +from uuid6 import uuid7 #126 + +from sqlalchemy.orm import Session + +from src.app import models +from src.app.core.security import get_password_hash +from tests.conftest import fake + + +def create_user(db: Session, is_super_user: bool = False) -> models.User: + _user = models.User( + name=fake.name(), + username=fake.user_name(), + email=fake.email(), + hashed_password=get_password_hash(fake.password()), + profile_image_url=fake.image_url(), + uuid=uuid7, + is_superuser=is_super_user, + ) + + db.add(_user) + db.commit() + db.refresh(_user) + + return _user diff --git a/tests/helpers/mocks.py b/tests/helpers/mocks.py new file mode 100644 index 0000000..713ae68 --- /dev/null +++ b/tests/helpers/mocks.py @@ -0,0 +1,17 @@ +from typing import Any + +from fastapi.encoders import jsonable_encoder + +from src.app import models +from tests.conftest import fake + + +def get_current_user(user: models.User) -> dict[str, Any]: + return jsonable_encoder(user) + + +def oauth2_scheme() -> str: + token = fake.sha256() + if isinstance(token, bytes): + token = token.decode("utf-8") + return token # type: ignore diff --git a/tests/test_user.py b/tests/test_user.py new file mode 100644 index 0000000..831cbff --- /dev/null +++ b/tests/test_user.py @@ -0,0 +1,195 @@ +"""Unit tests for user API endpoints.""" + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from src.app.api.v1.users import erase_user, patch_user, read_user, read_users, write_user +from src.app.core.exceptions.http_exceptions import DuplicateValueException, ForbiddenException, NotFoundException +from src.app.schemas.user import UserCreate, UserRead, UserUpdate + + +class TestWriteUser: + """Test user creation endpoint.""" + + @pytest.mark.asyncio + async def test_create_user_success(self, mock_db, sample_user_data, sample_user_read): + """Test successful user creation.""" + user_create = UserCreate(**sample_user_data) + + with patch("src.app.api.v1.users.crud_users") as mock_crud: + # Mock that email and username don't exist + mock_crud.exists = AsyncMock(side_effect=[False, False]) # email, then username + mock_crud.create = AsyncMock(return_value=Mock(id=1)) + mock_crud.get = AsyncMock(return_value=sample_user_read) + + with patch("src.app.api.v1.users.get_password_hash") as mock_hash: + mock_hash.return_value = "hashed_password" + + result = await write_user(Mock(), user_create, mock_db) + + assert result == sample_user_read + mock_crud.exists.assert_any_call(db=mock_db, email=user_create.email) + mock_crud.exists.assert_any_call(db=mock_db, username=user_create.username) + mock_crud.create.assert_called_once() + + @pytest.mark.asyncio + async def test_create_user_duplicate_email(self, mock_db, sample_user_data): + """Test user creation with duplicate email.""" + user_create = UserCreate(**sample_user_data) + + with patch("src.app.api.v1.users.crud_users") as mock_crud: + # Mock that email already exists + mock_crud.exists = AsyncMock(return_value=True) + + with pytest.raises(DuplicateValueException, match="Email is already registered"): + await write_user(Mock(), user_create, mock_db) + + @pytest.mark.asyncio + async def test_create_user_duplicate_username(self, mock_db, sample_user_data): + """Test user creation with duplicate username.""" + user_create = UserCreate(**sample_user_data) + + with patch("src.app.api.v1.users.crud_users") as mock_crud: + # Mock email doesn't exist, but username does + mock_crud.exists = AsyncMock(side_effect=[False, True]) + + with pytest.raises(DuplicateValueException, match="Username not available"): + await write_user(Mock(), user_create, mock_db) + + +class TestReadUser: + """Test user retrieval endpoint.""" + + @pytest.mark.asyncio + async def test_read_user_success(self, mock_db, sample_user_read): + """Test successful user retrieval.""" + username = "test_user" + + with patch("src.app.api.v1.users.crud_users") as mock_crud: + mock_crud.get = AsyncMock(return_value=sample_user_read) + + result = await read_user(Mock(), username, mock_db) + + assert result == sample_user_read + mock_crud.get.assert_called_once_with( + db=mock_db, username=username, is_deleted=False, schema_to_select=UserRead + ) + + @pytest.mark.asyncio + async def test_read_user_not_found(self, mock_db): + """Test user retrieval when user doesn't exist.""" + username = "nonexistent_user" + + with patch("src.app.api.v1.users.crud_users") as mock_crud: + mock_crud.get = AsyncMock(return_value=None) + + with pytest.raises(NotFoundException, match="User not found"): + await read_user(Mock(), username, mock_db) + + +class TestReadUsers: + """Test users list endpoint.""" + + @pytest.mark.asyncio + async def test_read_users_success(self, mock_db): + """Test successful users list retrieval.""" + mock_users_data = {"data": [{"id": 1}, {"id": 2}], "count": 2} + + with patch("src.app.api.v1.users.crud_users") as mock_crud: + mock_crud.get_multi = AsyncMock(return_value=mock_users_data) + + with patch("src.app.api.v1.users.paginated_response") as mock_paginated: + expected_response = {"data": [{"id": 1}, {"id": 2}], "pagination": {}} + mock_paginated.return_value = expected_response + + result = await read_users(Mock(), mock_db, page=1, items_per_page=10) + + assert result == expected_response + mock_crud.get_multi.assert_called_once() + mock_paginated.assert_called_once() + + +class TestPatchUser: + """Test user update endpoint.""" + + @pytest.mark.asyncio + async def test_patch_user_success(self, mock_db, current_user_dict, sample_user_read): + """Test successful user update.""" + username = current_user_dict["username"] + user_update = UserUpdate(name="New Name") + + + user_dict = sample_user_read.model_dump() + user_dict["username"] = username + + with patch("src.app.api.v1.users.crud_users") as mock_crud: + mock_crud.get = AsyncMock(return_value=user_dict) + mock_crud.exists = AsyncMock(return_value=False) + mock_crud.update = AsyncMock(return_value=None) + + result = await patch_user(Mock(), user_update, username, current_user_dict, mock_db) + + assert result == {"message": "User updated"} + mock_crud.update.assert_called_once() + + @pytest.mark.asyncio + async def test_patch_user_forbidden(self, mock_db, current_user_dict, sample_user_read): + """Test user update when user tries to update another user.""" + username = "different_user" + user_update = UserUpdate(name="New Name") + user_dict = sample_user_read.model_dump() + user_dict["username"] = username + + with patch("src.app.api.v1.users.crud_users") as mock_crud: + mock_crud.get = AsyncMock(return_value=user_dict) + + with pytest.raises(ForbiddenException): + await patch_user(Mock(), user_update, username, current_user_dict, mock_db) + + +class TestEraseUser: + """Test user deletion endpoint.""" + + @pytest.mark.asyncio + async def test_erase_user_success(self, mock_db, current_user_dict, sample_user_read): + """Test successful user deletion.""" + username = current_user_dict["username"] + sample_user_read.username = username + token = "mock_token" + + with patch("src.app.api.v1.users.crud_users") as mock_crud: + mock_crud.get = AsyncMock(return_value=sample_user_read) + mock_crud.delete = AsyncMock(return_value=None) + + with patch("src.app.api.v1.users.blacklist_token", new_callable=AsyncMock) as mock_blacklist: + result = await erase_user(Mock(), username, current_user_dict, mock_db, token) + + assert result == {"message": "User deleted"} + mock_crud.delete.assert_called_once_with(db=mock_db, username=username) + mock_blacklist.assert_called_once_with(token=token, db=mock_db) + + @pytest.mark.asyncio + async def test_erase_user_not_found(self, mock_db, current_user_dict): + """Test user deletion when user doesn't exist.""" + username = "nonexistent_user" + token = "mock_token" + + with patch("src.app.api.v1.users.crud_users") as mock_crud: + mock_crud.get = AsyncMock(return_value=None) + + with pytest.raises(NotFoundException, match="User not found"): + await erase_user(Mock(), username, current_user_dict, mock_db, token) + + @pytest.mark.asyncio + async def test_erase_user_forbidden(self, mock_db, current_user_dict, sample_user_read): + """Test user deletion when user tries to delete another user.""" + username = "different_user" + sample_user_read.username = username + token = "mock_token" + + with patch("src.app.api.v1.users.crud_users") as mock_crud: + mock_crud.get = AsyncMock(return_value=sample_user_read) + + with pytest.raises(ForbiddenException): + await erase_user(Mock(), username, current_user_dict, mock_db, token) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..08f84ba --- /dev/null +++ b/uv.lock @@ -0,0 +1,1599 @@ +version = 1 +revision = 3 +requires-python = ">=3.11, <4" + +[[package]] +name = "aiosqlite" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, +] + +[[package]] +name = "alembic" +version = "1.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/89/bfb4fe86e3fc3972d35431af7bedbc60fa606e8b17196704a1747f7aa4c3/alembic-1.16.1.tar.gz", hash = "sha256:43d37ba24b3d17bc1eb1024fe0f51cd1dc95aeb5464594a02c6bb9ca9864bfa4", size = 1955006, upload-time = "2025-05-21T23:11:05.991Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/59/565286efff3692c5716c212202af61466480f6357c4ae3089d4453bff1f3/alembic-1.16.1-py3-none-any.whl", hash = "sha256:0cdd48acada30d93aa1035767d67dff25702f8de74d7c3919f2e8492c8db2e67", size = 242488, upload-time = "2025-05-21T23:11:07.783Z" }, +] + +[[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.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, +] + +[[package]] +name = "arq" +version = "0.26.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "redis", extra = ["hiredis"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/65/5add7049297a449d1453e26a8d5924f0d5440b3876edc9e80d5dc621f16d/arq-0.26.3.tar.gz", hash = "sha256:362063ea3c726562fb69c723d5b8ee80827fdefda782a8547da5be3d380ac4b1", size = 291111, upload-time = "2025-01-06T22:44:49.771Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/b3/a24a183c628da633b7cafd1759b14aaf47958de82ba6bcae9f1c2898781d/arq-0.26.3-py3-none-any.whl", hash = "sha256:9f4b78149a58c9dc4b88454861a254b7c4e7a159f2c973c89b548288b77e9005", size = 25968, upload-time = "2025-01-06T22:44:45.771Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/0e/f5d708add0d0b97446c402db7e8dd4c4183c13edaabe8a8500b411e7b495/asyncpg-0.30.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5e0511ad3dec5f6b4f7a9e063591d407eee66b88c14e2ea636f187da1dcfff6a", size = 674506, upload-time = "2024-10-20T00:29:27.988Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a0/67ec9a75cb24a1d99f97b8437c8d56da40e6f6bd23b04e2f4ea5d5ad82ac/asyncpg-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:915aeb9f79316b43c3207363af12d0e6fd10776641a7de8a01212afd95bdf0ed", size = 645922, upload-time = "2024-10-20T00:29:29.391Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/a7584f24174bd86ff1053b14bb841f9e714380c672f61c906eb01d8ec433/asyncpg-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c198a00cce9506fcd0bf219a799f38ac7a237745e1d27f0e1f66d3707c84a5a", size = 3079565, upload-time = "2024-10-20T00:29:30.832Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/a4c0f9660e333114bdb04d1a9ac70db690dd4ae003f34f691139a5cbdae3/asyncpg-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3326e6d7381799e9735ca2ec9fd7be4d5fef5dcbc3cb555d8a463d8460607956", size = 3109962, upload-time = "2024-10-20T00:29:33.114Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/199fd16b5a981b1575923cbb5d9cf916fdc936b377e0423099f209e7e73d/asyncpg-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:51da377487e249e35bd0859661f6ee2b81db11ad1f4fc036194bc9cb2ead5056", size = 3064791, upload-time = "2024-10-20T00:29:34.677Z" }, + { url = "https://files.pythonhosted.org/packages/77/52/0004809b3427534a0c9139c08c87b515f1c77a8376a50ae29f001e53962f/asyncpg-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc6d84136f9c4d24d358f3b02be4b6ba358abd09f80737d1ac7c444f36108454", size = 3188696, upload-time = "2024-10-20T00:29:36.389Z" }, + { url = "https://files.pythonhosted.org/packages/52/cb/fbad941cd466117be58b774a3f1cc9ecc659af625f028b163b1e646a55fe/asyncpg-0.30.0-cp311-cp311-win32.whl", hash = "sha256:574156480df14f64c2d76450a3f3aaaf26105869cad3865041156b38459e935d", size = 567358, upload-time = "2024-10-20T00:29:37.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0a/0a32307cf166d50e1ad120d9b81a33a948a1a5463ebfa5a96cc5606c0863/asyncpg-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:3356637f0bd830407b5597317b3cb3571387ae52ddc3bca6233682be88bbbc1f", size = 629375, upload-time = "2024-10-20T00:29:39.987Z" }, + { url = "https://files.pythonhosted.org/packages/4b/64/9d3e887bb7b01535fdbc45fbd5f0a8447539833b97ee69ecdbb7a79d0cb4/asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", size = 673162, upload-time = "2024-10-20T00:29:41.88Z" }, + { url = "https://files.pythonhosted.org/packages/6e/eb/8b236663f06984f212a087b3e849731f917ab80f84450e943900e8ca4052/asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", size = 637025, upload-time = "2024-10-20T00:29:43.352Z" }, + { url = "https://files.pythonhosted.org/packages/cc/57/2dc240bb263d58786cfaa60920779af6e8d32da63ab9ffc09f8312bd7a14/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", size = 3496243, upload-time = "2024-10-20T00:29:44.922Z" }, + { url = "https://files.pythonhosted.org/packages/f4/40/0ae9d061d278b10713ea9021ef6b703ec44698fe32178715a501ac696c6b/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", size = 3575059, upload-time = "2024-10-20T00:29:46.891Z" }, + { url = "https://files.pythonhosted.org/packages/c3/75/d6b895a35a2c6506952247640178e5f768eeb28b2e20299b6a6f1d743ba0/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", size = 3473596, upload-time = "2024-10-20T00:29:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e7/3693392d3e168ab0aebb2d361431375bd22ffc7b4a586a0fc060d519fae7/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", size = 3641632, upload-time = "2024-10-20T00:29:50.768Z" }, + { url = "https://files.pythonhosted.org/packages/32/ea/15670cea95745bba3f0352341db55f506a820b21c619ee66b7d12ea7867d/asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", size = 560186, upload-time = "2024-10-20T00:29:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6b/fe1fad5cee79ca5f5c27aed7bd95baee529c1bf8a387435c8ba4fe53d5c1/asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", size = 621064, upload-time = "2024-10-20T00:29:53.757Z" }, + { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload-time = "2024-10-20T00:29:55.165Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload-time = "2024-10-20T00:29:57.14Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload-time = "2024-10-20T00:29:58.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload-time = "2024-10-20T00:30:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload-time = "2024-10-20T00:30:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload-time = "2024-10-20T00:30:04.501Z" }, + { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload-time = "2024-10-20T00:30:06.537Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload-time = "2024-10-20T00:30:09.024Z" }, +] + +[[package]] +name = "bcrypt" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" }, + { url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" }, + { url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" }, + { url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" }, + { url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" }, + { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" }, + { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" }, + { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" }, + { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" }, + { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b1/1289e21d710496b88340369137cc4c5f6ee036401190ea116a7b4ae6d32a/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a839320bf27d474e52ef8cb16449bb2ce0ba03ca9f44daba6d93fa1d8828e48a", size = 275103, upload-time = "2025-02-28T01:24:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/94/41/19be9fe17e4ffc5d10b7b67f10e459fc4eee6ffe9056a88de511920cfd8d/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:bdc6a24e754a555d7316fa4774e64c6c3997d27ed2d1964d55920c7c227bc4ce", size = 280513, upload-time = "2025-02-28T01:24:02.243Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/05687a9ef89edebdd8ad7474c16d8af685eb4591c3c38300bb6aad4f0076/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:55a935b8e9a1d2def0626c4269db3fcd26728cbff1e84f0341465c31c4ee56d8", size = 274685, upload-time = "2025-02-28T01:24:04.512Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/47bba97924ebe86a62ef83dc75b7c8a881d53c535f83e2c54c4bd701e05c/bcrypt-4.3.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57967b7a28d855313a963aaea51bf6df89f833db4320da458e5b3c5ab6d4c938", size = 280110, upload-time = "2025-02-28T01:24:05.896Z" }, +] + +[[package]] +name = "certifi" +version = "2025.4.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, + { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, + { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, + { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, + { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, + { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, + { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, + { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, + { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, + { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, +] + +[[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/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { 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.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[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 = "crudadmin" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosqlite" }, + { name = "bcrypt" }, + { name = "fastapi" }, + { name = "fastcrud" }, + { name = "greenlet" }, + { name = "jinja2" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-jose" }, + { name = "python-multipart" }, + { name = "sqlalchemy" }, + { name = "user-agents" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/f9/2a202981d7508d327cb969af23e4236adf0988d9873d979be4af8490c028/crudadmin-0.4.2.tar.gz", hash = "sha256:6bcfaedbaddc5bbefb9960b6a0bf7d8b75d6bf0f880b625ad3f6293a085cd31a", size = 189902, upload-time = "2025-06-26T06:58:52.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/49/8f1f51346756c0ceb11ef9309cabb29f3d333c097bae4b4cd69f7bf0beab/crudadmin-0.4.2-py3-none-any.whl", hash = "sha256:8bba024031505eb8f7454a23c4a3690144ae4a49e0366e02d320b6374b2a9c5c", size = 217454, upload-time = "2025-06-26T06:58:51.389Z" }, +] + +[[package]] +name = "cryptography" +version = "45.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/1f/9fa001e74a1993a9cadd2333bb889e50c66327b8594ac538ab8a04f915b7/cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899", size = 744738, upload-time = "2025-05-25T14:17:24.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b2/2345dc595998caa6f68adf84e8f8b50d18e9fc4638d32b22ea8daedd4b7a/cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71", size = 7056239, upload-time = "2025-05-25T14:16:12.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/3d/ac361649a0bfffc105e2298b720d8b862330a767dab27c06adc2ddbef96a/cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b", size = 4205541, upload-time = "2025-05-25T14:16:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/70/3e/c02a043750494d5c445f769e9c9f67e550d65060e0bfce52d91c1362693d/cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f", size = 4433275, upload-time = "2025-05-25T14:16:16.421Z" }, + { url = "https://files.pythonhosted.org/packages/40/7a/9af0bfd48784e80eef3eb6fd6fde96fe706b4fc156751ce1b2b965dada70/cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942", size = 4209173, upload-time = "2025-05-25T14:16:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/31/5f/d6f8753c8708912df52e67969e80ef70b8e8897306cd9eb8b98201f8c184/cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9", size = 3898150, upload-time = "2025-05-25T14:16:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/8b/50/f256ab79c671fb066e47336706dc398c3b1e125f952e07d54ce82cf4011a/cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56", size = 4466473, upload-time = "2025-05-25T14:16:22.605Z" }, + { url = "https://files.pythonhosted.org/packages/62/e7/312428336bb2df0848d0768ab5a062e11a32d18139447a76dfc19ada8eed/cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca", size = 4211890, upload-time = "2025-05-25T14:16:24.738Z" }, + { url = "https://files.pythonhosted.org/packages/e7/53/8a130e22c1e432b3c14896ec5eb7ac01fb53c6737e1d705df7e0efb647c6/cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1", size = 4466300, upload-time = "2025-05-25T14:16:26.768Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/6bb6579688ef805fd16a053005fce93944cdade465fc92ef32bbc5c40681/cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578", size = 4332483, upload-time = "2025-05-25T14:16:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/2538f4e1ce05c6c4f81f43c1ef2bd6de7ae5e24ee284460ff6c77e42ca77/cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497", size = 4573714, upload-time = "2025-05-25T14:16:30.474Z" }, + { url = "https://files.pythonhosted.org/packages/f5/bb/e86e9cf07f73a98d84a4084e8fd420b0e82330a901d9cac8149f994c3417/cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710", size = 2934752, upload-time = "2025-05-25T14:16:32.204Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/063bc9ddc3d1c73e959054f1fc091b79572e716ef74d6caaa56e945b4af9/cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490", size = 3412465, upload-time = "2025-05-25T14:16:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/71/9b/04ead6015229a9396890d7654ee35ef630860fb42dc9ff9ec27f72157952/cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06", size = 7031892, upload-time = "2025-05-25T14:16:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/c7d05d0e133a09fc677b8a87953815c522697bdf025e5cac13ba419e7240/cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57", size = 4196181, upload-time = "2025-05-25T14:16:37.934Z" }, + { url = "https://files.pythonhosted.org/packages/08/7a/6ad3aa796b18a683657cef930a986fac0045417e2dc428fd336cfc45ba52/cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716", size = 4423370, upload-time = "2025-05-25T14:16:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/4f/58/ec1461bfcb393525f597ac6a10a63938d18775b7803324072974b41a926b/cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8", size = 4197839, upload-time = "2025-05-25T14:16:41.322Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3d/5185b117c32ad4f40846f579369a80e710d6146c2baa8ce09d01612750db/cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc", size = 3886324, upload-time = "2025-05-25T14:16:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/67/85/caba91a57d291a2ad46e74016d1f83ac294f08128b26e2a81e9b4f2d2555/cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342", size = 4450447, upload-time = "2025-05-25T14:16:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/164e3c9d559133a38279215c712b8ba38e77735d3412f37711b9f8f6f7e0/cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b", size = 4200576, upload-time = "2025-05-25T14:16:46.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/e002d5ce624ed46dfc32abe1deff32190f3ac47ede911789ee936f5a4255/cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782", size = 4450308, upload-time = "2025-05-25T14:16:48.228Z" }, + { url = "https://files.pythonhosted.org/packages/87/ad/3fbff9c28cf09b0a71e98af57d74f3662dea4a174b12acc493de00ea3f28/cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65", size = 4325125, upload-time = "2025-05-25T14:16:49.844Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b4/51417d0cc01802304c1984d76e9592f15e4801abd44ef7ba657060520bf0/cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b", size = 4560038, upload-time = "2025-05-25T14:16:51.398Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/d572f6482d45789a7202fb87d052deb7a7b136bf17473ebff33536727a2c/cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab", size = 2924070, upload-time = "2025-05-25T14:16:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/91/5a/61f39c0ff4443651cc64e626fa97ad3099249152039952be8f344d6b0c86/cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2", size = 3395005, upload-time = "2025-05-25T14:16:55.134Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/58a246342093a66af8935d6aa59f790cbb4731adae3937b538d054bdc2f9/cryptography-45.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:edd6d51869beb7f0d472e902ef231a9b7689508e83880ea16ca3311a00bf5ce7", size = 3589802, upload-time = "2025-05-25T14:17:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/96/61/751ebea58c87b5be533c429f01996050a72c7283b59eee250275746632ea/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:555e5e2d3a53b4fabeca32835878b2818b3f23966a4efb0d566689777c5a12c8", size = 4146964, upload-time = "2025-05-25T14:17:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/28c90601b199964de383da0b740b5156f5d71a1da25e7194fdf793d373ef/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:25286aacb947286620a31f78f2ed1a32cded7be5d8b729ba3fb2c988457639e4", size = 4388103, upload-time = "2025-05-25T14:17:11.978Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ec/cd892180b9e42897446ef35c62442f5b8b039c3d63a05f618aa87ec9ebb5/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:050ce5209d5072472971e6efbfc8ec5a8f9a841de5a4db0ebd9c2e392cb81972", size = 4150031, upload-time = "2025-05-25T14:17:14.131Z" }, + { url = "https://files.pythonhosted.org/packages/db/d4/22628c2dedd99289960a682439c6d3aa248dff5215123ead94ac2d82f3f5/cryptography-45.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dc10ec1e9f21f33420cc05214989544727e776286c1c16697178978327b95c9c", size = 4387389, upload-time = "2025-05-25T14:17:17.303Z" }, + { url = "https://files.pythonhosted.org/packages/39/ec/ba3961abbf8ecb79a3586a4ff0ee08c9d7a9938b4312fb2ae9b63f48a8ba/cryptography-45.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9eda14f049d7f09c2e8fb411dda17dd6b16a3c76a1de5e249188a32aeb92de19", size = 3337432, upload-time = "2025-05-25T14:17:19.507Z" }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967, upload-time = "2024-06-20T11:30:30.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521, upload-time = "2024-06-20T11:30:28.248Z" }, +] + +[[package]] +name = "faker" +version = "37.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/4b/5354912eaff922876323f2d07e21408b10867f3295d5f917748341cb6f53/faker-37.3.0.tar.gz", hash = "sha256:77b79e7a2228d57175133af0bbcdd26dc623df81db390ee52f5104d46c010f2f", size = 1901376, upload-time = "2025-05-14T15:24:18.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/99/045b2dae19a01b9fbb23b9971bc04f4ef808e7f3a213d08c81067304a210/faker-37.3.0-py3-none-any.whl", hash = "sha256:48c94daa16a432f2d2bc803c7ff602509699fca228d13e97e379cd860a7e216e", size = 1942203, upload-time = "2025-05-14T15:24:16.159Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.14" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/53/8c38a874844a8b0fa10dd8adf3836ac154082cf88d3f22b544e9ceea0a15/fastapi-0.115.14.tar.gz", hash = "sha256:b1de15cdc1c499a4da47914db35d0e4ef8f1ce62b624e94e0e5824421df99739", size = 296263, upload-time = "2025-06-26T15:29:08.21Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/50/b1222562c6d270fea83e9c9075b8e8600b8479150a18e4516a6138b980d1/fastapi-0.115.14-py3-none-any.whl", hash = "sha256:6c0c8bf9420bd58f565e585036d971872472b4f7d3f6c73b698e10cffdefb3ca", size = 95514, upload-time = "2025-06-26T15:29:06.49Z" }, +] + +[[package]] +name = "fastapi-boilerplate" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "arq" }, + { name = "asyncpg" }, + { name = "bcrypt" }, + { name = "crudadmin" }, + { name = "fastapi" }, + { name = "fastcrud" }, + { name = "greenlet" }, + { name = "gunicorn" }, + { name = "httptools" }, + { name = "httpx" }, + { name = "mypy" }, + { name = "niquests" }, + { name = "psycopg2-binary" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-jose" }, + { name = "python-multipart" }, + { name = "redis" }, + { name = "ruff" }, + { name = "sqlalchemy" }, + { name = "sqlalchemy-utils" }, + { name = "uuid" }, + { name = "uuid6" }, + { name = "uvicorn" }, + { name = "uvloop" }, +] + +[package.optional-dependencies] +dev = [ + { name = "faker" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-mock" }, + { name = "ruff" }, + { name = "types-redis" }, +] + +[package.dev-dependencies] +dev = [ + { name = "openapi-generator-cli" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13.1" }, + { name = "arq", specifier = ">=0.25.0" }, + { name = "asyncpg", specifier = ">=0.29.0" }, + { name = "bcrypt", specifier = ">=4.1.1" }, + { name = "crudadmin", specifier = ">=0.4.2" }, + { name = "faker", marker = "extra == 'dev'", specifier = ">=26.0.0" }, + { name = "fastapi", specifier = ">=0.109.1" }, + { name = "fastcrud", specifier = ">=0.15.5" }, + { name = "greenlet", specifier = ">=2.0.2" }, + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "httptools", specifier = ">=0.6.1" }, + { name = "httpx", specifier = ">=0.26.0" }, + { name = "mypy", specifier = ">=1.16.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, + { name = "niquests", specifier = ">=3.15.2" }, + { name = "psycopg2-binary", specifier = ">=2.9.9" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.6.1" }, + { name = "pydantic-settings", specifier = ">=2.0.3" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.2" }, + { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.14.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-jose", specifier = ">=3.3.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "redis", specifier = ">=5.0.1" }, + { name = "ruff", specifier = ">=0.11.13" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "sqlalchemy", specifier = ">=2.0.25" }, + { name = "sqlalchemy-utils", specifier = ">=0.41.1" }, + { name = "types-redis", marker = "extra == 'dev'", specifier = ">=4.6.0" }, + { name = "uuid", specifier = ">=1.30" }, + { name = "uuid6", specifier = ">=2024.1.12" }, + { name = "uvicorn", specifier = ">=0.27.0" }, + { name = "uvloop", specifier = ">=0.19.0" }, +] +provides-extras = ["dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "openapi-generator-cli", specifier = ">=7.16.0" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, +] + +[[package]] +name = "fastcrud" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "pydantic" }, + { name = "sqlalchemy" }, + { name = "sqlalchemy-utils" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/53/f75ee9b3661761dd9dcd27bcf5cd95ba8b0f1df67b43c4953e34deab5a26/fastcrud-0.15.12.tar.gz", hash = "sha256:8fe76abd176e8f506e4cf6193f350a291e1932a3a1c3606c2f8bc26b992c4ca4", size = 41216, upload-time = "2025-06-09T00:47:13.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/5c/3be53d780d58b99e94cc5c0f4e2a9b8573aac2713e62c9aade02176b9aec/fastcrud-0.15.12-py3-none-any.whl", hash = "sha256:8a828a2f838f437cd7fec8bde639b45b4e63d5482bd725c5e2214a9c9d2493df", size = 55747, upload-time = "2025-06-09T00:47:12.465Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752, upload-time = "2025-06-05T16:16:09.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/2e/d4fcb2978f826358b673f779f78fa8a32ee37df11920dc2bb5589cbeecef/greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822", size = 270219, upload-time = "2025-06-05T16:10:10.414Z" }, + { url = "https://files.pythonhosted.org/packages/16/24/929f853e0202130e4fe163bc1d05a671ce8dcd604f790e14896adac43a52/greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83", size = 630383, upload-time = "2025-06-05T16:38:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b2/0320715eb61ae70c25ceca2f1d5ae620477d246692d9cc284c13242ec31c/greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf", size = 642422, upload-time = "2025-06-05T16:41:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/445fd1a210f4747fedf77615d941444349c6a3a4a1135bba9701337cd966/greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b", size = 638375, upload-time = "2025-06-05T16:48:18.235Z" }, + { url = "https://files.pythonhosted.org/packages/7e/c8/ca19760cf6eae75fa8dc32b487e963d863b3ee04a7637da77b616703bc37/greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147", size = 637627, upload-time = "2025-06-05T16:13:02.858Z" }, + { url = "https://files.pythonhosted.org/packages/65/89/77acf9e3da38e9bcfca881e43b02ed467c1dedc387021fc4d9bd9928afb8/greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5", size = 585502, upload-time = "2025-06-05T16:12:49.642Z" }, + { url = "https://files.pythonhosted.org/packages/97/c6/ae244d7c95b23b7130136e07a9cc5aadd60d59b5951180dc7dc7e8edaba7/greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc", size = 1114498, upload-time = "2025-06-05T16:36:46.598Z" }, + { url = "https://files.pythonhosted.org/packages/89/5f/b16dec0cbfd3070658e0d744487919740c6d45eb90946f6787689a7efbce/greenlet-3.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:751261fc5ad7b6705f5f76726567375bb2104a059454e0226e1eef6c756748ba", size = 1139977, upload-time = "2025-06-05T16:12:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/66/77/d48fb441b5a71125bcac042fc5b1494c806ccb9a1432ecaa421e72157f77/greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34", size = 297017, upload-time = "2025-06-05T16:25:05.225Z" }, + { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992, upload-time = "2025-06-05T16:11:23.467Z" }, + { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820, upload-time = "2025-06-05T16:38:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046, upload-time = "2025-06-05T16:41:36.343Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701, upload-time = "2025-06-05T16:48:19.604Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747, upload-time = "2025-06-05T16:13:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461, upload-time = "2025-06-05T16:12:50.792Z" }, + { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190, upload-time = "2025-06-05T16:36:48.59Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055, upload-time = "2025-06-05T16:12:40.457Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817, upload-time = "2025-06-05T16:29:49.244Z" }, + { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732, upload-time = "2025-06-05T16:10:08.26Z" }, + { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033, upload-time = "2025-06-05T16:38:53.983Z" }, + { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999, upload-time = "2025-06-05T16:41:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368, upload-time = "2025-06-05T16:48:21.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037, upload-time = "2025-06-05T16:13:06.402Z" }, + { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402, upload-time = "2025-06-05T16:12:51.91Z" }, + { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577, upload-time = "2025-06-05T16:36:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121, upload-time = "2025-06-05T16:12:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603, upload-time = "2025-06-05T16:20:12.651Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479, upload-time = "2025-06-05T16:10:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952, upload-time = "2025-06-05T16:38:55.125Z" }, + { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917, upload-time = "2025-06-05T16:41:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443, upload-time = "2025-06-05T16:48:23.113Z" }, + { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995, upload-time = "2025-06-05T16:13:07.972Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320, upload-time = "2025-06-05T16:12:53.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236, upload-time = "2025-06-05T16:15:20.111Z" }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, +] + +[[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 = "hiredis" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/08/24b72f425b75e1de7442fb1740f69ca66d5820b9f9c0e2511ff9aadab3b7/hiredis-3.2.1.tar.gz", hash = "sha256:5a5f64479bf04dd829fe7029fad0ea043eac4023abc6e946668cbbec3493a78d", size = 89096, upload-time = "2025-05-23T11:41:57.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/84/2ea9636f2ba0811d9eb3bebbbfa84f488238180ddab70c9cb7fa13419d78/hiredis-3.2.1-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:e4ae0be44cab5e74e6e4c4a93d04784629a45e781ff483b136cc9e1b9c23975c", size = 82425, upload-time = "2025-05-23T11:39:54.135Z" }, + { url = "https://files.pythonhosted.org/packages/fc/24/b9ebf766a99998fda3975937afa4912e98de9d7f8d0b83f48096bdd961c1/hiredis-3.2.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:24647e84c9f552934eb60b7f3d2116f8b64a7020361da9369e558935ca45914d", size = 45231, upload-time = "2025-05-23T11:39:55.455Z" }, + { url = "https://files.pythonhosted.org/packages/68/4c/c009b4d9abeb964d607f0987561892d1589907f770b9e5617552b34a4a4d/hiredis-3.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6fb3e92d1172da8decc5f836bf8b528c0fc9b6d449f1353e79ceeb9dc1801132", size = 43240, upload-time = "2025-05-23T11:39:57.8Z" }, + { url = "https://files.pythonhosted.org/packages/e9/83/d53f3ae9e4ac51b8a35afb7ccd68db871396ed1d7c8ba02ce2c30de0cf17/hiredis-3.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38ba7a32e51e518b6b3e470142e52ed2674558e04d7d73d86eb19ebcb37d7d40", size = 169624, upload-time = "2025-05-23T11:40:00.055Z" }, + { url = "https://files.pythonhosted.org/packages/91/2f/f9f091526e22a45385d45f3870204dc78aee365b6fe32e679e65674da6a7/hiredis-3.2.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4fc632be73174891d6bb71480247e57b2fd8f572059f0a1153e4d0339e919779", size = 165799, upload-time = "2025-05-23T11:40:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cc/e561274438cdb19794f0638136a5a99a9ca19affcb42679b12a78016b8ad/hiredis-3.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f03e6839ff21379ad3c195e0700fc9c209e7f344946dea0f8a6d7b5137a2a141", size = 180612, upload-time = "2025-05-23T11:40:02.385Z" }, + { url = "https://files.pythonhosted.org/packages/83/ba/a8a989f465191d55672e57aea2a331bfa3a74b5cbc6f590031c9e11f7491/hiredis-3.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99983873e37c71bb71deb544670ff4f9d6920dab272aaf52365606d87a4d6c73", size = 169934, upload-time = "2025-05-23T11:40:03.524Z" }, + { url = "https://files.pythonhosted.org/packages/52/5f/1148e965df1c67b17bdcaef199f54aec3def0955d19660a39c6ee10a6f55/hiredis-3.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffd982c419f48e3a57f592678c72474429465bb4bfc96472ec805f5d836523f0", size = 170074, upload-time = "2025-05-23T11:40:04.618Z" }, + { url = "https://files.pythonhosted.org/packages/43/5e/e6846ad159a938b539fb8d472e2e68cb6758d7c9454ea0520211f335ea72/hiredis-3.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bc993f4aa4abc029347f309e722f122e05a3b8a0c279ae612849b5cc9dc69f2d", size = 164158, upload-time = "2025-05-23T11:40:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a1/5891e0615f0993f194c1b51a65aaac063b0db318a70df001b28e49f0579d/hiredis-3.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dde790d420081f18b5949227649ccb3ed991459df33279419a25fcae7f97cd92", size = 162591, upload-time = "2025-05-23T11:40:07.041Z" }, + { url = "https://files.pythonhosted.org/packages/d4/da/8bce52ca81716f53c1014f689aea4c170ba6411e6848f81a1bed1fc375eb/hiredis-3.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b0c8cae7edbef860afcf3177b705aef43e10b5628f14d5baf0ec69668247d08d", size = 174808, upload-time = "2025-05-23T11:40:09.146Z" }, + { url = "https://files.pythonhosted.org/packages/84/91/fc1ef444ed4dc432b5da9b48e9bd23266c703528db7be19e2b608d67ba06/hiredis-3.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e8a90eaca7e1ce7f175584f07a2cdbbcab13f4863f9f355d7895c4d28805f65b", size = 167060, upload-time = "2025-05-23T11:40:10.757Z" }, + { url = "https://files.pythonhosted.org/packages/66/ad/beebf73a5455f232b97e00564d1e8ad095d4c6e18858c60c6cfdd893ac1e/hiredis-3.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:476031958fa44e245e803827e0787d49740daa4de708fe514370293ce519893a", size = 164833, upload-time = "2025-05-23T11:40:12.001Z" }, + { url = "https://files.pythonhosted.org/packages/75/79/a9591bdc0148c0fbdf54cf6f3d449932d3b3b8779e87f33fa100a5a8088f/hiredis-3.2.1-cp311-cp311-win32.whl", hash = "sha256:eb3f5df2a9593b4b4b676dce3cea53b9c6969fc372875188589ddf2bafc7f624", size = 20402, upload-time = "2025-05-23T11:40:13.216Z" }, + { url = "https://files.pythonhosted.org/packages/9f/05/c93cc6fab31e3c01b671126c82f44372fb211facb8bd4571fd372f50898d/hiredis-3.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1402e763d8a9fdfcc103bbf8b2913971c0a3f7b8a73deacbda3dfe5f3a9d1e0b", size = 22085, upload-time = "2025-05-23T11:40:14.19Z" }, + { url = "https://files.pythonhosted.org/packages/60/a1/6da1578a22df1926497f7a3f6a3d2408fe1d1559f762c1640af5762a8eb6/hiredis-3.2.1-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:3742d8b17e73c198cabeab11da35f2e2a81999d406f52c6275234592256bf8e8", size = 82627, upload-time = "2025-05-23T11:40:15.362Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b1/1056558ca8dc330be5bb25162fe5f268fee71571c9a535153df9f871a073/hiredis-3.2.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:9c2f3176fb617a79f6cccf22cb7d2715e590acb534af6a82b41f8196ad59375d", size = 45404, upload-time = "2025-05-23T11:40:16.72Z" }, + { url = "https://files.pythonhosted.org/packages/58/4f/13d1fa1a6b02a99e9fed8f546396f2d598c3613c98e6c399a3284fa65361/hiredis-3.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a8bd46189c7fa46174e02670dc44dfecb60f5bd4b67ed88cb050d8f1fd842f09", size = 43299, upload-time = "2025-05-23T11:40:17.697Z" }, + { url = "https://files.pythonhosted.org/packages/c0/25/ddfac123ba5a32eb1f0b40ba1b2ec98a599287f7439def8856c3c7e5dd0d/hiredis-3.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f86ee4488c8575b58139cdfdddeae17f91e9a893ffee20260822add443592e2f", size = 172194, upload-time = "2025-05-23T11:40:19.143Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/443a3703ce570b631ca43494094fbaeb051578a0ebe4bfcefde351e1ba25/hiredis-3.2.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3717832f4a557b2fe7060b9d4a7900e5de287a15595e398c3f04df69019ca69d", size = 168429, upload-time = "2025-05-23T11:40:20.329Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/0d8c6c706ed79b2298c001b5458c055615e3166533dcee3900e821a18a3e/hiredis-3.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e5cb12c21fb9e2403d28c4e6a38120164973342d34d08120f2d7009b66785644", size = 182967, upload-time = "2025-05-23T11:40:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/da8dd231fbce858b5a20ab7d7bf558912cd125f08bac4c778865ef5fe2c2/hiredis-3.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:080fda1510bbd389af91f919c11a4f2aa4d92f0684afa4709236faa084a42cac", size = 172495, upload-time = "2025-05-23T11:40:23.105Z" }, + { url = "https://files.pythonhosted.org/packages/65/25/83a31420535e2778662caa95533d5c997011fa6a88331f0cdb22afea9ec3/hiredis-3.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1252e10a1f3273d1c6bf2021e461652c2e11b05b83e0915d6eb540ec7539afe2", size = 173142, upload-time = "2025-05-23T11:40:24.24Z" }, + { url = "https://files.pythonhosted.org/packages/41/d7/cb907348889eb75e2aa2e6b63e065b611459e0f21fe1e371a968e13f0d55/hiredis-3.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d9e320e99ab7d2a30dc91ff6f745ba38d39b23f43d345cdee9881329d7b511d6", size = 166433, upload-time = "2025-05-23T11:40:25.287Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/7cbc69d82af7b29a95723d50f5261555ba3d024bfbdc414bdc3d23c0defb/hiredis-3.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:641668f385f16550fdd6fdc109b0af6988b94ba2acc06770a5e06a16e88f320c", size = 164883, upload-time = "2025-05-23T11:40:26.454Z" }, + { url = "https://files.pythonhosted.org/packages/f9/00/f995b1296b1d7e0247651347aa230f3225a9800e504fdf553cf7cd001cf7/hiredis-3.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e1f44208c39d6c345ff451f82f21e9eeda6fe9af4ac65972cc3eeb58d41f7cb", size = 177262, upload-time = "2025-05-23T11:40:27.576Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/723a67d729e94764ce9e0d73fa5f72a0f87d3ce3c98c9a0b27cbf001cc79/hiredis-3.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f882a0d6415fffe1ffcb09e6281d0ba8b1ece470e866612bbb24425bf76cf397", size = 169619, upload-time = "2025-05-23T11:40:29.671Z" }, + { url = "https://files.pythonhosted.org/packages/45/58/f69028df00fb1b223e221403f3be2059ae86031e7885f955d26236bdfc17/hiredis-3.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b4e78719a0730ebffe335528531d154bc8867a246418f74ecd88adbc4d938c49", size = 167303, upload-time = "2025-05-23T11:40:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/2b/7d/567411e65cce76cf265a9a4f837fd2ebc564bef6368dd42ac03f7a517c0a/hiredis-3.2.1-cp312-cp312-win32.whl", hash = "sha256:33c4604d9f79a13b84da79950a8255433fca7edaf292bbd3364fd620864ed7b2", size = 20551, upload-time = "2025-05-23T11:40:32.69Z" }, + { url = "https://files.pythonhosted.org/packages/90/74/b4c291eb4a4a874b3690ff9fc311a65d5292072556421b11b1d786e3e1d0/hiredis-3.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7b9749375bf9d171aab8813694f379f2cff0330d7424000f5e92890ad4932dc9", size = 22128, upload-time = "2025-05-23T11:40:33.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/91/c07e737288e891c974277b9fa090f0a43c72ab6ccb5182117588f1c01269/hiredis-3.2.1-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:7cabf7f1f06be221e1cbed1f34f00891a7bdfad05b23e4d315007dd42148f3d4", size = 82636, upload-time = "2025-05-23T11:40:35.035Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/02cb1820360eda419bc17eb835eca976079e2b3e48aecc5de0666b79a54c/hiredis-3.2.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:db85cb86f8114c314d0ec6d8de25b060a2590b4713135240d568da4f7dea97ac", size = 45404, upload-time = "2025-05-23T11:40:36.113Z" }, + { url = "https://files.pythonhosted.org/packages/87/51/d30a4aadab8670ed9d40df4982bc06c891ee1da5cdd88d16a74e1ecbd520/hiredis-3.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c9a592a49b7b8497e4e62c3ff40700d0c7f1a42d145b71e3e23c385df573c964", size = 43301, upload-time = "2025-05-23T11:40:37.557Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7b/2c613e1bb5c2e2bac36e8befeefdd58b42816befb17e26ab600adfe337fb/hiredis-3.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0079ef1e03930b364556b78548e67236ab3def4e07e674f6adfc52944aa972dd", size = 172486, upload-time = "2025-05-23T11:40:38.659Z" }, + { url = "https://files.pythonhosted.org/packages/1e/df/8f2c4fcc28d6f5178b25ee1ba2157cc473f9908c16ce4b8e0bdd79e38b05/hiredis-3.2.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d6a290ed45d9c14f4c50b6bda07afb60f270c69b5cb626fd23a4c2fde9e3da1", size = 168532, upload-time = "2025-05-23T11:40:39.843Z" }, + { url = "https://files.pythonhosted.org/packages/88/ae/d0864ffaa0461e29a6940a11c858daf78c99476c06ed531b41ad2255ec25/hiredis-3.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79dd5fe8c0892769f82949adeb021342ca46871af26e26945eb55d044fcdf0d0", size = 183216, upload-time = "2025-05-23T11:40:41.005Z" }, + { url = "https://files.pythonhosted.org/packages/75/17/558e831b77692d73f5bcf8b493ab3eace9f11b0aa08839cdbb87995152c7/hiredis-3.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:998a82281a159f4aebbfd4fb45cfe24eb111145206df2951d95bc75327983b58", size = 172689, upload-time = "2025-05-23T11:40:42.153Z" }, + { url = "https://files.pythonhosted.org/packages/35/b9/4fccda21f930f08c5072ad51e825d85d457748138443d7b510afe77b8264/hiredis-3.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41fc3cd52368ffe7c8e489fb83af5e99f86008ed7f9d9ba33b35fec54f215c0a", size = 173319, upload-time = "2025-05-23T11:40:43.328Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/596d613588b0a3c58dfcf9a17edc6a886c4de6a3096e27c7142a94e2304d/hiredis-3.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8d10df3575ce09b0fa54b8582f57039dcbdafde5de698923a33f601d2e2a246c", size = 166695, upload-time = "2025-05-23T11:40:44.453Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5b/6a1c266e9f6627a8be1fa0d8622e35e35c76ae40cce6d1c78a7e6021184a/hiredis-3.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ab010d04be33735ad8e643a40af0d68a21d70a57b1d0bff9b6a66b28cca9dbf", size = 165181, upload-time = "2025-05-23T11:40:45.697Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/a9b91fa70d21763d9dfd1c27ddd378f130749a0ae4a0645552f754b3d1fc/hiredis-3.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ec3b5f9ea34f70aaba3e061cbe1fa3556fea401d41f5af321b13e326792f3017", size = 177589, upload-time = "2025-05-23T11:40:46.903Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c7/31bbb015156dc4441f6e19daa9598266a61445bf3f6e14c44292764638f6/hiredis-3.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:158dfb505fff6bffd17f823a56effc0c2a7a8bc4fb659d79a52782f22eefc697", size = 169883, upload-time = "2025-05-23T11:40:48.111Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/cddc23379e0ce20ad7514b2adb2aa2c9b470ffb1ca0a2d8c020748962a22/hiredis-3.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d632cd0ddd7895081be76748e6fb9286f81d2a51c371b516541c6324f2fdac9", size = 167585, upload-time = "2025-05-23T11:40:49.208Z" }, + { url = "https://files.pythonhosted.org/packages/48/92/8fc9b981ed01fc2bbac463a203455cd493482b749801bb555ebac72923f1/hiredis-3.2.1-cp313-cp313-win32.whl", hash = "sha256:e9726d03e7df068bf755f6d1ecc61f7fc35c6b20363c7b1b96f39a14083df940", size = 20554, upload-time = "2025-05-23T11:40:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6e/e76341d68aa717a705a2ee3be6da9f4122a0d1e3f3ad93a7104ed7a81bea/hiredis-3.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:b5b1653ad7263a001f2e907e81a957d6087625f9700fa404f1a2268c0a4f9059", size = 22136, upload-time = "2025-05-23T11:40:51.497Z" }, +] + +[[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 = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/26/bb526d4d14c2774fe07113ca1db7255737ffbb119315839af2065abfdac3/httptools-0.6.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f47f8ed67cc0ff862b84a1189831d1d33c963fb3ce1ee0c65d3b0cbe7b711069", size = 199029, upload-time = "2024-10-16T19:44:18.427Z" }, + { url = "https://files.pythonhosted.org/packages/a6/17/3e0d3e9b901c732987a45f4f94d4e2c62b89a041d93db89eafb262afd8d5/httptools-0.6.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0614154d5454c21b6410fdf5262b4a3ddb0f53f1e1721cfd59d55f32138c578a", size = 103492, upload-time = "2024-10-16T19:44:19.515Z" }, + { url = "https://files.pythonhosted.org/packages/b7/24/0fe235d7b69c42423c7698d086d4db96475f9b50b6ad26a718ef27a0bce6/httptools-0.6.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8787367fbdfccae38e35abf7641dafc5310310a5987b689f4c32cc8cc3ee975", size = 462891, upload-time = "2024-10-16T19:44:21.067Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2f/205d1f2a190b72da6ffb5f41a3736c26d6fa7871101212b15e9b5cd8f61d/httptools-0.6.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b0f7fe4fd38e6a507bdb751db0379df1e99120c65fbdc8ee6c1d044897a636", size = 459788, upload-time = "2024-10-16T19:44:22.958Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4c/d09ce0eff09057a206a74575ae8f1e1e2f0364d20e2442224f9e6612c8b9/httptools-0.6.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40a5ec98d3f49904b9fe36827dcf1aadfef3b89e2bd05b0e35e94f97c2b14721", size = 433214, upload-time = "2024-10-16T19:44:24.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/84c9e23edbccc4a4c6f96a1b8d99dfd2350289e94f00e9ccc7aadde26fb5/httptools-0.6.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dacdd3d10ea1b4ca9df97a0a303cbacafc04b5cd375fa98732678151643d4988", size = 434120, upload-time = "2024-10-16T19:44:26.295Z" }, + { url = "https://files.pythonhosted.org/packages/d0/46/4d8e7ba9581416de1c425b8264e2cadd201eb709ec1584c381f3e98f51c1/httptools-0.6.4-cp311-cp311-win_amd64.whl", hash = "sha256:288cd628406cc53f9a541cfaf06041b4c71d751856bab45e3702191f931ccd17", size = 88565, upload-time = "2024-10-16T19:44:29.188Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" }, + { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "jh2" +version = "5.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/ed/466eb2a162d9cfaa8452e9a05d24b4fc11d4cf84cf27f5a71457dc590323/jh2-5.0.10.tar.gz", hash = "sha256:2c737a47bee50dc727f7a766185e110befdceba5efb1c4fa240b1e4399291487", size = 7301475, upload-time = "2025-10-05T06:18:59.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/88/91e402bd0e323f3c7895d8eb6e79efe7d94bf40e035f6abcd9da0a08325c/jh2-5.0.10-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5a6885a315bdd24d822873d5e581eac90ab25589fb48d34f822352710139439a", size = 603894, upload-time = "2025-10-05T06:16:22.199Z" }, + { url = "https://files.pythonhosted.org/packages/3e/52/cdf454b01bdf7432848f7576b6054826fc65d77062324164995ff77a813d/jh2-5.0.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa031e2aba9bd4cf6e1c0514764781b907557484cf163f02f1ad65a5932faf2", size = 378697, upload-time = "2025-10-05T06:16:24.128Z" }, + { url = "https://files.pythonhosted.org/packages/8e/dd/6e7106bc020e9fc13a70476c95cd4b40d2d301ef1c5ff7cd093adeec2143/jh2-5.0.10-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c816cfe85ae5d4fb26efc2713aedf9dfe1bb826544fe76cfd35ce7a60e099e8f", size = 390293, upload-time = "2025-10-05T06:16:25.723Z" }, + { url = "https://files.pythonhosted.org/packages/88/94/e64d83f8d2f5b7490e32d12f0ba3835b45b19d14af72ea592aacfb65592e/jh2-5.0.10-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b28c70b440f32bb8f14c3adaa11c094ea109fc1d2540434a8fc6e08cf4cf1aef", size = 524781, upload-time = "2025-10-05T06:16:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/25/72/a2c72aff206bc27f3373982d318f305d31aca62dd5daa0c4e50c528208bb/jh2-5.0.10-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:df79bdf69d33ec0093c63abb633a6cbdb4b905a5ea3dda2514c4adf7e5713d20", size = 518173, upload-time = "2025-10-05T06:16:29.029Z" }, + { url = "https://files.pythonhosted.org/packages/26/27/e41b23fa62a0bbbf87cefdecbd938056a44b9c47a454a11edd760b92a9b3/jh2-5.0.10-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c0b7cda4b50692e2f259382fc2a374cd9118a96f8d369ef04fe088124f84fc", size = 409290, upload-time = "2025-10-05T06:16:30.628Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ed/516bdea8ff60bb321b92bac7d3b99a8aee322495e8b4dccc5b42eeede0b7/jh2-5.0.10-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ffeb6a352ce6c89d77bd185972185f20e3c35b3be4d0253963b6d8b6444c4aa", size = 386155, upload-time = "2025-10-05T06:16:31.898Z" }, + { url = "https://files.pythonhosted.org/packages/12/18/057408a548a66eb069c2fa12bfd13c1e34eaae07603f40ce32743ce0faa6/jh2-5.0.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:271720035a1293031c807e24a27235a2426c51de8755db872eb1adad310213cd", size = 403861, upload-time = "2025-10-05T06:16:33.036Z" }, + { url = "https://files.pythonhosted.org/packages/3d/70/73d22e62af0e756cb3b86ee57b67b117efe75091d56ff286bf136adde863/jh2-5.0.10-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:7b28fc12106654573881f19b1a78e41cf37657ae76efa84e7da518faced56f0f", size = 559909, upload-time = "2025-10-05T06:16:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/66/fa/5d5e4304ecfa27773bbe036454b782882fc2ab02f7521ae0d367514c7618/jh2-5.0.10-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:976134b61b7fdf290a7cc70e7676c2605af84dd83e2a1e78c170928a0119278b", size = 653300, upload-time = "2025-10-05T06:16:35.825Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/31583a8bcbe58ee13b30bdf9d82ca52a3272a13c45384bf8e193a2e4541f/jh2-5.0.10-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:75581b5acbcc344e070f811938919165999bf7221406845306e0ab860605cdbf", size = 580061, upload-time = "2025-10-05T06:16:37.598Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/ffcc082b72c7f83512cc1dbda866afa5c0dbd76c423e6f0294496744af27/jh2-5.0.10-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:206e4a92c687f6928b3846d7666ebf2602c16556feb154f16f1d399219046f5d", size = 550849, upload-time = "2025-10-05T06:16:38.887Z" }, + { url = "https://files.pythonhosted.org/packages/19/da/01f0df3a779d0d2e8b7ce121da48b1a91346264fd494c0be16a74e5c1c4f/jh2-5.0.10-cp313-cp313t-win32.whl", hash = "sha256:fca31a36827205c76c959be94e8bad7aaa1be06ea8ed904b20b5d96a7829ce45", size = 234414, upload-time = "2025-10-05T06:16:40.509Z" }, + { url = "https://files.pythonhosted.org/packages/82/c1/42a68cbe4c6ee9453d3685bd4ebce8e3bccbda608b3c26f380cf30640825/jh2-5.0.10-cp313-cp313t-win_amd64.whl", hash = "sha256:4bbefb69efaa365f3193d0db096d34e9e0da5885b0bb1341ab7593852e811f69", size = 241768, upload-time = "2025-10-05T06:16:41.701Z" }, + { url = "https://files.pythonhosted.org/packages/10/14/4d89e958e2a98ee09895290a105caee5cd158fb456fc9aae913f15251184/jh2-5.0.10-cp313-cp313t-win_arm64.whl", hash = "sha256:9752ea045ab3da4544104201a800d3f7ce7c63b529db5a9715c587cbfedca9b7", size = 237090, upload-time = "2025-10-05T06:16:43.228Z" }, + { url = "https://files.pythonhosted.org/packages/62/2d/d1d5161adadacd04afb98016c86ca3c429e89ec5e46a93c1f9bd613d9e2e/jh2-5.0.10-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e340215fa14b914096aa2e0960798603939f6bc85e9467057121c3aae4eadda1", size = 603721, upload-time = "2025-10-05T06:16:44.48Z" }, + { url = "https://files.pythonhosted.org/packages/77/88/06dd26cfd8e47f8b573af4be09161d0b0b3a26357cb5224da4ceebbb9d11/jh2-5.0.10-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6124124ea77ba77b22cb1f66554eddd61b14a2ce0bb2bc2032d91c3a2b9cbfed", size = 379003, upload-time = "2025-10-05T06:16:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/d6/37/e6abb173b034151eca851ad18908f97cb984bf657c744a4ee72bd4836862/jh2-5.0.10-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:db95f18b629f5dc914cf962725b7dfcb9673c4bb06e50d654426615c3d8d88d2", size = 389806, upload-time = "2025-10-05T06:16:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/bd/aa/158f6ebafac187f80837d024b864e547ffe4a0ffa4df368c6b5d1dd20f49/jh2-5.0.10-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c16af652777ce4edc0627d037ac5195b921665b2e13e3547782116ce5a62cd5a", size = 528227, upload-time = "2025-10-05T06:16:48.98Z" }, + { url = "https://files.pythonhosted.org/packages/2e/26/4fe6ec57e9e6610443dea281a246b86438f9f6ea090adee4095ce3096f70/jh2-5.0.10-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0190daf5b65fbf52641ba761d1b895b74abcdb671ca1fb6cd138dd49050cfa8", size = 521170, upload-time = "2025-10-05T06:16:50.274Z" }, + { url = "https://files.pythonhosted.org/packages/d8/87/bbbaf7f146788544c5584142a7a4f5997147d65588ceed4a1ac769b7ab2d/jh2-5.0.10-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:578394d8a9409d540b45138cbecb9d611830ccce6c7f81157c96f2d8d54abd5a", size = 409999, upload-time = "2025-10-05T06:16:51.691Z" }, + { url = "https://files.pythonhosted.org/packages/0d/6f/ff1df3a83daa557e30ce0df48cf789a7faa0521ac56014e38fdd68457e79/jh2-5.0.10-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:108143494bba0b1bf5f8cd205f9c659667235e788d3e3f0f454ad8e52f2c8056", size = 386010, upload-time = "2025-10-05T06:16:53.256Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c7/e3ba47b2cc066b99084278fd77828a2689c59e443cdf8089bd3411deb2e7/jh2-5.0.10-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:85631d5b4d0e1eb9f9daacc43e6394efd8c65eb39c7a7e8458f0c3894108003c", size = 404137, upload-time = "2025-10-05T06:16:54.854Z" }, + { url = "https://files.pythonhosted.org/packages/3a/f8/b7ae14f5d3fc0e7c66b20c94d56fcf191cf588e33d0b653da022a31f32de/jh2-5.0.10-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a9aec4760a6545a654f503d709456c1d8afb63b0ee876a1e11090b75f2fd7488", size = 560449, upload-time = "2025-10-05T06:16:56.278Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6b/abc648a4149582733618e117d42e6b64d5f0885c4311b8108e2c2e667afc/jh2-5.0.10-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:028227ec9e44fb62e073b71ca391cb1f932c5c7da3981ccafff852df81d2b7f9", size = 653077, upload-time = "2025-10-05T06:16:57.903Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ec/8ccf1a7dbdabba5cdc27220507dd839e9bc36bdd4c2bf990334642ad6b8b/jh2-5.0.10-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:2c2ed55a32735b91a0683c7b995433e39325fdf42c6ffc8e87d56606ac6235bb", size = 580386, upload-time = "2025-10-05T06:16:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/0b/57/50eab697d39b2a4d790355e304c79b3c2ab22f7a4889396155a946b8956a/jh2-5.0.10-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0075415d2a2dfdf3a8ddeaa51cf8d92fb3d7c90fa09cb9752c79ee54e960b9a8", size = 551533, upload-time = "2025-10-05T06:17:00.802Z" }, + { url = "https://files.pythonhosted.org/packages/56/f5/4f73015f4d65f1b3024043a2516062fd3f34846fe88433b3c3a0922ff5fd/jh2-5.0.10-cp314-cp314t-win32.whl", hash = "sha256:a8404e17a3d90c215e67be8a5ccb679eb9cf9bfeeec732521487b65ffaeac4a6", size = 234381, upload-time = "2025-10-05T06:17:02.558Z" }, + { url = "https://files.pythonhosted.org/packages/02/8d/61fcba06faeb9ab7d1ead7e2ef3db07264523f2d2fd463a4d2ec1780103a/jh2-5.0.10-cp314-cp314t-win_amd64.whl", hash = "sha256:5d664450ab1435f6a78c64e076c7ea22ffe779bafceb9c42600cce95ff20f927", size = 241614, upload-time = "2025-10-05T06:17:03.732Z" }, + { url = "https://files.pythonhosted.org/packages/02/67/6641130f5f4f3e9e9254121267b0e7c2423863e8c1a46ee097098e7ede8f/jh2-5.0.10-cp314-cp314t-win_arm64.whl", hash = "sha256:ad6d18301f2162996d679be6759c6a120325b58a196231220b7a809e034280ed", size = 237355, upload-time = "2025-10-05T06:17:04.931Z" }, + { url = "https://files.pythonhosted.org/packages/63/8f/fe337b9104ab3a444a7b20baffc3dd54f3227a44f3037aba2a30bf36fefd/jh2-5.0.10-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7c2918379cce3352b6d40717ed3d5b06da6e6c17253317302cab2f0dbff62a5d", size = 622842, upload-time = "2025-10-05T06:17:06.121Z" }, + { url = "https://files.pythonhosted.org/packages/63/37/f3c59f62e771755b31b6d1ce9124d4ab40bc3a2206116bfd879a33c1b02f/jh2-5.0.10-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd229bbe7dfdf499394fa8453c92e2ea19de47c80afc1efaec7f85704be97717", size = 385674, upload-time = "2025-10-05T06:17:07.369Z" }, + { url = "https://files.pythonhosted.org/packages/03/9d/719cfd3afab6bb9115f574687fa24ea5731267ee9701439e30e06a45f468/jh2-5.0.10-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:36e9b5fb9cd8872846d031fae442df1b5c83f4dd29ef1dd1c3f70afd86fe8fc3", size = 396276, upload-time = "2025-10-05T06:17:08.933Z" }, + { url = "https://files.pythonhosted.org/packages/22/63/1f36ff610684f2cb177218c700d3a3f4f87aad4d45a6e3f59feb360812c6/jh2-5.0.10-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:217af8256551627b9253737a866c05ce5f6c49f171c099260b76915239cfb13a", size = 532742, upload-time = "2025-10-05T06:17:10.396Z" }, + { url = "https://files.pythonhosted.org/packages/59/c5/063fe0b930c0b15e8c0376d9d6cc20dcc3179823e6f456c1a571db1d27c4/jh2-5.0.10-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e20e3d747c4022d890865fb25f2e8b3ff6cbacf7556f29155dcfbff592d96202", size = 525320, upload-time = "2025-10-05T06:17:12.187Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7a/c1f4a961554f6b521bfc75320264c0bde50210c11a206719e0c98c17e617/jh2-5.0.10-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ea77b8b6b7defb67cbec69340e653495b36881671e9f0ac395cdd6b27144000", size = 416184, upload-time = "2025-10-05T06:17:13.428Z" }, + { url = "https://files.pythonhosted.org/packages/37/c9/75fdfd2ef673accba9b1ace28ffa2aaced23fba3209ac73521e441ae2265/jh2-5.0.10-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3fd6b5b2d4d38e089ac982ea341b36f2cb827c0765217e6b8f3e58a409290e6f", size = 394145, upload-time = "2025-10-05T06:17:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/52/5e/38b1b14182afcebf89d542b7a2e4cd4d7deaf9e4cae0a45b0a85115f0da7/jh2-5.0.10-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c3d38121a1ddc2ffc769f09aaaa4c7e67f89e25c578921326b515402176e7cf", size = 411333, upload-time = "2025-10-05T06:17:15.913Z" }, + { url = "https://files.pythonhosted.org/packages/a8/04/10383a467f24d2643013f784bca4ddb81dc9d0d81641a846b71bd0aa64e0/jh2-5.0.10-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:33e4ac058acf8f3f89ea1375deb33ac61713d60fb9e3333bd079f3602425739c", size = 565892, upload-time = "2025-10-05T06:17:17.517Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1a/b416bd65176593b405320bfd763ddc9cae3941fe96c7055ec1c46e081ebd/jh2-5.0.10-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:86571f18274d7a5a7e6406e156a9f27fa1a72203a176921ea234c4a11fe0e105", size = 659878, upload-time = "2025-10-05T06:17:18.972Z" }, + { url = "https://files.pythonhosted.org/packages/a9/2d/e4c3b90585e92676777e9bcb9218ce97552f0c9797cec142d549904ca67b/jh2-5.0.10-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:62f0842b5f8da463b6a548a8c2a7e69fa709888d03c997486390097341e88e09", size = 587129, upload-time = "2025-10-05T06:17:20.669Z" }, + { url = "https://files.pythonhosted.org/packages/98/d5/3e89d65bb6bbcdaa14236e9ec8f643cf77a5809d7315ce1208f0ef53927c/jh2-5.0.10-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f3b6401f089a65b87f2e5beffe81666a1c2ab1d90e8406cd49c756d66881bedc", size = 556811, upload-time = "2025-10-05T06:17:22.37Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8c/a05403065463ef759059d75e0862c94aa60d2019a7dcd41d716f6d8d6c32/jh2-5.0.10-cp37-abi3-win32.whl", hash = "sha256:7b79771bd366a11a36041f49cabc7876047cf1b99cee89df1333e6890f66d973", size = 239474, upload-time = "2025-10-05T06:17:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/96/ac/92f97f07f748bdc9280c5d50e787773bef188c29c32f6804f2fc72cca725/jh2-5.0.10-cp37-abi3-win_amd64.whl", hash = "sha256:9c730e3f40f22bd4ff0ab63ee41d70ee22aa1cc54e5cb295ae0ac3b0a016af3e", size = 246320, upload-time = "2025-10-05T06:17:25.313Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ad/e9f4035ddd847efe26914da64f9d54198bf5b0bdcfd0e8bbcf6a328e1f7c/jh2-5.0.10-cp37-abi3-win_arm64.whl", hash = "sha256:d18eccec757374afca8de31bf012a888fb82903d5803e84f2e6f0b707478ced6", size = 241790, upload-time = "2025-10-05T06:17:26.488Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bf/846ee9a66e6ee6083c7d2f113b8528dd1adc721af1701dc08a11b4617444/jh2-5.0.10-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7c7fa4c48eeb72d6fd0b4c52982454c6adbb5b9058b391f12597edc3dd9d612e", size = 612502, upload-time = "2025-10-05T06:17:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/37/3c/b16ead7a7229dcdadf64534152d493c2f17124e588f786092898769842aa/jh2-5.0.10-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0f0bac7286af3ba0556c5865322cdbfbbf77f43aa313be721523618d6d82216", size = 379754, upload-time = "2025-10-05T06:17:46.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/54/c69f1548489be4e293b34b5e4f18cf626d8e42f242c1a58c5c7db8c12b2c/jh2-5.0.10-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c409cc630faf7c06b8da5391043409a5f7758cdcd5de520999855d2df6d59d7", size = 390892, upload-time = "2025-10-05T06:17:47.766Z" }, + { url = "https://files.pythonhosted.org/packages/29/7a/60a3e9b904cb5c1ec6513fe5162514fe9540dfd50300b7a7a7e689c22fa6/jh2-5.0.10-pp311-pypy311_pp73-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2e8d3803e2cb2caca82e68eafd16db9d239888cbed7fd79ffa209898cfa89eda", size = 525641, upload-time = "2025-10-05T06:17:49.035Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a1/7008e3779b5b53f745737857a060180cbfde9a5355282407098db979fe39/jh2-5.0.10-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bba536afdab619299f30473de68144714bf8fc82dc452b77333cf66a7ab78c77", size = 519309, upload-time = "2025-10-05T06:17:50.322Z" }, + { url = "https://files.pythonhosted.org/packages/99/65/1fff90e37a7afb9ee98202ed80087b29769cffd82be89fbaebaf5b938847/jh2-5.0.10-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7807ade662d56dcf5d67d92940d97a4d277be90735e085ce4719f0242c1af82b", size = 410302, upload-time = "2025-10-05T06:17:51.689Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8d/8ee75e1ebfcedcb6d6f356e46b20124230d8c47b910690156c6b5226b984/jh2-5.0.10-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9bf0eb30d40f5a82c816c52c6007b0acd317d418ee8e1d8d32b7d2d5314519", size = 387944, upload-time = "2025-10-05T06:17:52.885Z" }, + { url = "https://files.pythonhosted.org/packages/44/00/ae089c81fb1080b09d6e33f104d99ea53f5b87e78674b85c7940ecfd41f4/jh2-5.0.10-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bf61215aa09bda4ac20e726f6dce701991fa81f043de7355899efd363444534", size = 406065, upload-time = "2025-10-05T06:17:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/03/0f/290f70104c0d3b39382f408750bf9730f693eb0329e7c395a0fffec3f047/jh2-5.0.10-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4b9d6c16b466f0dce3b9c9bcab743ca1c1e36443d0db450c353652210a89a946", size = 561006, upload-time = "2025-10-05T06:17:55.481Z" }, + { url = "https://files.pythonhosted.org/packages/11/83/94a9984aee39115e261c024e981ee4dc2e1a44c07404dec2c8b98157fbd1/jh2-5.0.10-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d752f99644c2d34a27863bdb8ac79f39938f83331f2f31d5e9c19bbf58a84ca2", size = 654202, upload-time = "2025-10-05T06:17:56.831Z" }, + { url = "https://files.pythonhosted.org/packages/32/e3/ce45ca7f4a39cfc2a6dcaf154dc2e44b822bd8fcd2b8bbc032e95c5cd46e/jh2-5.0.10-pp311-pypy311_pp73-musllinux_1_1_i686.whl", hash = "sha256:be98c17f5a2a9c0b11393c7c208f47a599664311d18aac135841d1674b15c209", size = 582456, upload-time = "2025-10-05T06:17:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/f3ed310f02c591b3463d2c11fd8d72b713eb7ef68d4e86c423f354517b9e/jh2-5.0.10-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f082f417c0219a355e209dbfde71a9a57d3c9a6e67bec91a1128cc0e25999e75", size = 552441, upload-time = "2025-10-05T06:18:00.097Z" }, + { url = "https://files.pythonhosted.org/packages/69/10/392bd070cbf08379b267db160d6f2e7609bb1dcd1c0aff5d3aa694fdcded/jh2-5.0.10-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:576037676654def515aab8926da7f2ca069d6356bb55fde1a8f2e6f4c4f291c6", size = 242510, upload-time = "2025-10-05T06:18:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/57/d0fcb025736e24cb68c4e250cc4cf63a87b7e0bd7285e188c543018d05c1/jh2-5.0.10-py3-none-any.whl", hash = "sha256:1808c0e5d355c60485bb282b34c6aa3f15069b2634eb44779fb9f3bda0256ac0", size = 98099, upload-time = "2025-10-05T06:18:58.203Z" }, +] + +[[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 = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353, upload-time = "2024-10-18T15:21:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392, upload-time = "2024-10-18T15:21:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984, upload-time = "2024-10-18T15:21:03.953Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120, upload-time = "2024-10-18T15:21:06.495Z" }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032, upload-time = "2024-10-18T15:21:07.295Z" }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057, upload-time = "2024-10-18T15:21:08.073Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359, upload-time = "2024-10-18T15:21:09.318Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306, upload-time = "2024-10-18T15:21:10.185Z" }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094, upload-time = "2024-10-18T15:21:11.005Z" }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521, upload-time = "2024-10-18T15:21:12.911Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "mypy" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/38/13c2f1abae94d5ea0354e146b95a1be9b2137a0d506728e0da037c4276f6/mypy-1.16.0.tar.gz", hash = "sha256:84b94283f817e2aa6350a14b4a8fb2a35a53c286f97c9d30f53b63620e7af8ab", size = 3323139, upload-time = "2025-05-29T13:46:12.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/c4/ff2f79db7075c274fe85b5fff8797d29c6b61b8854c39e3b7feb556aa377/mypy-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9f826aaa7ff8443bac6a494cf743f591488ea940dd360e7dd330e30dd772a5ab", size = 10884498, upload-time = "2025-05-29T13:18:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/02/07/12198e83006235f10f6a7808917376b5d6240a2fd5dce740fe5d2ebf3247/mypy-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82d056e6faa508501af333a6af192c700b33e15865bda49611e3d7d8358ebea2", size = 10011755, upload-time = "2025-05-29T13:34:00.851Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9b/5fd5801a72b5d6fb6ec0105ea1d0e01ab2d4971893076e558d4b6d6b5f80/mypy-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:089bedc02307c2548eb51f426e085546db1fa7dd87fbb7c9fa561575cf6eb1ff", size = 11800138, upload-time = "2025-05-29T13:32:55.082Z" }, + { url = "https://files.pythonhosted.org/packages/2e/81/a117441ea5dfc3746431e51d78a4aca569c677aa225bca2cc05a7c239b61/mypy-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a2322896003ba66bbd1318c10d3afdfe24e78ef12ea10e2acd985e9d684a666", size = 12533156, upload-time = "2025-05-29T13:19:12.963Z" }, + { url = "https://files.pythonhosted.org/packages/3f/38/88ec57c6c86014d3f06251e00f397b5a7daa6888884d0abf187e4f5f587f/mypy-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:021a68568082c5b36e977d54e8f1de978baf401a33884ffcea09bd8e88a98f4c", size = 12742426, upload-time = "2025-05-29T13:20:22.72Z" }, + { url = "https://files.pythonhosted.org/packages/bd/53/7e9d528433d56e6f6f77ccf24af6ce570986c2d98a5839e4c2009ef47283/mypy-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:54066fed302d83bf5128632d05b4ec68412e1f03ef2c300434057d66866cea4b", size = 9478319, upload-time = "2025-05-29T13:21:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/70/cf/158e5055e60ca2be23aec54a3010f89dcffd788732634b344fc9cb1e85a0/mypy-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5436d11e89a3ad16ce8afe752f0f373ae9620841c50883dc96f8b8805620b13", size = 11062927, upload-time = "2025-05-29T13:35:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/94/34/cfff7a56be1609f5d10ef386342ce3494158e4d506516890142007e6472c/mypy-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2622af30bf01d8fc36466231bdd203d120d7a599a6d88fb22bdcb9dbff84090", size = 10083082, upload-time = "2025-05-29T13:35:33.378Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7f/7242062ec6288c33d8ad89574df87c3903d394870e5e6ba1699317a65075/mypy-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d045d33c284e10a038f5e29faca055b90eee87da3fc63b8889085744ebabb5a1", size = 11828306, upload-time = "2025-05-29T13:21:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5f/b392f7b4f659f5b619ce5994c5c43caab3d80df2296ae54fa888b3d17f5a/mypy-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4968f14f44c62e2ec4a038c8797a87315be8df7740dc3ee8d3bfe1c6bf5dba8", size = 12702764, upload-time = "2025-05-29T13:20:42.826Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c0/7646ef3a00fa39ac9bc0938626d9ff29d19d733011be929cfea59d82d136/mypy-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb14a4a871bb8efb1e4a50360d4e3c8d6c601e7a31028a2c79f9bb659b63d730", size = 12896233, upload-time = "2025-05-29T13:18:37.446Z" }, + { url = "https://files.pythonhosted.org/packages/6d/38/52f4b808b3fef7f0ef840ee8ff6ce5b5d77381e65425758d515cdd4f5bb5/mypy-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd4e1ebe126152a7bbaa4daedd781c90c8f9643c79b9748caa270ad542f12bec", size = 9565547, upload-time = "2025-05-29T13:20:02.836Z" }, + { url = "https://files.pythonhosted.org/packages/97/9c/ca03bdbefbaa03b264b9318a98950a9c683e06472226b55472f96ebbc53d/mypy-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e056237c89f1587a3be1a3a70a06a698d25e2479b9a2f57325ddaaffc3567b", size = 11059753, upload-time = "2025-05-29T13:18:18.167Z" }, + { url = "https://files.pythonhosted.org/packages/36/92/79a969b8302cfe316027c88f7dc6fee70129490a370b3f6eb11d777749d0/mypy-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b07e107affb9ee6ce1f342c07f51552d126c32cd62955f59a7db94a51ad12c0", size = 10073338, upload-time = "2025-05-29T13:19:48.079Z" }, + { url = "https://files.pythonhosted.org/packages/14/9b/a943f09319167da0552d5cd722104096a9c99270719b1afeea60d11610aa/mypy-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6fb60cbd85dc65d4d63d37cb5c86f4e3a301ec605f606ae3a9173e5cf34997b", size = 11827764, upload-time = "2025-05-29T13:46:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/ff75e71c65a0cb6ee737287c7913ea155845a556c64144c65b811afdb9c7/mypy-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7e32297a437cc915599e0578fa6bc68ae6a8dc059c9e009c628e1c47f91495d", size = 12701356, upload-time = "2025-05-29T13:35:13.553Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ad/0e93c18987a1182c350f7a5fab70550852f9fabe30ecb63bfbe51b602074/mypy-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:afe420c9380ccec31e744e8baff0d406c846683681025db3531b32db56962d52", size = 12900745, upload-time = "2025-05-29T13:17:24.409Z" }, + { url = "https://files.pythonhosted.org/packages/28/5d/036c278d7a013e97e33f08c047fe5583ab4f1fc47c9a49f985f1cdd2a2d7/mypy-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:55f9076c6ce55dd3f8cd0c6fff26a008ca8e5131b89d5ba6d86bd3f47e736eeb", size = 9572200, upload-time = "2025-05-29T13:33:44.92Z" }, + { url = "https://files.pythonhosted.org/packages/99/a3/6ed10530dec8e0fdc890d81361260c9ef1f5e5c217ad8c9b21ecb2b8366b/mypy-1.16.0-py3-none-any.whl", hash = "sha256:29e1499864a3888bca5c1542f2d7232c6e586295183320caa95758fc84034031", size = 2265773, upload-time = "2025-05-29T13:35:18.762Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "niquests" +version = "3.15.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "charset-normalizer" }, + { name = "urllib3-future" }, + { name = "wassima" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f9/b472d4aae737686c88154bedf6a8939cf8f191e30df0ad904ca6b614b437/niquests-3.15.2.tar.gz", hash = "sha256:8076b1d2ff957022d52b2216ca7df92d92ce426d19a7ed63c7fd4fd630ab6c2b", size = 975234, upload-time = "2025-08-16T14:06:03.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/71/c82f55feb3197b3c2e0699f3c961d20806a3199b0b15190d4ced13e2ecc1/niquests-3.15.2-py3-none-any.whl", hash = "sha256:2446e3602ba1418434822f5c1fcf8b8d1b52a3c296d2808a1ab7de4cf1312d99", size = 167060, upload-time = "2025-08-16T14:06:01.758Z" }, +] + +[[package]] +name = "openapi-generator-cli" +version = "7.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/75/477c36fa8a6d1279c48c4be16155ca9985c6dbc8dd75d3bc1466d81469e9/openapi_generator_cli-7.16.0.tar.gz", hash = "sha256:a056ea34c12b989363c94025a5290ec24b714d80bac2e275bb9366c6bfda130b", size = 27710447, upload-time = "2025-09-29T01:27:50.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/ee/c274afa6b0817d6a80704f6ecc5cb98a655e64f5992c14d0813f1288a761/openapi_generator_cli-7.16.0-py3-none-any.whl", hash = "sha256:b4ac990a9c6d3cd7e38a1b914c089449943183fca6fff169cbd219d06123fd7d", size = 27724625, upload-time = "2025-09-29T01:27:47.333Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/8f/9feb01291d0d7a0a4c6a6bab24094135c2b59c6a81943752f632c75896d6/psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff", size = 3043397, upload-time = "2024-10-16T11:19:40.033Z" }, + { url = "https://files.pythonhosted.org/packages/15/30/346e4683532011561cd9c8dfeac6a8153dd96452fee0b12666058ab7893c/psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c", size = 3274806, upload-time = "2024-10-16T11:19:43.5Z" }, + { url = "https://files.pythonhosted.org/packages/66/6e/4efebe76f76aee7ec99166b6c023ff8abdc4e183f7b70913d7c047701b79/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c", size = 2851370, upload-time = "2024-10-16T11:19:46.986Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fd/ff83313f86b50f7ca089b161b8e0a22bb3c319974096093cd50680433fdb/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb", size = 3080780, upload-time = "2024-10-16T11:19:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c4/bfadd202dcda8333a7ccafdc51c541dbdfce7c2c7cda89fa2374455d795f/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341", size = 3264583, upload-time = "2024-10-16T11:19:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/09f45ac25e704ac954862581f9f9ae21303cc5ded3d0b775532b407f0e90/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a", size = 3019831, upload-time = "2024-10-16T11:19:57.762Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2e/9beaea078095cc558f215e38f647c7114987d9febfc25cb2beed7c3582a5/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b", size = 2871822, upload-time = "2024-10-16T11:20:04.693Z" }, + { url = "https://files.pythonhosted.org/packages/01/9e/ef93c5d93f3dc9fc92786ffab39e323b9aed066ba59fdc34cf85e2722271/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7", size = 2820975, upload-time = "2024-10-16T11:20:11.401Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f0/049e9631e3268fe4c5a387f6fc27e267ebe199acf1bc1bc9cbde4bd6916c/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e", size = 2919320, upload-time = "2024-10-16T11:20:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9a/bcb8773b88e45fb5a5ea8339e2104d82c863a3b8558fbb2aadfe66df86b3/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68", size = 2957617, upload-time = "2024-10-16T11:20:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6b/144336a9bf08a67d217b3af3246abb1d027095dab726f0687f01f43e8c03/psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392", size = 1024618, upload-time = "2024-10-16T11:20:27.718Z" }, + { url = "https://files.pythonhosted.org/packages/61/69/3b3d7bd583c6d3cbe5100802efa5beacaacc86e37b653fc708bf3d6853b8/psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4", size = 1163816, upload-time = "2024-10-16T11:20:30.777Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" }, + { url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" }, + { url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" }, + { url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" }, + { url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" }, + { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" }, + { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, + { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.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/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/71db63483d518cbbf290261a1fc2839d17ff89fce7089e08cad07ccfce67/pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7", size = 2028584, upload-time = "2025-04-23T18:31:03.106Z" }, + { url = "https://files.pythonhosted.org/packages/24/2f/3cfa7244ae292dd850989f328722d2aef313f74ffc471184dc509e1e4e5a/pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246", size = 1855071, upload-time = "2025-04-23T18:31:04.621Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/4ae42d33f5e3f50dd467761304be2fa0a9417fbf09735bc2cce003480f2a/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f", size = 1897823, upload-time = "2025-04-23T18:31:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/aa5976e8352b7695ff808599794b1fba2a9ae2ee954a3426855935799488/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc", size = 1983792, upload-time = "2025-04-23T18:31:07.93Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7a/cda9b5a23c552037717f2b2a5257e9b2bfe45e687386df9591eff7b46d28/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de", size = 2136338, upload-time = "2025-04-23T18:31:09.283Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/b8f9ec8dd1417eb9da784e91e1667d58a2a4a7b7b34cf4af765ef663a7e5/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a", size = 2730998, upload-time = "2025-04-23T18:31:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/47/bc/cd720e078576bdb8255d5032c5d63ee5c0bf4b7173dd955185a1d658c456/pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef", size = 2003200, upload-time = "2025-04-23T18:31:13.536Z" }, + { url = "https://files.pythonhosted.org/packages/ca/22/3602b895ee2cd29d11a2b349372446ae9727c32e78a94b3d588a40fdf187/pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e", size = 2113890, upload-time = "2025-04-23T18:31:15.011Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e6/e3c5908c03cf00d629eb38393a98fccc38ee0ce8ecce32f69fc7d7b558a7/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d", size = 2073359, upload-time = "2025-04-23T18:31:16.393Z" }, + { url = "https://files.pythonhosted.org/packages/12/e7/6a36a07c59ebefc8777d1ffdaf5ae71b06b21952582e4b07eba88a421c79/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30", size = 2245883, upload-time = "2025-04-23T18:31:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/16/3f/59b3187aaa6cc0c1e6616e8045b284de2b6a87b027cce2ffcea073adf1d2/pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf", size = 2241074, upload-time = "2025-04-23T18:31:19.205Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/55532bb88f674d5d8f67ab121a2a13c385df382de2a1677f30ad385f7438/pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51", size = 1910538, upload-time = "2025-04-23T18:31:20.541Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1b/25b7cccd4519c0b23c2dd636ad39d381abf113085ce4f7bec2b0dc755eb1/pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab", size = 1952909, upload-time = "2025-04-23T18:31:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/49/a9/d809358e49126438055884c4366a1f6227f0f84f635a9014e2deb9b9de54/pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65", size = 1897786, upload-time = "2025-04-23T18:31:24.161Z" }, + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/7b/27/d4ae6487d73948d6f20dddcd94be4ea43e74349b56eba82e9bdee2d7494c/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8", size = 2025200, upload-time = "2025-04-23T18:33:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b8/b3cb95375f05d33801024079b9392a5ab45267a63400bf1866e7ce0f0de4/pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593", size = 1859123, upload-time = "2025-04-23T18:33:16.555Z" }, + { url = "https://files.pythonhosted.org/packages/05/bc/0d0b5adeda59a261cd30a1235a445bf55c7e46ae44aea28f7bd6ed46e091/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612", size = 1892852, upload-time = "2025-04-23T18:33:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/d37bdebbda2e449cb3f519f6ce950927b56d62f0b84fd9cb9e372a26a3d5/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7", size = 2067484, upload-time = "2025-04-23T18:33:20.475Z" }, + { url = "https://files.pythonhosted.org/packages/8c/55/1f95f0a05ce72ecb02a8a8a1c3be0579bbc29b1d5ab68f1378b7bebc5057/pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e", size = 2108896, upload-time = "2025-04-23T18:33:22.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/89/2b2de6c81fa131f423246a9109d7b2a375e83968ad0800d6e57d0574629b/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8", size = 2069475, upload-time = "2025-04-23T18:33:24.528Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013, upload-time = "2025-04-23T18:33:26.621Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715, upload-time = "2025-04-23T18:33:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757, upload-time = "2025-04-23T18:33:30.645Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234, upload-time = "2025-04-18T16:44:48.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356, upload-time = "2025-04-18T16:44:46.617Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/68/ce067f09fca4abeca8771fe667d89cc347d1e99da3e093112ac329c6020e/pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c", size = 78825, upload-time = "2024-08-01T15:01:08.445Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/84/0fdf9b18ba31d69877bd39c9cd6052b47f3761e9910c15de788e519f079f/PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", size = 22344, upload-time = "2024-08-01T15:01:06.481Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/aa/405082ce2749be5398045152251ac69c0f3578c7077efc53431303af97ce/pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6", size = 1515232, upload-time = "2025-06-02T17:36:30.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/de/afa024cbe022b1b318a3d224125aa24939e99b4ff6f22e0ba639a2eaee47/pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e", size = 363797, upload-time = "2025-06-02T17:36:27.859Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960, upload-time = "2025-05-26T04:54:40.484Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/05/ce271016e351fddc8399e546f6e23761967ee09c8c568bbfbecb0c150171/pytest_asyncio-1.0.0-py3-none-any.whl", hash = "sha256:4f024da9f1ef945e680dc68610b52550e36590a67fd31bb3b4943979a1f90ef3", size = 15976, upload-time = "2025-05-26T04:54:39.035Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/28/67172c96ba684058a4d24ffe144d64783d2a270d0af0d9e792737bddc75c/pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e", size = 33241, upload-time = "2025-05-26T13:58:45.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/05/77b60e520511c53d1c1ca75f1930c7dd8e971d0c4379b7f4b3f9644685ba/pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0", size = 9923, upload-time = "2025-05-26T13:58:43.487Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920, upload-time = "2025-03-25T10:14:56.835Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256, upload-time = "2025-03-25T10:14:55.034Z" }, +] + +[[package]] +name = "python-jose" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "qh3" +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/00/744587dd7cdb49697e3a92e80dcba08a5a8fceb7bfc7b7c708267749c795/qh3-1.5.5.tar.gz", hash = "sha256:fd9dd4bc435914aaaad63d5212f35d4e3f887de2814b6f9f93b94984deb52179", size = 264998, upload-time = "2025-10-05T05:29:44.986Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/bd/81910d2b8baf90e6aff001ece917d38f8efd749d6630bc49c6ab2a294be3/qh3-1.5.5-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:67dc1332ed84ef81a0f0bce79a464bdc43f4d695bd701dcb275287241d4d6cc5", size = 4455878, upload-time = "2025-10-05T05:26:45.707Z" }, + { url = "https://files.pythonhosted.org/packages/97/31/14b78defcd2fe8a728a5d9e0dcf9923e82c62bd5f6c901a47344e097ec4e/qh3-1.5.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3649b42a9e045e6a76e24b9cbeb2c4fff8cb7aca5d899bb9da40f04eec1e5179", size = 2170884, upload-time = "2025-10-05T05:26:47.952Z" }, + { url = "https://files.pythonhosted.org/packages/84/b7/a3f0cef343bceee3854788f9a93803485e20507d2d2c75d0cdbbea172dc4/qh3-1.5.5-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:acb18c87ca0b022be3ee40ade88a772233c119032c688805ddbb67996748203d", size = 1898633, upload-time = "2025-10-05T05:26:49.68Z" }, + { url = "https://files.pythonhosted.org/packages/95/07/2ce106184d1cce8986882095f034babeebe7ad139ee867d4e8eefd4fc082/qh3-1.5.5-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c065497e0c66574009db9849dc6f42c1851adc34c459db0b777be6d341a33b6", size = 2042222, upload-time = "2025-10-05T05:26:51.462Z" }, + { url = "https://files.pythonhosted.org/packages/17/9f/4621afa9a3fc3bcf79494c0dfdfd4de68c0197e6f2a8a1390ab553bc2284/qh3-1.5.5-cp313-cp313t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e7eac59f6fc79db530d9032c91b009de63a11237ba2d7b71d846d260722016d0", size = 2027721, upload-time = "2025-10-05T05:26:53.057Z" }, + { url = "https://files.pythonhosted.org/packages/40/09/9ce9b9551567df1d61ac06daaed4d92713f62256d067562545fdf4ecf814/qh3-1.5.5-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:566976894044cdde5bb67b73003673c71980429e8a7218d39b048a899b296dc3", size = 2078220, upload-time = "2025-10-05T05:26:54.881Z" }, + { url = "https://files.pythonhosted.org/packages/82/c3/2727bb49fc97575d1b46e63b2ce2c1745d9da94efdd3ce3b2e7b6897e6bb/qh3-1.5.5-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c47d04ac94aeff1813d9e579786737bec214526f1592553719e9952b3aee626f", size = 2137197, upload-time = "2025-10-05T05:26:56.283Z" }, + { url = "https://files.pythonhosted.org/packages/9f/39/ba61eb17e22a34693f3d4382891e5c0f6efa2851c3cb8440733a6e777e69/qh3-1.5.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4757bccfa60b17896b0641d4c9a9f91307f699d4bf06e50fb661cec8da49421c", size = 2368023, upload-time = "2025-10-05T05:26:58.055Z" }, + { url = "https://files.pythonhosted.org/packages/39/d3/b886835f1c6a0644118c55ca8f5f4b33e73e95ffe4c2f2fb5148e0c62fe5/qh3-1.5.5-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:0612f911dc6e1f8bdb198b41f71ce8f581cdfa08e801b8a37095866fbca26139", size = 2034699, upload-time = "2025-10-05T05:26:59.462Z" }, + { url = "https://files.pythonhosted.org/packages/e9/bb/7a15b7577f291d8b80a7d8c7b0b119f6eee2ba8df27a29713fcff0a3fe44/qh3-1.5.5-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:8cfb34037a4cee69784504f9fb93b8b76f74bb25e1bbe8ff8d17bfc2a67b07ff", size = 2366958, upload-time = "2025-10-05T05:27:00.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e2/66962f04e3c181c700e952e67c0f4632c5280465dd20ae2380615b52ec3c/qh3-1.5.5-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:004b4ce49daa2f4c22ac89658732a10e5ecf652f221ebf5ce879d954a03c5b82", size = 2136303, upload-time = "2025-10-05T05:27:02.427Z" }, + { url = "https://files.pythonhosted.org/packages/b1/18/a724cb2e74e0475ceb5ca27fde2512faec3b64bbe8e17b7d665b515f98e3/qh3-1.5.5-cp313-cp313t-musllinux_1_1_i686.whl", hash = "sha256:bc571a41a8e8c71708a90005eea3eafa31cd9cf38bfd20eae54d0a097ed038f0", size = 2166419, upload-time = "2025-10-05T05:27:03.803Z" }, + { url = "https://files.pythonhosted.org/packages/28/45/f497eb4d9ed6bd699982a2cb8279cbf1651dc6d4c707dc9f5518a74b6203/qh3-1.5.5-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:7cf106b2da4755f19e6e289710714fc254cf926c6b481346b9ab6fa1bde62c5a", size = 2538329, upload-time = "2025-10-05T05:27:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/23/82/6444c40464179ff6dc471953ec9a980ab39e283eb8d0c3b8170581c836c9/qh3-1.5.5-cp313-cp313t-win32.whl", hash = "sha256:18a53167a439301ed080d08c46d136777ecfea7f38cce9efe7fb44799cd1d14b", size = 1740241, upload-time = "2025-10-05T05:27:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/da/2e/ac49c03278dc789629fcb46c5d33966213c51e286346401a621eda389c48/qh3-1.5.5-cp313-cp313t-win_amd64.whl", hash = "sha256:b2c2c82ca77f269c9a78fbc50affb831cfbc7f683a76092562331b9bc529cdb9", size = 1985679, upload-time = "2025-10-05T05:27:08.606Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6f/1d6832d11a8b9fb0448a3b9178a0b8fc6e16b7f62c1cb6526916223704f0/qh3-1.5.5-cp313-cp313t-win_arm64.whl", hash = "sha256:91f34b49658cd59aa92956b4fe1a81b9368cad32a64f86cb2c90f8f8774bb9f2", size = 1817235, upload-time = "2025-10-05T05:27:09.963Z" }, + { url = "https://files.pythonhosted.org/packages/59/5c/a1e3c7e6c8af4a844b7a7db6c57e7cb260bc1640880efdf90b085e72657f/qh3-1.5.5-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:426e064d29da0f729c0d2b2f678c32d95ac61baa5b2b2760b8c2a8f465d8755a", size = 4457994, upload-time = "2025-10-05T05:27:11.358Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e2/11588fe9509f8b4d05a589bb9876459eaa07ad91bc445f51a8f68b4d0bbb/qh3-1.5.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06c2f6d20163637c52ce3acdc6d26b0db563d64dd0881d7c7a2c06cbfbf20cab", size = 2171444, upload-time = "2025-10-05T05:27:13.137Z" }, + { url = "https://files.pythonhosted.org/packages/7d/65/2bf56675919bba1c12375f1b28ae4f1b9e8be1c7836b1bf71a899b0874b1/qh3-1.5.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5542c58a86110a9fa18540546055d08da631e9d6a67bad278f9d51e04b3122", size = 1897669, upload-time = "2025-10-05T05:27:14.852Z" }, + { url = "https://files.pythonhosted.org/packages/16/b9/9ea77e27b89b5eb0c4513a9227631fcd5251b92a00b48ebf9ae1b1306a02/qh3-1.5.5-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93e7c91391d3e237902f544cf47fb83c168f6b4c1c3f84c20ab580a93d2994ac", size = 2041487, upload-time = "2025-10-05T05:27:16.226Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/124da6b9a20da5241a94a5d9f8c73fac5d2e5aa4d40451dd6d1cf8ea9d22/qh3-1.5.5-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:84f4fcc3d01f0d147439eed98a38db776d11563ef8a42095f2a7ac272be9a79a", size = 2028712, upload-time = "2025-10-05T05:27:18.05Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/74c9786662c17a8cd6a125b8b5751fc4b36231c136163320781b889cdee7/qh3-1.5.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c06ff7c40da5459ac5aed99e690a5682689f19671ae87e42b67300132801db24", size = 2078974, upload-time = "2025-10-05T05:27:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/04/88/f369da0518c3e363ad34cf2f9da2a3582aa2c5eb1553f55ea7a954f2c565/qh3-1.5.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b556dba61d31f4715e04aae9c0d89233871ec9f3a0ce1ca7368cc6cb6b92b50", size = 2137894, upload-time = "2025-10-05T05:27:21.362Z" }, + { url = "https://files.pythonhosted.org/packages/6c/46/2b5a3fd354d838d8903e1da3435be0de85ebf08770746d9df45987c4ddbd/qh3-1.5.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a77dd6e8c2fd6c00f8d2500768f45edddeebe71c0ee3063f7fdcdfcd57bdaa1", size = 2368569, upload-time = "2025-10-05T05:27:22.667Z" }, + { url = "https://files.pythonhosted.org/packages/92/86/ad286b68fb36256bded93329ffc47c8c278c27cc020fcbe711e1fff65fc0/qh3-1.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:b8cb6a493eb0197970e19aeba52de21002221e8b33daba42e5fee47b50db50d2", size = 2035673, upload-time = "2025-10-05T05:27:24.461Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9f/298ac4d07c41060142b929b5541fde84583bae001e4edfdcf691de7613bf/qh3-1.5.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:757be08c725e79f8c35a3896d7e13297be7c80105112d06981aca460b4ff8da5", size = 2367563, upload-time = "2025-10-05T05:27:26.645Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b3/31661238d6e4f242f7b7add21d7566936c731f28c9662ff69afda8eea64d/qh3-1.5.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:65184645f6d1834c5fdc2130c905a93fc962fe00d301233534c90fe95e8e3489", size = 2135573, upload-time = "2025-10-05T05:27:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/ab/7a/d8ebbe8650420d590b5475d8c8c1459864069ef6de105db3440c4033c3f2/qh3-1.5.5-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:d2221852355688b7fce5b330d2b76f987352aab5e29594b3084235a8a1621bef", size = 2166006, upload-time = "2025-10-05T05:27:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/05/b1/3181b70fdfe0d09dc16e24ca4b684019d668760fe7927ee33fa372814d39/qh3-1.5.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e85d6653b001122ace5e5ddd5ce6d689e30d933be844d06b5a3fc97adb093f1c", size = 2538876, upload-time = "2025-10-05T05:27:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/2f/22/7c8e40790bd992c81b1181d8af899abd789fb0bb3c9b773da9d53fc19826/qh3-1.5.5-cp314-cp314t-win32.whl", hash = "sha256:ac90715f666d0447a531f865808a519b953c71d29d82f31d47b50b3da18fdfba", size = 1740486, upload-time = "2025-10-05T05:27:32.676Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c5/9cf3ce969172696c1ea001bc88a73c6b462d01a820057014ef019e3112f7/qh3-1.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:bf8c2f47b4fa0261e607c4fac9c45c629b53f3b256a4871eede84be39f8deab5", size = 1985804, upload-time = "2025-10-05T05:27:34.039Z" }, + { url = "https://files.pythonhosted.org/packages/18/da/fe53a3873742802bb47354c85527952f1825b407bc141853a129aed0584f/qh3-1.5.5-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8f81d0914dd012ff4d24298e49dd0db98d26341d3ed18f6beb0cbbad9bfc8d07", size = 4466341, upload-time = "2025-10-05T05:27:35.857Z" }, + { url = "https://files.pythonhosted.org/packages/19/a7/938274eb4d2cff09b552956e5382b91a6e3c9bc57e7346f9accc643754d9/qh3-1.5.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04075474a1e41547826c73dbe4fce416ba24a936e3acdb6652a157b7a9cf3acb", size = 2174771, upload-time = "2025-10-05T05:27:37.28Z" }, + { url = "https://files.pythonhosted.org/packages/c1/50/f7f0aab6274e0166eb0d3383bbccae3d780b58dfa24ce8f392cbe834c495/qh3-1.5.5-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2f960db0737ffda735e6d90eae2401d72e83cfae6b35e46ca3812550c1609a05", size = 1900303, upload-time = "2025-10-05T05:27:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/0fd14c41806803b06e4ea1da9a5e81212b9b98ac78da7279a27e10a75b45/qh3-1.5.5-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:684bcb3529072f66f13b1e79badde27380d3eb79e90cda91475d3194201e2487", size = 2043664, upload-time = "2025-10-05T05:27:39.923Z" }, + { url = "https://files.pythonhosted.org/packages/11/6d/da3b9d7ba87e7abb15ede4db365e965b48ff6a8544dac7d5c37c819e0e64/qh3-1.5.5-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:34c979d79ee7d8a7d976b35c77f4dd87a4ccbf87fd826cb0620daa4677a88e50", size = 2033662, upload-time = "2025-10-05T05:27:41.282Z" }, + { url = "https://files.pythonhosted.org/packages/98/bc/f8d63fdb75491ae51159a1acc9720bf90e331c98a68cc8c803c5c2a21aa1/qh3-1.5.5-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:10ec96556dc5555e676071e74a2d6a49ddedbc4c2c6ca086e097d016d9c192a5", size = 2083249, upload-time = "2025-10-05T05:27:43.482Z" }, + { url = "https://files.pythonhosted.org/packages/6b/02/dba1805fb9daec17de31a074ad889db918d997c9a4f0985ae4567fadefcb/qh3-1.5.5-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23b8c715e8a97601362c898f6044da808e637eadcabf3c5bc38da76a6a614095", size = 2146545, upload-time = "2025-10-05T05:27:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/5c73df2e591d07614afa11946546de5b3ab7dfc6e812002a0976c4e84637/qh3-1.5.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5430339f13e2fc320e77c10cb34103a3f2d4260638446fa9cbdbfe0401b261ad", size = 2371444, upload-time = "2025-10-05T05:27:46.543Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bc/929d04207ef5fd2ede191ddce99f1ee15e60f9f5e64f9cebfff04fac8191/qh3-1.5.5-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:0e0597da5e167d5e59dff37b6b8874fe7822ebb2572d05e5cab9470f90d1a9a8", size = 2042056, upload-time = "2025-10-05T05:27:48.035Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/5c4aa76917c23326871ab2eaaa64df5d09d66b8993885b89a7771de9b5b7/qh3-1.5.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:1187a2b862c05eb3fe251f5af9bf9b8d079179f70f411875f25292ade39390a8", size = 2371292, upload-time = "2025-10-05T05:27:49.688Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a4/117d1dcd84dfd0ecbfaad0be153e611d841c96a7f8d56fa574add5e079ab/qh3-1.5.5-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:4f6b48c8b481104c355b4d840723ce10f2cdf2000d57523223489e598c75d247", size = 2137998, upload-time = "2025-10-05T05:27:51.513Z" }, + { url = "https://files.pythonhosted.org/packages/67/51/de5cff8e72b05f7d12ce870c144c37f6447aee99e84c0dbc68356b04ca40/qh3-1.5.5-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:9bd537ec4189fff640f68a82b377351b26fd46183215881b26a326d89859a54d", size = 2168915, upload-time = "2025-10-05T05:27:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/5f/f7/2b40692de40a5f186f817bb2de82b2e8f639f6c55079df115ecc57a53785/qh3-1.5.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:eff41202b40b7b525d24fb956ba6be4f8ad6a56e4c4dca7ca5235c26957152b0", size = 2541403, upload-time = "2025-10-05T05:27:54.733Z" }, + { url = "https://files.pythonhosted.org/packages/4a/81/fd2a71e7d41db1bad6a4d5c2c4fc6223c120c6ff4a8d1a9bc3c018d5d948/qh3-1.5.5-cp37-abi3-win32.whl", hash = "sha256:3ee29d8e987cbdd89f7c8f4975161c571d9b0807aebad1b131e1efbbaef0018f", size = 1739680, upload-time = "2025-10-05T05:27:56.243Z" }, + { url = "https://files.pythonhosted.org/packages/69/53/f7367cf3d6a71f505c561f3473dd27acf3a907a00da6c15f2fa2278fd131/qh3-1.5.5-cp37-abi3-win_amd64.whl", hash = "sha256:71e6891e194707aad4ab8e530b961d08ddba297bd452162abd43f62dede3fdc3", size = 1985516, upload-time = "2025-10-05T05:27:57.662Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7f/b2500412c0bbc62c4b2f3b3967fc7559d1f3cfa630c0090f5dc453fd8fbb/qh3-1.5.5-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bec53fb2d82df451b70e88d47ee8010a069ff4553a11b99951797db4b3bb37ea", size = 4464769, upload-time = "2025-10-05T05:28:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/92d36a6b5ec3eead9513d77eef018891c4375b76193c62003d8f146c8a3a/qh3-1.5.5-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3104f1c2ca255e1c2ea5b8ed590380d4ef18e312bcd367b2e86697635d0dc855", size = 2171740, upload-time = "2025-10-05T05:28:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9f/b19279b057d125a75304f615bb0e76d69df51b93535e739828f2431b8a40/qh3-1.5.5-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:620cbc25ad4601a33b6be7b3d00b94a5543b844b3879be27c1fde19aea44bbba", size = 1900454, upload-time = "2025-10-05T05:28:23.693Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ab/7c8d857a2a9b44a08f483be857b62908f500428b444efa7df461a8476ad1/qh3-1.5.5-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:874ad1ff2b32eac52f5abfbc8d28bd51794f52f90271404bb4255f30ccf4354e", size = 2046225, upload-time = "2025-10-05T05:28:25.094Z" }, + { url = "https://files.pythonhosted.org/packages/32/fd/82c0ae0f538374e6b1abe54b03ccfa82d939041f787dc256cd9b346cfeb3/qh3-1.5.5-pp311-pypy311_pp73-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:8563db39e0d0bb644753ea5d94fcd3ec231a81ec5a5e86767c0e473991bd07c4", size = 2032493, upload-time = "2025-10-05T05:28:26.927Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/f9e24b7cf5ef49bfc249c25bbc85cb04db7f8b90a53f44525c0df076d649/qh3-1.5.5-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73adc47fe93ce85eea731d5a64c004b3f25775b5c7bd58c485d9f3221c58847f", size = 2082000, upload-time = "2025-10-05T05:28:28.374Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/32abb5b1db7a12c2c7a90477b343d7e7a767b7fce209a0efad18c52f1edf/qh3-1.5.5-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35d7f5f3ec553a75f392bdb5572bd0d3546d0477258cbfb1bc8fc3105a94493e", size = 2138528, upload-time = "2025-10-05T05:28:30.211Z" }, + { url = "https://files.pythonhosted.org/packages/eb/49/918e7ae35e92d8fb94a9cd1b9789431c6f861086bcb552bf4e3196595d28/qh3-1.5.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bafb6811b4b202d595253ded655c1b52ac22e6cf6743b6ef045a40396f4c0191", size = 2369788, upload-time = "2025-10-05T05:28:31.978Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ec/238c89838603fc22fa7ab7ec2e004ef2ee906d7097e5b549add0b9fb8331/qh3-1.5.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:70e916053c3a569dfde9d1e9604dffb0ebb506b8f18e94421625b3fa9bc064d1", size = 2368120, upload-time = "2025-10-05T05:28:33.812Z" }, + { url = "https://files.pythonhosted.org/packages/2b/3a/5a6455b24722fbf51bc7fb999373fa201cc9ea665f0c1c5f27469da06c29/qh3-1.5.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:9cc3364d1b8b3b31368ab7ed748bad80535c089b633903cd18a929a773094886", size = 2137809, upload-time = "2025-10-05T05:28:35.217Z" }, + { url = "https://files.pythonhosted.org/packages/01/7b/5346225692bd4b67c22aa7580a1e331a00e8583c3ccd82a69d9e9a43adfc/qh3-1.5.5-pp311-pypy311_pp73-musllinux_1_1_i686.whl", hash = "sha256:fe56052f177d13597a61e8124a9205c2f976b861d40d3ccb520e51545b4b7191", size = 2168999, upload-time = "2025-10-05T05:28:36.594Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3e/75f29607645a7454c23385ccd468f68cbc1f30f6d59025284ca93c533272/qh3-1.5.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d16815d8936525f239b01470eb3bd9e2a8d7ce9a702dc9018a979d060476f591", size = 2540166, upload-time = "2025-10-05T05:28:37.94Z" }, + { url = "https://files.pythonhosted.org/packages/29/89/c83d50836556af028366f3e10a1e669f56f04e18649088059fab4aa127b2/qh3-1.5.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e5ed1449db926eaa2433290b86458f6564b648dc968db78827de218020fdec41", size = 1984122, upload-time = "2025-10-05T05:28:39.594Z" }, +] + +[[package]] +name = "redis" +version = "5.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, + { name = "pyjwt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/dd/2b37032f4119dff2a2f9bbcaade03221b100ba26051bb96e275de3e5db7a/redis-5.3.0.tar.gz", hash = "sha256:8d69d2dde11a12dc85d0dbf5c45577a5af048e2456f7077d87ad35c1c81c310e", size = 4626288, upload-time = "2025-04-30T14:54:40.634Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/b0/aa601efe12180ba492b02e270554877e68467e66bda5d73e51eaa8ecc78a/redis-5.3.0-py3-none-any.whl", hash = "sha256:f1deeca1ea2ef25c1e4e46b07f4ea1275140526b1feea4c6459c0ec27a10ef83", size = 272836, upload-time = "2025-04-30T14:54:30.744Z" }, +] + +[package.optional-dependencies] +hiredis = [ + { name = "hiredis" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "ruff" +version = "0.11.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/4e/b00e3ffae32b74b5180e15d2ab4040531ee1bef4c19755fe7926622dc958/sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f", size = 2121232, upload-time = "2025-05-14T17:48:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/6547ebb10875302074a37e1970a5dce7985240665778cfdee2323709f749/sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560", size = 2110897, upload-time = "2025-05-14T17:48:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/9e/21/59df2b41b0f6c62da55cd64798232d7349a9378befa7f1bb18cf1dfd510a/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f", size = 3273313, upload-time = "2025-05-14T17:51:56.205Z" }, + { url = "https://files.pythonhosted.org/packages/62/e4/b9a7a0e5c6f79d49bcd6efb6e90d7536dc604dab64582a9dec220dab54b6/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6", size = 3273807, upload-time = "2025-05-14T17:55:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/d8/79f2427251b44ddee18676c04eab038d043cff0e764d2d8bb08261d6135d/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04", size = 3209632, upload-time = "2025-05-14T17:51:59.384Z" }, + { url = "https://files.pythonhosted.org/packages/d4/16/730a82dda30765f63e0454918c982fb7193f6b398b31d63c7c3bd3652ae5/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582", size = 3233642, upload-time = "2025-05-14T17:55:29.901Z" }, + { url = "https://files.pythonhosted.org/packages/04/61/c0d4607f7799efa8b8ea3c49b4621e861c8f5c41fd4b5b636c534fcb7d73/sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8", size = 2086475, upload-time = "2025-05-14T17:56:02.095Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8e/8344f8ae1cb6a479d0741c02cd4f666925b2bf02e2468ddaf5ce44111f30/sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504", size = 2110903, upload-time = "2025-05-14T17:56:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645, upload-time = "2025-05-14T17:55:24.854Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399, upload-time = "2025-05-14T17:55:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269, upload-time = "2025-05-14T17:50:38.227Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364, upload-time = "2025-05-14T17:51:49.829Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072, upload-time = "2025-05-14T17:50:39.774Z" }, + { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074, upload-time = "2025-05-14T17:51:51.736Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514, upload-time = "2025-05-14T17:55:49.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557, upload-time = "2025-05-14T17:55:51.349Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491, upload-time = "2025-05-14T17:55:31.177Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827, upload-time = "2025-05-14T17:55:34.921Z" }, + { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224, upload-time = "2025-05-14T17:50:41.418Z" }, + { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045, upload-time = "2025-05-14T17:51:54.722Z" }, + { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357, upload-time = "2025-05-14T17:50:43.483Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511, upload-time = "2025-05-14T17:51:57.308Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420, upload-time = "2025-05-14T17:55:52.69Z" }, + { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329, upload-time = "2025-05-14T17:55:54.495Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" }, +] + +[[package]] +name = "sqlalchemy-utils" +version = "0.41.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/bf/abfd5474cdd89ddd36dbbde9c6efba16bfa7f5448913eba946fed14729da/SQLAlchemy-Utils-0.41.2.tar.gz", hash = "sha256:bc599c8c3b3319e53ce6c5c3c471120bd325d0071fb6f38a10e924e3d07b9990", size = 138017, upload-time = "2024-03-24T15:17:28.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/f0/dc4757b83ac1ab853cf222df8535ed73973e0c203d983982ba7b8bc60508/SQLAlchemy_Utils-0.41.2-py3-none-any.whl", hash = "sha256:85cf3842da2bf060760f955f8467b87983fb2e30f1764fd0e24a48307dc8ec6e", size = 93083, upload-time = "2024-03-24T15:17:24.533Z" }, +] + +[[package]] +name = "starlette" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/cb/244daf0d7be4508099ad5bca3cdfe8b8b5538acd719c5f397f614e569fff/starlette-0.40.0.tar.gz", hash = "sha256:1a3139688fb298ce5e2d661d37046a66ad996ce94be4d4983be019a23a04ea35", size = 2573611, upload-time = "2024-10-15T06:52:34.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/0f/64baf7a06492e8c12f5c4b49db286787a7255195df496fc21f5fd9eecffa/starlette-0.40.0-py3-none-any.whl", hash = "sha256:c494a22fae73805376ea6bf88439783ecfba9aac88a43911b48c653437e784c4", size = 73303, upload-time = "2024-10-15T06:52:32.486Z" }, +] + +[[package]] +name = "types-cffi" +version = "1.17.0.20250523" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/5f/ac80a2f55757019e5d4809d17544569c47a623565258ca1a836ba951d53f/types_cffi-1.17.0.20250523.tar.gz", hash = "sha256:e7110f314c65590533adae1b30763be08ca71ad856a1ae3fe9b9d8664d49ec22", size = 16858, upload-time = "2025-05-23T03:05:40.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/86/e26e6ae4dfcbf6031b8422c22cf3a9eb2b6d127770406e7645b6248d8091/types_cffi-1.17.0.20250523-py3-none-any.whl", hash = "sha256:e98c549d8e191f6220e440f9f14315d6775a21a0e588c32c20476be885b2fad9", size = 20010, upload-time = "2025-05-23T03:05:39.136Z" }, +] + +[[package]] +name = "types-pyopenssl" +version = "24.1.0.20240722" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "types-cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/29/47a346550fd2020dac9a7a6d033ea03fccb92fa47c726056618cc889745e/types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39", size = 8458, upload-time = "2024-07-22T02:32:22.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/05/c868a850b6fbb79c26f5f299b768ee0adc1f9816d3461dcf4287916f655b/types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54", size = 7499, upload-time = "2024-07-22T02:32:21.232Z" }, +] + +[[package]] +name = "types-redis" +version = "4.6.0.20241004" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "types-pyopenssl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/95/c054d3ac940e8bac4ca216470c80c26688a0e79e09f520a942bb27da3386/types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", size = 49679, upload-time = "2024-10-04T02:43:59.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/82/7d25dce10aad92d2226b269bce2f85cfd843b4477cd50245d7d40ecf8f89/types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed", size = 58737, upload-time = "2024-10-04T02:43:57.968Z" }, +] + +[[package]] +name = "types-setuptools" +version = "80.9.0.20250529" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/66/1b276526aad4696a9519919e637801f2c103419d2c248a6feb2729e034d1/types_setuptools-80.9.0.20250529.tar.gz", hash = "sha256:79e088ba0cba2186c8d6499cbd3e143abb142d28a44b042c28d3148b1e353c91", size = 41337, upload-time = "2025-05-29T03:07:34.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d8/83790d67ec771bf029a45ff1bd1aedbb738d8aa58c09dd0cc3033eea0e69/types_setuptools-80.9.0.20250529-py3-none-any.whl", hash = "sha256:00dfcedd73e333a430e10db096e4d46af93faf9314f832f13b6bbe3d6757e95f", size = 63263, upload-time = "2025-05-29T03:07:33.064Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "ua-parser" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ua-parser-builtins" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/0e/ed98be735bc89d5040e0c60f5620d0b8c04e9e7da99ed1459e8050e90a77/ua_parser-1.0.1.tar.gz", hash = "sha256:f9d92bf19d4329019cef91707aecc23c6d65143ad7e29a233f0580fb0d15547d", size = 728106, upload-time = "2025-02-01T14:13:32.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/37/be6dfbfa45719aa82c008fb4772cfe5c46db765a2ca4b6f524a1fdfee4d7/ua_parser-1.0.1-py3-none-any.whl", hash = "sha256:b059f2cb0935addea7e551251cbbf42e9a8872f86134163bc1a4f79e0945ffea", size = 31410, upload-time = "2025-02-01T14:13:28.458Z" }, +] + +[[package]] +name = "ua-parser-builtins" +version = "0.18.0.post1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/d3/13adff37f15489c784cc7669c35a6c3bf94b87540229eedf52ef2a1d0175/ua_parser_builtins-0.18.0.post1-py3-none-any.whl", hash = "sha256:eb4f93504040c3a990a6b0742a2afd540d87d7f9f05fd66e94c101db1564674d", size = 86077, upload-time = "2024-12-05T18:44:36.732Z" }, +] + +[[package]] +name = "urllib3-future" +version = "2.14.905" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "jh2" }, + { name = "qh3", marker = "(python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_machine == 'AMD64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ARM64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'arm64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'armv7l' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'i686' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ppc64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'ppc64le' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'riscv64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'riscv64gc' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 's390x' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'x86' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (python_full_version < '3.12' and platform_machine == 'x86_64' and platform_python_implementation == 'PyPy' and sys_platform == 'win32') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/58/176e33013d79ffd785eeb5c08c58c0debb1301b38e329f55eb4d4d00bf57/urllib3_future-2.14.905.tar.gz", hash = "sha256:3693ad0fcaa97001dfee760ed45c44bf8234b178189ebcb6892a9f9a29b29834", size = 1109820, upload-time = "2025-10-16T03:30:14.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/78/f1063a6e5296f346ebc244c63729be0f52e26c4224d3ee30ac3d7547749e/urllib3_future-2.14.905-py3-none-any.whl", hash = "sha256:c9ad3a3860f2f2548e4ef297a152e5b66a9e19774c0557bfa55ae5d270868025", size = 681401, upload-time = "2025-10-16T03:30:12.094Z" }, +] + +[[package]] +name = "user-agents" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ua-parser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/e1/63c5bfb485a945010c8cbc7a52f85573561737648d36b30394248730a7bc/user-agents-2.2.0.tar.gz", hash = "sha256:d36d25178db65308d1458c5fa4ab39c9b2619377010130329f3955e7626ead26", size = 9525, upload-time = "2020-08-23T06:01:56.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/1c/20bb3d7b2bad56d881e3704131ddedbb16eb787101306887dff349064662/user_agents-2.2.0-py3-none-any.whl", hash = "sha256:a98c4dc72ecbc64812c4534108806fb0a0b3a11ec3fd1eafe807cee5b0a942e7", size = 9614, upload-time = "2020-08-23T06:01:54.047Z" }, +] + +[[package]] +name = "uuid" +version = "1.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/63/f42f5aa951ebf2c8dac81f77a8edcc1c218640a2a35a03b9ff2d4aa64c3d/uuid-1.30.tar.gz", hash = "sha256:1f87cc004ac5120466f36c5beae48b4c48cc411968eed0eaecd3da82aa96193f", size = 5811, upload-time = "2007-05-26T11:13:24Z" } + +[[package]] +name = "uuid6" +version = "2025.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/b7/4c0f736ca824b3a25b15e8213d1bcfc15f8ac2ae48d1b445b310892dc4da/uuid6-2025.0.1.tar.gz", hash = "sha256:cd0af94fa428675a44e32c5319ec5a3485225ba2179eefcf4c3f205ae30a81bd", size = 13932, upload-time = "2025-07-04T18:30:35.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b2/93faaab7962e2aa8d6e174afb6f76be2ca0ce89fde14d3af835acebcaa59/uuid6-2025.0.1-py3-none-any.whl", hash = "sha256:80530ce4d02a93cdf82e7122ca0da3ebbbc269790ec1cb902481fa3e9cc9ff99", size = 6979, upload-time = "2025-07-04T18:30:34.001Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/a7/4cf0334105c1160dd6819f3297f8700fda7fc30ab4f61fbf3e725acbc7cc/uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8", size = 1447410, upload-time = "2024-10-14T23:37:33.612Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7c/1517b0bbc2dbe784b563d6ab54f2ef88c890fdad77232c98ed490aa07132/uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0", size = 805476, upload-time = "2024-10-14T23:37:36.11Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ea/0bfae1aceb82a503f358d8d2fa126ca9dbdb2ba9c7866974faec1cb5875c/uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e", size = 3960855, upload-time = "2024-10-14T23:37:37.683Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ca/0864176a649838b838f36d44bf31c451597ab363b60dc9e09c9630619d41/uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb", size = 3973185, upload-time = "2024-10-14T23:37:40.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/bf/08ad29979a936d63787ba47a540de2132169f140d54aa25bc8c3df3e67f4/uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6", size = 3820256, upload-time = "2024-10-14T23:37:42.839Z" }, + { url = "https://files.pythonhosted.org/packages/da/e2/5cf6ef37e3daf2f06e651aae5ea108ad30df3cb269102678b61ebf1fdf42/uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d", size = 3937323, upload-time = "2024-10-14T23:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, +] + +[[package]] +name = "wassima" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/1c/47fa7d26f64b952aaa90e9b0256e6db86a81f33241c1742558766d547c80/wassima-2.0.2.tar.gz", hash = "sha256:45de4ddf2a99e9277cc33616b3b34eee7dfcaaf5059b6e8c19ca62a6c5a65fbf", size = 150476, upload-time = "2025-10-05T05:27:26.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/db/e73493de23e9ac182997e14f3f0ff694a42f5696268668691665e7d1405c/wassima-2.0.2-py3-none-any.whl", hash = "sha256:54396882c0a210e404bb11740d47d66a35a6d68dde073a381c57d6e112cc245a", size = 145807, upload-time = "2025-10-05T05:27:25.041Z" }, +]