Compare commits

..
10 Commits
Author SHA1 Message Date
venus 9a4a2ac956 working local deployment
Linters / Run linters (push) Canceled after 0s
2026-09-15 20:11:19 -05:00
CopilotGitHubZeeckacopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
0193ca4a7d Fix shell injection in jpseek analyzer via password field (#195)
* Initial plan

* Fix shell injection vulnerability in jpseek analyzer (CVE-style: command injection via password field)

- Replace bash -c with direct expect invocation
- Pass password via JPSEEK_PASS env variable to prevent injection
- Quote file paths with Tcl braces for safety
- Bump version to 3.2.1
- Add CHANGELOG.md

Agent-Logs-Url: https://github.com/Zeecka/AperiSolve/sessions/8c64fc67-869e-442e-9be9-ad2fef4ae738

Co-authored-by: Zeecka <24873615+Zeecka@users.noreply.github.com>

* Remove CHANGELOG.md

Agent-Logs-Url: https://github.com/Zeecka/AperiSolve/sessions/a5f3aa6c-72f5-48de-9cb6-65845bef19a1

Co-authored-by: Zeecka <24873615+Zeecka@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Zeecka <24873615+Zeecka@users.noreply.github.com>
2026-03-30 17:27:39 +02:00
CopilotGitHubZeeckacopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
a3d37681ec Fix version display: read version from package metadata instead of hardcoding "development" (#189)
* Initial plan

* Fix version display: use importlib.metadata to show package version

Co-authored-by: Zeecka <24873615+Zeecka@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Zeecka <24873615+Zeecka@users.noreply.github.com>
2026-03-01 18:06:57 +01:00
Aradhya C PandGitHub 1087d724f8 Add PROJECT_VERSION to .env during deployment (#190) 2026-03-01 18:04:58 +01:00
ZeeckaandGitHub 40b0a29a51 Change badge colors in README
Updated badge colors for UV, Ruff, and types.
2026-02-21 19:36:02 +01:00
ZeeckaandGitHub 2502448985 Fix CI build 2026-02-21 12:38:27 +01:00
Zeecka 7e85ed3e14 uv lock and Dockerfile fix 2026-02-21 01:07:17 +01:00
Zeecka 00c5ce4fe4 Fix invalid-context-manager 2026-02-21 00:05:01 +01:00
ZeeckaandGitHub 730ed50f70 Feature/ruff (#186)
* Ruff and Pyright OK

* lint

* uv

* add uv to readme

* Replace pyright with TY

* Lint is ok
2026-02-21 00:00:48 +01:00
Aradhya C PandGitHub 6660cd2e5d Minor CSS work to refactor the landing page (#183)
* Enhance landing UI with updated typography, button styles, and footer layout

* Update Fira Code font weight for improved typography

* Refactor footer layout for improved structure and readability

* Improve landing page UI by adding a question class to the header and simplifying the browse button style

* Remove unused font imports to optimize CSS file size

* Fix command comment in Dockerfile from french to english

* Fix footer CSS rule by closing the declaration block

* Update text-shadow properties for title elements and clean up footer styles

* Refactor footer HTML structure to simplify copyright display

* Add icon to 'Choose an image' button for improved UI

* Remove unnecessary footer-container styles to streamline layout
2026-02-02 21:24:36 +01:00
45 changed files with 1780 additions and 922 deletions
+12 -15
View File
@@ -20,19 +20,16 @@ jobs:
with: with:
python-version: 3.12 python-version: 3.12
- name: Install Python dependencies - name: Set up uv
run: | uses: astral-sh/setup-uv@v6
pip install -r aperisolve/requirements-dev.txt
pip install -r aperisolve/requirements.txt
- name: Run linters - name: Sync Python dependencies
uses: wearerequired/lint-action@v2 run: uv sync --extra dev
with:
black: true - name: Run Ruff
black_args: "--line-length=100" run: |
flake8: true uv run ruff check .
flake8_args: "--ignore=E203,E501,W503" uv run ruff format . --check
pylint: true
pylint_args: "--disable W0511,W0718,R0801,R0903,R0914" - name: Run ty
mypy: true run: uv run ty check aperisolve
mypy_args: "--disallow-any-generics --disallow-untyped-def --follow-imports skip --ignore-missing-imports --disable-error-code unused-awaitable"
+29 -35
View File
@@ -2,6 +2,8 @@ name: Docker Image CI
on: on:
push: push:
branches:
- main
tags: tags:
- '*' - '*'
@@ -9,39 +11,40 @@ jobs:
docker-releases: docker-releases:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
RELEASE_VERSION: ${{ github.ref_name }}
steps: steps:
- name: Get release version - name: Checkout
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV uses: actions/checkout@v4
- name: Checkout - name: Set up QEMU
uses: actions/checkout@v4 uses: docker/setup-qemu-action@v3
- name: Set up QEMU - name: Set up Docker Buildx
uses: docker/setup-qemu-action@v3 uses: docker/setup-buildx-action@v3
- name: Set up Docker Buildx - name: Login to GitHub Container Registry
uses: docker/setup-buildx-action@v3 if: startsWith(github.ref, 'refs/tags/')
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Build (and push on tag)
uses: docker/login-action@v3 uses: docker/build-push-action@v6.9.0
with: with:
registry: ghcr.io context: .
username: ${{ github.actor }} push: ${{ startsWith(github.ref, 'refs/tags/') }}
password: ${{ secrets.GITHUB_TOKEN }} tags: |
ghcr.io/zeecka/aperisolve:${{ env.RELEASE_VERSION }}
# Build and push for MAIN tags (X.X.X) ghcr.io/zeecka/aperisolve:latest
- name: Build and push Docker images
uses: docker/build-push-action@v6.9.0
with:
context: ./
push: true
tags: |
ghcr.io/zeecka/aperisolve:${{ env.RELEASE_VERSION }}
ghcr.io/zeecka/aperisolve:latest
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: docker-releases needs: docker-releases
if: startsWith(github.ref, 'refs/tags/')
environment: prod environment: prod
steps: steps:
@@ -50,7 +53,6 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
# wait for 120 seconds
- name: Wait for image propagation - name: Wait for image propagation
run: | run: |
echo "Waiting 120 seconds for image to propagate in registry..." echo "Waiting 120 seconds for image to propagate in registry..."
@@ -67,41 +69,33 @@ jobs:
set -e set -e
cd /root/AperiSolve cd /root/AperiSolve
# update code
git pull git pull
cat .env.example | grep -v '^PROJECT_VERSION=' > .env cat .env.example | grep -v '^PROJECT_VERSION=' > .env
# create .env
cat <<EOF >> .env cat <<EOF >> .env
PROJECT_VERSION=${{ github.ref_name }}
WEB_APP_PORT=${{ vars.WEB_APP_PORT }} WEB_APP_PORT=${{ vars.WEB_APP_PORT }}
MAX_CONTENT_LENGTH=${{ vars.MAX_CONTENT_LENGTH }} MAX_CONTENT_LENGTH=${{ vars.MAX_CONTENT_LENGTH }}
MAX_PENDING_TIME=${{ vars.MAX_PENDING_TIME }} MAX_PENDING_TIME=${{ vars.MAX_PENDING_TIME }}
MAX_STORE_TIME=${{ vars.MAX_STORE_TIME }} MAX_STORE_TIME=${{ vars.MAX_STORE_TIME }}
CLEAR_AT_RESTART=${{ vars.CLEAR_AT_RESTART }} CLEAR_AT_RESTART=${{ vars.CLEAR_AT_RESTART }}
SKIP_IHDR_FILL=${{ vars.SKIP_IHDR_FILL }} SKIP_IHDR_FILL=${{ vars.SKIP_IHDR_FILL }}
REMOVAL_MIN_AGE_SECONDS=${{ vars.REMOVAL_MIN_AGE_SECONDS }} REMOVAL_MIN_AGE_SECONDS=${{ vars.REMOVAL_MIN_AGE_SECONDS }}
GOOGLE_ADS_TXT=${{ vars.GOOGLE_ADS_TXT }} GOOGLE_ADS_TXT=${{ vars.GOOGLE_ADS_TXT }}
CUSTOM_EXTERNAL_SCRIPT=${{ vars.CUSTOM_EXTERNAL_SCRIPT }} CUSTOM_EXTERNAL_SCRIPT=${{ vars.CUSTOM_EXTERNAL_SCRIPT }}
FLASK_DEBUG=${{ vars.FLASK_DEBUG }} FLASK_DEBUG=${{ vars.FLASK_DEBUG }}
RQ_DASHBOARD_REDIS_URL=${{ vars.RQ_DASHBOARD_REDIS_URL }} RQ_DASHBOARD_REDIS_URL=${{ vars.RQ_DASHBOARD_REDIS_URL }}
POSTGRES_DB=${{ secrets.POSTGRES_DB }} POSTGRES_DB=${{ secrets.POSTGRES_DB }}
POSTGRES_USER=${{ secrets.POSTGRES_USER }} POSTGRES_USER=${{ secrets.POSTGRES_USER }}
POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
DB_URI=${{ secrets.DATABASE_URL }} DB_URI=${{ secrets.DATABASE_URL }}
SENTRY_DSN=${{ secrets.SENTRY_DSN }} SENTRY_DSN=${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE=${{ vars.SENTRY_TRACES_SAMPLE_RATE }} SENTRY_TRACES_SAMPLE_RATE=${{ vars.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE=${{ vars.SENTRY_PROFILES_SAMPLE_RATE }} SENTRY_PROFILES_SAMPLE_RATE=${{ vars.SENTRY_PROFILES_SAMPLE_RATE }}
SENTRY_RELEASE=${{ vars.SENTRY_RELEASE }} SENTRY_RELEASE=${{ github.ref_name }}
SENTRY_ENVIRONMENT=${{ vars.SENTRY_ENVIRONMENT }} SENTRY_ENVIRONMENT=${{ vars.SENTRY_ENVIRONMENT }}
EOF EOF
# Pull images and deploy
docker compose pull docker compose pull
docker compose down -v docker compose down -v
docker compose up -d docker compose up -d
+33 -61
View File
@@ -27,6 +27,7 @@ Want to add support for a new steganography or forensics tool? We'd love that!
> Check out our detailed tutorial: [Adding a New Analyzer](docs/adding-analyzer.md) > Check out our detailed tutorial: [Adding a New Analyzer](docs/adding-analyzer.md)
**Quick checklist for new analyzers:** **Quick checklist for new analyzers:**
- [ ] Create analyzer file in `analyzers/` - [ ] Create analyzer file in `analyzers/`
- [ ] Extend `SubprocessAnalyzer` base class - [ ] Extend `SubprocessAnalyzer` base class
- [ ] Register in `workers.py` - [ ] Register in `workers.py`
@@ -37,6 +38,7 @@ Want to add support for a new steganography or forensics tool? We'd love that!
### 🐛 Bug Fixes ### 🐛 Bug Fixes
Found a bug? Please: Found a bug? Please:
- Check if it's already reported in [Issues](../../issues) - Check if it's already reported in [Issues](../../issues)
- If not, open a new issue with reproduction steps - If not, open a new issue with reproduction steps
- Feel free to submit a PR with the fix! - Feel free to submit a PR with the fix!
@@ -44,6 +46,7 @@ Found a bug? Please:
### 📚 Documentation ### 📚 Documentation
Help us improve: Help us improve:
- Fix typos or unclear explanations - Fix typos or unclear explanations
- Add examples and use cases - Add examples and use cases
- Improve installation instructions - Improve installation instructions
@@ -52,6 +55,7 @@ Help us improve:
### 🎨 UI/UX Improvements ### 🎨 UI/UX Improvements
Contributions to the web interface are welcome: Contributions to the web interface are welcome:
- Better error messages - Better error messages
- Improved result presentation - Improved result presentation
- Mobile responsiveness - Mobile responsiveness
@@ -63,14 +67,12 @@ Contributions to the web interface are welcome:
> Follow the project’s code style and run linters before submitting any code. > Follow the project’s code style and run linters before submitting any code.
This project enforces: This project enforces:
- **Black** : line length 100
- **Isort** : profile black - **Ruff** : Check + Format (line length 100)
- **Flake8** : ignoring E203, E501, W503 - **ty** : Type checking
- **Pylint** : ignoring W0511, W0718, R0801, R0903, R0914
- **Mypy** : ignoring unused-awaitable
> [!TIP] > [!TIP]
> All tool configurations (Black, Isort, Flake8, Mypy, Pylint) are centralized in pyproject.toml. > All tool configurations (Ruff, ty) are centralized in pyproject.toml.
> You can run each tool directly and it will automatically pick up the configuration. > You can run each tool directly and it will automatically pick up the configuration.
### Setup ### Setup
@@ -80,12 +82,9 @@ This project enforces:
```bash ```bash
# Create & activate a virtual environment # Create & activate a virtual environment
python3 -m venv .venv uv venv
source .venv/bin/activate # Install project + development dependencies from pyproject.toml
uv sync --extra dev
# Install dependencies
pip install -r aperisolve/requirements.txt
pip install -r aperisolve/requirements-dev.txt
``` ```
### Running Tools Manually ### Running Tools Manually
@@ -95,67 +94,39 @@ pip install -r aperisolve/requirements-dev.txt
Run this script [lint.sh](lint.sh) at the project root folder. Run this script [lint.sh](lint.sh) at the project root folder.
``` ```
🖤 Running Black... 🧹 Running Ruff lint checks...
All done! ✨ 🍰 ✨ 🎨 Running Ruff format checks...
25 files left unchanged. 🔍 Running ty type checks...
📦 Running Isort...
Skipped 2 files
🐍 Running Flake8...
🔍 Running Mypy...
Success: no issues found in 25 source files
⚡ Running Pylint...
------------------------------------
Your code has been rated at 10.00/10
✅ All checks passed! ✅ All checks passed!
``` ```
Equivalent manual commands:
```bash
ruff check .
ruff format . --check
ty check aperisolve
```
**Option 2 - Pre-Commit hook** **Option 2 - Pre-Commit hook**
Create a pre-commit hooks file [.pre-commit-config.yaml](#) at the project root folder. Create a pre-commit hooks file [.pre-commit-config.yaml](#) at the project root folder.
```bash ```bash
repos: repos:
- repo: https://github.com/psf/black - repo: https://github.com/astral-sh/ruff-pre-commit
rev: 25.12.0 rev: v0.13.0
hooks: hooks:
- id: black - id: ruff-check
args: ['--line-length', '100']
- repo: https://github.com/pycqa/isort
rev: 7.0.0
hooks:
- id: isort
args: ['--profile', 'black']
- repo: https://github.com/PyCQA/flake8
rev: 7.3.0
hooks:
- id: flake8
args: ['--extend-ignore','E203,E501,W503']
- repo: https://github.com/pylint-dev/pylint
rev: v4.0.4
hooks:
- id: pylint
args: ['--disable', 'W0511,W0718,R0801,R0903,R0914']
language: system
- repo: local - repo: local
hooks: hooks:
- id: mypy - id: ty-check
name: mypy name: ty-check
entry: mypy entry: ty check
types: [python] types: [python]
language: system language: system
pass_filenames: true pass_filenames: true
args:
[
"--disallow-any-generics",
"--disallow-untyped-def",
"--follow-imports",
"skip",
"--ignore-missing-imports",
"--disable-error-code",
"unused-awaitable",
]
``` ```
Then run the following command so each staged files will be checked while commited. Then run the following command so each staged files will be checked while commited.
@@ -173,20 +144,20 @@ pre-commit run --all-files
> [!NOTE] > [!NOTE]
> Continuous Integration (CI) runs all checks on every PR. Ensure your code passes before submitting. > Continuous Integration (CI) runs all checks on every PR. Ensure your code passes before submitting.
## Pull Request Guidelines ## Pull Request Guidelines
Use clear, descriptive titles: Use clear, descriptive titles:
- ✅ `Add stegdetect analyzer` - ✅ `Add stegdetect analyzer`
- ✅ `Fix binwalk extraction error handling` - ✅ `Fix binwalk extraction error handling`
- ✅ `Update documentation for password-protected tools` - ✅ `Update documentation for password-protected tools`
- ❌ `Update code` - ❌ `Update code`
- ❌ `Fix bug` - ❌ `Fix bug`
### Commit Messages ### Commit Messages
Write clear, concise commit messages: Write clear, concise commit messages:
```bash ```bash
# Good # Good
git commit -m "Add zsteg analyzer for PNG steganography detection" git commit -m "Add zsteg analyzer for PNG steganography detection"
@@ -204,12 +175,11 @@ git commit -m "fix"
If your contribution needs new dependencies: If your contribution needs new dependencies:
1. **Python packages**: Add to `requirements.txt` 1. **Python packages**: Add to `pyproject.toml` (`[project.dependencies]` or `[project.optional-dependencies.dev]`)
2. **System tools**: Add to `Dockerfile` 2. **System tools**: Add to `Dockerfile`
3. **Explain why** in your PR description 3. **Explain why** in your PR description
4. **Keep dependencies minimal** - avoid adding large libraries for small features 4. **Keep dependencies minimal** - avoid adding large libraries for small features
## Useful commands ## Useful commands
```bash ```bash
@@ -234,6 +204,7 @@ docker cp aperisolve-web:/app/aperisolve/results/filename.ext /path/to/backup/fi
> [!WARNING] > [!WARNING]
> If switching between dev and production compose files, remove the `results` directory or mounted volume to avoid conflicts: > If switching between dev and production compose files, remove the `results` directory or mounted volume to avoid conflicts:
>
> ```bash > ```bash
> rm -rf aperisolve/results > rm -rf aperisolve/results
> ``` > ```
@@ -250,6 +221,7 @@ RUN apt-get update && apt-get install -y \
``` ```
Or for tools requiring compilation: Or for tools requiring compilation:
```dockerfile ```dockerfile
# Builder stage example (jphide) # Builder stage example (jphide)
RUN git clone https://github.com/h3xx/jphs.git /tmp/jphs && \ RUN git clone https://github.com/h3xx/jphs.git /tmp/jphs && \
+10 -5
View File
@@ -43,6 +43,9 @@ FROM python:3.14-slim
WORKDIR /app WORKDIR /app
# Install uv binary
COPY --from=ghcr.io/astral-sh/uv:0.8.22 /uv /uvx /bin/
# Install runtime + stego tools # Install runtime + stego tools
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
expect \ expect \
@@ -69,16 +72,18 @@ RUN dpkg -i /tmp/openstego.deb || apt-get install -f -y --no-install-recommends
&& rm -rf /tmp/openstego.deb \ && rm -rf /tmp/openstego.deb \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy app # Copy Python package metadata and app
COPY aperisolve/ /app/aperisolve/ COPY pyproject.toml uv.lock ./
COPY aperisolve/ ./aperisolve/
# Install Python dependencies # Install Python dependencies from pyproject.toml
RUN pip install --no-cache-dir -r /app/aperisolve/requirements.txt RUN uv sync --frozen --no-dev
ENV PATH="/app/.venv/bin:$PATH"
# Copy jphide and jsteg binaries # Copy jphide and jsteg binaries
COPY --from=builder /usr/local/bin/jphide /usr/local/bin/jphide COPY --from=builder /usr/local/bin/jphide /usr/local/bin/jphide
COPY --from=builder /usr/local/bin/jpseek /usr/local/bin/jpseek COPY --from=builder /usr/local/bin/jpseek /usr/local/bin/jpseek
COPY --from=builder /usr/local/bin/jsteg /usr/local/bin/jsteg COPY --from=builder /usr/local/bin/jsteg /usr/local/bin/jsteg
# Commande de lancement # Command to start
CMD gunicorn -w ${WEB_WORKERS:-8} -b 0.0.0.0:5000 --access-logfile - --error-logfile - --log-level info --capture-output aperisolve.utils.wsgi:application CMD gunicorn -w ${WEB_WORKERS:-8} -b 0.0.0.0:5000 --access-logfile - --error-logfile - --log-level info --capture-output aperisolve.utils.wsgi:application
+3 -1
View File
@@ -5,7 +5,9 @@
![Lint](https://github.com/Zeecka/AperiSolve/actions/workflows/lint.yml/badge.svg) ![Lint](https://github.com/Zeecka/AperiSolve/actions/workflows/lint.yml/badge.svg)
![Docker](https://img.shields.io/badge/docker-compose-blue?logo=docker) ![Docker](https://img.shields.io/badge/docker-compose-blue?logo=docker)
![Python Version](https://img.shields.io/badge/python-3.12%2B-blue) ![Python Version](https://img.shields.io/badge/python-3.12%2B-blue)
![Black Code Style](https://img.shields.io/badge/code%20style-black-000000.svg) ![Ruff](https://img.shields.io/badge/lint-ruff-30173d)
![uv](https://img.shields.io/badge/package-uv-30173d)
![ty](https://img.shields.io/badge/types-ty-30173d)
![License](https://img.shields.io/github/license/Zeecka/AperiSolve) ![License](https://img.shields.io/github/license/Zeecka/AperiSolve)
![GitHub Issues](https://img.shields.io/github/issues/Zeecka/AperiSolve) ![GitHub Issues](https://img.shields.io/github/issues/Zeecka/AperiSolve)
![GitHub Pull Requests](https://img.shields.io/github/issues-pr/Zeecka/AperiSolve) ![GitHub Pull Requests](https://img.shields.io/github/issues-pr/Zeecka/AperiSolve)
+1 -4
View File
@@ -1,8 +1,5 @@
"""Main Aperi'Solve module""" """Main Aperi'Solve module."""
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
from . import app, config, models, workers from . import app, config, models, workers
__all__ = ["app", "config", "models", "workers"] __all__ = ["app", "config", "models", "workers"]
+1 -3
View File
@@ -1,3 +1 @@
# flake8: noqa: E203,E501,W503 """Analyzer implementations for AperiSolve."""
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
+77 -51
View File
@@ -1,19 +1,16 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,W0613,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Base Analyzer class for image analysis tools.""" """Base Analyzer class for image analysis tools."""
import asyncio
import fcntl import fcntl
import json import json
import os
import subprocess
import threading import threading
from abc import ABC from abc import ABC
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from typing import Any, Optional, overload from subprocess import CompletedProcess
from typing import Any, overload
from ..config import MAX_PENDING_TIME from aperisolve.config import MAX_PENDING_TIME
_thread_lock = threading.Lock() _thread_lock = threading.Lock()
@@ -30,8 +27,14 @@ class SubprocessAnalyzer(ABC):
make_folder: bool = True make_folder: bool = True
def __init__( def __init__(
self, name: str, input_img: Path, output_dir: Path, has_archive: bool = False self,
name: str,
input_img: Path,
output_dir: Path,
*,
has_archive: bool = False,
) -> None: ) -> None:
"""Initialize analyzer metadata and execution context."""
self.name = name self.name = name
self.input_img = input_img self.input_img = input_img
self.img = f"../{self.input_img.name}" self.img = f"../{self.input_img.name}"
@@ -39,19 +42,37 @@ class SubprocessAnalyzer(ABC):
self.has_archive = has_archive self.has_archive = has_archive
def run_command( def run_command(
self, cmd: list[str], cwd: Optional[Path] = None self,
) -> subprocess.CompletedProcess[str]: cmd: list[str],
cwd: Path | None = None,
) -> CompletedProcess[str]:
"""Run a subprocess command.""" """Run a subprocess command."""
return subprocess.run(
cmd,
cwd=cwd,
capture_output=True,
text=True,
check=False,
timeout=MAX_PENDING_TIME,
)
def generate_archive(self, output_dir: Path, extracted_dir: Optional[Path] = None) -> str: async def _run() -> CompletedProcess[str]:
process = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(cwd) if cwd is not None else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout_raw, stderr_raw = await asyncio.wait_for(
process.communicate(),
timeout=MAX_PENDING_TIME,
)
returncode = process.returncode
if returncode is None:
msg = "Subprocess exited without a return code"
raise RuntimeError(msg)
return CompletedProcess(
args=cmd,
returncode=returncode,
stdout=stdout_raw.decode("utf-8", errors="replace"),
stderr=stderr_raw.decode("utf-8", errors="replace"),
)
return asyncio.run(_run())
def generate_archive(self, _output_dir: Path, extracted_dir: Path | None = None) -> str:
"""Zip the extracted files and remove the directory.""" """Zip the extracted files and remove the directory."""
if extracted_dir is None: if extracted_dir is None:
extracted_dir = self.get_extracted_dir() extracted_dir = self.get_extracted_dir()
@@ -68,30 +89,29 @@ class SubprocessAnalyzer(ABC):
json_file.parent.mkdir(parents=True, exist_ok=True) json_file.parent.mkdir(parents=True, exist_ok=True)
with _thread_lock: # synchronizes across threads with _thread_lock, lock_file.open("w", encoding="utf-8") as lock:
with open(lock_file, "w", encoding="utf-8") as lock: fcntl.flock(lock, fcntl.LOCK_EX) # synchronizes across processes
fcntl.flock(lock, fcntl.LOCK_EX) # synchronizes across processes
try: try:
# Read existing JSON # Read existing JSON
data: dict[Any, Any] = {} data: dict[Any, Any] = {}
if json_file.exists(): if json_file.exists():
try: try:
with open(json_file, "r", encoding="utf-8") as f: with json_file.open(encoding="utf-8") as f:
data = json.load(f) data = json.load(f)
except json.JSONDecodeError: except json.JSONDecodeError:
data = {} data = {}
# Update with new data # Update with new data
data.update(new_data) data.update(new_data)
# Write safely to a temp file # Write safely to a temp file
with open(tmp_file, "w", encoding="utf-8") as f: with tmp_file.open("w", encoding="utf-8") as f:
json.dump(data, f, sort_keys=False) json.dump(data, f, sort_keys=False)
os.replace(tmp_file, json_file) # ensures file write is atomic tmp_file.replace(json_file) # ensures file write is atomic
finally: finally:
fcntl.flock(lock, fcntl.LOCK_UN) fcntl.flock(lock, fcntl.LOCK_UN)
def get_extracted_dir(self) -> Path: def get_extracted_dir(self) -> Path:
"""Get the extracted directory path. Can be overridden but work as it is.""" """Get the extracted directory path. Can be overridden but work as it is."""
@@ -103,13 +123,15 @@ class SubprocessAnalyzer(ABC):
@overload @overload
def build_cmd(self, password: str) -> list[str]: ... def build_cmd(self, password: str) -> list[str]: ...
def build_cmd(self, password: Optional[str] = None) -> list[str]: def build_cmd(self, password: str | None = None) -> list[str]:
"""Build the command to run. Can be overridden.""" """Build the command to run. Can be overridden."""
_ = password
if self.cmd is None: if self.cmd is None:
raise NotImplementedError("cmd must be set or build_cmd overridden") msg = "cmd must be set or build_cmd overridden"
raise NotImplementedError(msg)
return self.cmd return self.cmd
def get_results(self, password: Optional[str] = None) -> dict[str, Any]: def get_results(self, password: str | None = None) -> dict[str, Any]:
"""Get results of command before returning.""" """Get results of command before returning."""
result: dict[str, Any] result: dict[str, Any]
extracted_dir = None extracted_dir = None
@@ -128,12 +150,12 @@ class SubprocessAnalyzer(ABC):
stderr = data.stderr stderr = data.stderr
stdout = data.stdout stdout = data.stdout
zip_exist = False zip_exists = False
if extracted_dir and extracted_dir.exists() and any(extracted_dir.iterdir()): if extracted_dir and extracted_dir.exists() and any(extracted_dir.iterdir()):
self.generate_archive(self.output_dir, extracted_dir) # keep archive self.generate_archive(self.output_dir, extracted_dir) # keep archive
zip_exist = True zip_exists = True
if self.is_error(returncode, stdout, stderr, zip_exist): if self.is_error(returncode, stdout, stderr, zip_exist=zip_exists):
result = { result = {
"status": "error", "status": "error",
} }
@@ -143,10 +165,10 @@ class SubprocessAnalyzer(ABC):
"status": "ok", "status": "ok",
"output": self.process_output(stdout, stderr), "output": self.process_output(stdout, stderr),
} }
note: Optional[str] = self.process_note(stdout, stderr) note: str | None = self.process_note(stdout, stderr)
if note: if note:
result["note"] = note result["note"] = note
if zip_exist: if zip_exists:
result["download"] = f"/download/{self.output_dir.name}/{self.name}" result["download"] = f"/download/{self.output_dir.name}/{self.name}"
return result return result
@@ -156,28 +178,32 @@ class SubprocessAnalyzer(ABC):
@overload @overload
def analyze(self, password: str) -> None: ... def analyze(self, password: str) -> None: ...
def analyze(self, password: Optional[str] = None) -> None: def analyze(self, password: str | None = None) -> None:
"""Run the subprocess command and handle results.""" """Run the subprocess command and handle results."""
result: dict[str, Any] result: dict[str, Any]
try: try:
result = self.get_results(password) result = self.get_results(password)
self.update_result(result) self.update_result(result)
except Exception as e: except (RuntimeError, ValueError, OSError, TimeoutError) as e:
self.update_result({"status": "error", "error": str(e)}) self.update_result({"status": "error", "error": str(e)})
raise raise
def is_error(self, returncode: int, stdout: str, stderr: str, zip_exist: bool) -> bool: def is_error(self, returncode: int, stdout: str, stderr: str, *, zip_exist: bool) -> bool:
"""Check if the result is an error.""" """Check if the result is an error."""
_ = returncode, stdout, zip_exist
return len(stderr) > 0 return len(stderr) > 0
def process_output(self, stdout: str, stderr: str) -> str | list[str] | dict[str, str]: def process_output(self, stdout: str, stderr: str) -> str | list[str] | dict[str, str]:
"""Process the stdout into a list of lines.""" """Process the stdout into a list of lines."""
_ = stderr
return [line for line in stdout.split("\n") if line] if stdout else [] return [line for line in stdout.split("\n") if line] if stdout else []
def process_error(self, stdout: str, stderr: str) -> str: def process_error(self, stdout: str, stderr: str) -> str:
"""Process the stderr.""" """Process the stderr."""
_ = stdout
return stderr return stderr
def process_note(self, stdout: str, stderr: str) -> Optional[str]: def process_note(self, stdout: str, stderr: str) -> str | None:
"""Process the stdout for informational purposes.""" """Process the stdout for informational purposes."""
_ = stdout, stderr
return None return None
+5 -4
View File
@@ -1,6 +1,3 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Binwalk Analyzer for Image Submissions.""" """Binwalk Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
@@ -12,14 +9,18 @@ class BinwalkAnalyzer(SubprocessAnalyzer):
"""Analyzer for binwalk.""" """Analyzer for binwalk."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the binwalk analyzer."""
super().__init__("binwalk", input_img, output_dir, has_archive=True) super().__init__("binwalk", input_img, output_dir, has_archive=True)
self.cmd = ["binwalk", "--matryoshka", "-e", self.img, "--run-as=root"] self.cmd = ["binwalk", "--matryoshka", "-e", self.img, "--run-as=root"]
self.make_folder = False # Binwalk already create its own folder self.make_folder = False # Binwalk already create its own folder
def get_extracted_dir(self) -> Path: def get_extracted_dir(self) -> Path:
"""Return the binwalk extraction directory."""
return self.output_dir / f"_{self.input_img.name}.extracted" return self.output_dir / f"_{self.input_img.name}.extracted"
def is_error(self, returncode: int, stdout: str, stderr: str, zip_exist: bool) -> bool: def is_error(self, returncode: int, stdout: str, stderr: str, *, zip_exist: bool) -> bool:
"""Check whether binwalk execution failed."""
_ = returncode, stdout
return len(stderr) > 0 and not zip_exist return len(stderr) > 0 and not zip_exist
+26 -17
View File
@@ -1,21 +1,24 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801,W0612,R0912
# mypy: disable-error-code=unused-awaitable
"""Color Remapping Analyzer for Image Submissions.""" """Color Remapping Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any
import numpy as np import numpy as np
from PIL import Image from PIL import Image
from .base_analyzer import SubprocessAnalyzer from .base_analyzer import SubprocessAnalyzer
RGB_CHANNEL_COUNT = 3
RGBA_CHANNEL_COUNT = 4
RANDOM_REMAPPING_COUNT = 8
GRAYSCALE_DIMENSIONS = 2
class ColorRemappingAnalyzer(SubprocessAnalyzer): class ColorRemappingAnalyzer(SubprocessAnalyzer):
"""Analyzer for color remapping.""" """Analyzer for color remapping."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the color remapping analyzer."""
super().__init__("color_remapping", input_img, output_dir) super().__init__("color_remapping", input_img, output_dir)
def _normalize_image(self, img_np: np.ndarray) -> tuple[np.ndarray, int]: def _normalize_image(self, img_np: np.ndarray) -> tuple[np.ndarray, int]:
@@ -23,41 +26,46 @@ class ColorRemappingAnalyzer(SubprocessAnalyzer):
Returns: Returns:
Tuple of (normalized image array, number of channels) Tuple of (normalized image array, number of channels)
""" """
if len(img_np.shape) == 2: if len(img_np.shape) == GRAYSCALE_DIMENSIONS:
channels = 1 channels = 1
img_np = np.expand_dims(img_np, axis=-1) img_np = np.expand_dims(img_np, axis=-1)
else: else:
channels = img_np.shape[2] channels = img_np.shape[2]
if channels < 3: if channels < RGB_CHANNEL_COUNT:
# Grayscale to RGB # Grayscale to RGB
img_np = np.repeat(img_np, 3, axis=2) img_np = np.repeat(img_np, RGB_CHANNEL_COUNT, axis=2)
channels = 3 channels = RGB_CHANNEL_COUNT
elif channels > 4: elif channels > RGBA_CHANNEL_COUNT:
# More than 4 channels, take first 3 # More than 4 channels, take first 3
img_np = img_np[..., :3] img_np = img_np[..., :RGB_CHANNEL_COUNT]
channels = 3 channels = RGB_CHANNEL_COUNT
return img_np, channels return img_np, channels
def _create_remapped_image(self, img_np: np.ndarray, color_map: np.ndarray) -> Image.Image: def _create_remapped_image(self, img_np: np.ndarray, color_map: np.ndarray) -> Image.Image:
"""Create a single remapped image using the color map.""" """Create a single remapped image using the color map."""
remapped = np.zeros_like(img_np) remapped = np.zeros_like(img_np)
for c in range(min(3, img_np.shape[2])): # Apply to RGB channels for c in range(min(RGB_CHANNEL_COUNT, img_np.shape[2])): # Apply to RGB channels
remapped[..., c] = color_map[img_np[..., c]] remapped[..., c] = color_map[img_np[..., c]]
# Keep alpha channel if present # Keep alpha channel if present
if img_np.shape[2] == 4: if img_np.shape[2] == RGBA_CHANNEL_COUNT:
remapped[..., 3] = img_np[..., 3] remapped[..., 3] = img_np[..., 3]
remapped_img = Image.fromarray(remapped.astype(np.uint8), mode="RGBA") remapped_img = Image.fromarray(remapped.astype(np.uint8), mode="RGBA")
else: else:
remapped_img = Image.fromarray(remapped[..., :3].astype(np.uint8), mode="RGB") remapped_img = Image.fromarray(
remapped[..., :RGB_CHANNEL_COUNT].astype(np.uint8),
mode="RGB",
)
return remapped_img return remapped_img
def get_results(self, password: Optional[str] = None) -> dict[str, Any]: def get_results(self, password: str | None = None) -> dict[str, Any]:
"""Analyze an image submission using color remapping.""" """Analyze an image submission using color remapping."""
_ = password
img = Image.open(self.input_img) img = Image.open(self.input_img)
converted = False converted = False
@@ -71,9 +79,10 @@ class ColorRemappingAnalyzer(SubprocessAnalyzer):
image_json = [] image_json = []
# Generate 8 random color remappings # Generate 8 random color remappings
for i in range(8): rng = np.random.default_rng()
for i in range(RANDOM_REMAPPING_COUNT):
# Create a random permutation of color values (0-255) # Create a random permutation of color values (0-255)
color_map = np.random.randint(0, 256, size=256, dtype=np.uint8) color_map = rng.integers(0, 256, size=256, dtype=np.uint8)
remapped_img = self._create_remapped_image(img_np, color_map) remapped_img = self._create_remapped_image(img_np, color_map)
# Save the remapped image # Save the remapped image
+10 -14
View File
@@ -1,25 +1,27 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Bits Decomposer Analyzer for Image Submissions.""" """Bits Decomposer Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any
import numpy as np import numpy as np
from PIL import Image from PIL import Image
from .base_analyzer import SubprocessAnalyzer from .base_analyzer import SubprocessAnalyzer
RGB_CHANNEL_THRESHOLD = 3
GRAYSCALE_DIMENSIONS = 2
class DecomposerAnalyzer(SubprocessAnalyzer): class DecomposerAnalyzer(SubprocessAnalyzer):
"""Analyzer for bits decomposer.""" """Analyzer for bits decomposer."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the decomposer analyzer."""
super().__init__("decomposer", input_img, output_dir) super().__init__("decomposer", input_img, output_dir)
def get_results(self, _: Optional[str] = None) -> dict[str, Any]: def get_results(self, password: str | None = None) -> dict[str, Any]:
"""Analyze an image submission using bits decomposition.""" """Analyze an image submission using bits decomposition."""
_ = password
img = Image.open(self.input_img) img = Image.open(self.input_img)
converted = False converted = False
@@ -30,20 +32,14 @@ class DecomposerAnalyzer(SubprocessAnalyzer):
img_np = np.array(img) img_np = np.array(img)
# Handle grayscale, RGB, RGBA, etc. # Handle grayscale, RGB, RGBA, etc.
if len(img_np.shape) == 2: channels = 1 if len(img_np.shape) == GRAYSCALE_DIMENSIONS else img_np.shape[2]
channels = 1
else:
channels = img_np.shape[2]
if channels > 1: channel_names = ["Red", "Green", "Blue", "Alpha"] if channels > 1 else ["Grayscale"]
channel_names = ["Red", "Green", "Blue", "Alpha"]
else:
channel_names = ["Grayscale"]
image_json = {} image_json = {}
# Add Superimposed RGB bit planes # Add Superimposed RGB bit planes
if channels >= 3: if channels >= RGB_CHANNEL_THRESHOLD:
superimposed_json = [] superimposed_json = []
for bit in range(8): for bit in range(8):
bit_mask = 1 << bit bit_mask = 1 << bit
+2 -3
View File
@@ -1,6 +1,3 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Exiftool Analyzer for Image Submissions.""" """Exiftool Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
@@ -12,11 +9,13 @@ class ExiftoolAnalyzer(SubprocessAnalyzer):
"""Analyzer for exiftool.""" """Analyzer for exiftool."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the exiftool analyzer."""
super().__init__("exiftool", input_img, output_dir) super().__init__("exiftool", input_img, output_dir)
self.cmd = ["exiftool", "-a", "-u", "-g1", self.img] self.cmd = ["exiftool", "-a", "-u", "-g1", self.img]
def process_output(self, stdout: str, stderr: str) -> dict[str, str]: def process_output(self, stdout: str, stderr: str) -> dict[str, str]:
"""Process the stdout into a list of lines.""" """Process the stdout into a list of lines."""
_ = stderr
metadata: dict[str, str] = {} metadata: dict[str, str] = {}
for line in stdout.split("\n"): for line in stdout.split("\n"):
if ":" in line: if ":" in line:
+3 -3
View File
@@ -1,7 +1,5 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""File Analyzer for Image Submissions.""" """File Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
from .base_analyzer import SubprocessAnalyzer from .base_analyzer import SubprocessAnalyzer
@@ -11,11 +9,13 @@ class FileAnalyzer(SubprocessAnalyzer):
"""Analyzer for the `file` command.""" """Analyzer for the `file` command."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the file analyzer."""
super().__init__("file", input_img, output_dir) super().__init__("file", input_img, output_dir)
self.cmd = ["file", "-b", self.img] self.cmd = ["file", "-b", self.img]
def process_output(self, stdout: str, stderr: str) -> str | list[str] | dict[str, str]: def process_output(self, stdout: str, stderr: str) -> str | list[str] | dict[str, str]:
"""Process the stdout into a list of lines.""" """Process the stdout into a list of lines."""
_ = stderr
return stdout return stdout
+7 -5
View File
@@ -1,26 +1,28 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Foremost Analyzer for Image Submissions.""" """Foremost Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
from .base_analyzer import SubprocessAnalyzer from .base_analyzer import SubprocessAnalyzer
FOREMOST_ERROR_THRESHOLD = 60
class ForemostAnalyzer(SubprocessAnalyzer): class ForemostAnalyzer(SubprocessAnalyzer):
"""Analyzer for foremost.""" """Analyzer for foremost."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the foremost analyzer."""
super().__init__("foremost", input_img, output_dir, has_archive=True) super().__init__("foremost", input_img, output_dir, has_archive=True)
self.cmd = ["foremost", "-o", str(self.get_extracted_dir()), "-i", self.img] self.cmd = ["foremost", "-o", str(self.get_extracted_dir()), "-i", self.img]
def is_error(self, returncode: int, stdout: str, stderr: str, zip_exist: bool) -> bool: def is_error(self, returncode: int, stdout: str, stderr: str, *, zip_exist: bool) -> bool:
"""Check if the result is an error.""" """Check if the result is an error."""
return len(stderr) > 60 _ = stdout, returncode, zip_exist
return len(stderr) > FOREMOST_ERROR_THRESHOLD
def process_output(self, stdout: str, stderr: str) -> str | list[str] | dict[str, str]: def process_output(self, stdout: str, stderr: str) -> str | list[str] | dict[str, str]:
"""Process the stdout/stderr.""" """Process the stdout/stderr."""
_ = stdout
if "Processing" in stderr and "|*|" in stderr: if "Processing" in stderr and "|*|" in stderr:
return stderr.strip().replace("\n", "") return stderr.strip().replace("\n", "")
return [] return []
+2 -3
View File
@@ -1,6 +1,3 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Identify Analyzer for Image Submissions.""" """Identify Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
@@ -12,11 +9,13 @@ class IdentifyAnalyzer(SubprocessAnalyzer):
"""Analyzer for GraphicMagick identify command.""" """Analyzer for GraphicMagick identify command."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the identify analyzer."""
super().__init__("identify", input_img, output_dir) super().__init__("identify", input_img, output_dir)
self.cmd = ["identify", "-verbose", self.img] self.cmd = ["identify", "-verbose", self.img]
def process_output(self, stdout: str, stderr: str) -> str | list[str]: def process_output(self, stdout: str, stderr: str) -> str | list[str]:
"""Process the stdout into a list of lines.""" """Process the stdout into a list of lines."""
_ = stderr
return [line for line in stdout.split("\n") if line.strip()] return [line for line in stdout.split("\n") if line.strip()]
+17 -20
View File
@@ -1,10 +1,6 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""JPSeek Analyzer for Image Submissions.""" """JPSeek Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
from typing import Optional
from .base_analyzer import SubprocessAnalyzer from .base_analyzer import SubprocessAnalyzer
@@ -13,24 +9,25 @@ class JpseekAnalyzer(SubprocessAnalyzer):
"""Analyzer for jpseek.""" """Analyzer for jpseek."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the jpseek analyzer."""
super().__init__("jpseek", input_img, output_dir, has_archive=True) super().__init__("jpseek", input_img, output_dir, has_archive=True)
def build_cmd(self, password: Optional[str] = None) -> list[str]: def build_cmd(self, password: str | None = None) -> list[str]:
"""Build the jpseek command wrapped with expect for password support."""
extracted_dir = self.get_extracted_dir() extracted_dir = self.get_extracted_dir()
assert extracted_dir is not None # since has_archive is True
out = str(extracted_dir / "jpseek.out") out = str(extracted_dir / "jpseek.out")
jpseek_cmd = f"jpseek {self.img} {out}"
if password is None: if password is None:
password = "" password = ""
expect_cmd = ( # File paths are quoted with Tcl braces to prevent interpretation of
f"expect -c 'spawn {jpseek_cmd}; " # special characters. The password is passed via an environment variable
f'expect "Passphrase:"; ' # (JPSEEK_PASS) to avoid any command injection through string interpolation.
f'send "{password}\\r"; ' expect_script = (
f"expect eof'" f"spawn jpseek {{{self.img}}} {{{out}}}; "
'expect "Passphrase:"; '
'send "$env(JPSEEK_PASS)\\r"; '
"expect eof"
) )
command = ["bash", "-c", expect_cmd] return ["env", f"JPSEEK_PASS={password}", "expect", "-c", expect_script]
print("Built command:", command)
return command
def _remove_output_artifacts(self, output: str) -> str: def _remove_output_artifacts(self, output: str) -> str:
extracted_dir = self.get_extracted_dir() extracted_dir = self.get_extracted_dir()
@@ -46,15 +43,15 @@ class JpseekAnalyzer(SubprocessAnalyzer):
o = o.replace(f"spawn jpseek {self.img} {out_file}", "") o = o.replace(f"spawn jpseek {self.img} {out_file}", "")
return o.strip() return o.strip()
def is_error(self, returncode: int, stdout: str, stderr: str, zip_exist: bool) -> bool: def is_error(self, returncode: int, stdout: str, stderr: str, *, zip_exist: bool) -> bool:
"""Check if the result is an error.""" """Check if the result is an error."""
return bool(returncode) and "File not completely recovered" not in "".join([stdout, stderr]) _ = zip_exist
return bool(returncode) and "File not completely recovered" not in f"{stdout}{stderr}"
def process_error(self, stdout: str, stderr: str) -> str: def process_error(self, stdout: str, stderr: str) -> str:
"""Process stderr.""" """Process stderr."""
output = stdout + stderr output = stdout + stderr
output = self._remove_output_artifacts(output) return self._remove_output_artifacts(output)
return output
def process_output(self, stdout: str, stderr: str) -> str: def process_output(self, stdout: str, stderr: str) -> str:
"""Process stdout.""" """Process stdout."""
@@ -65,7 +62,7 @@ class JpseekAnalyzer(SubprocessAnalyzer):
return output return output
def analyze_jpseek(input_img: Path, output_dir: Path, password: Optional[str] = None) -> None: def analyze_jpseek(input_img: Path, output_dir: Path, password: str | None = None) -> None:
"""Analyze an image submission using jpseek.""" """Analyze an image submission using jpseek."""
analyzer = JpseekAnalyzer(input_img, output_dir) analyzer = JpseekAnalyzer(input_img, output_dir)
if password: if password:
+2 -3
View File
@@ -1,6 +1,3 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Jsteg Analyzer for Image Submissions.""" """Jsteg Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
@@ -12,11 +9,13 @@ class JstegAnalyzer(SubprocessAnalyzer):
"""Analyzer for jsteg.""" """Analyzer for jsteg."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the jsteg analyzer."""
super().__init__("jsteg", input_img, output_dir) super().__init__("jsteg", input_img, output_dir)
self.cmd = ["jsteg", "reveal", self.img] self.cmd = ["jsteg", "reveal", self.img]
def process_output(self, stdout: str, stderr: str) -> str | list[str]: def process_output(self, stdout: str, stderr: str) -> str | list[str]:
"""Process the stdout into lines.""" """Process the stdout into lines."""
_ = stderr
if stdout.strip(): if stdout.strip():
return [line for line in stdout.split("\n") if line.strip()] return [line for line in stdout.split("\n") if line.strip()]
return [] return []
+11 -12
View File
@@ -1,10 +1,7 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""OpenStego Analyzer for Image Submissions.""" """OpenStego Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any
from .base_analyzer import SubprocessAnalyzer from .base_analyzer import SubprocessAnalyzer
@@ -13,11 +10,12 @@ class OpenStegoAnalyzer(SubprocessAnalyzer):
"""Analyzer for openstego.""" """Analyzer for openstego."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the OpenStego analyzer."""
super().__init__("openstego", input_img, output_dir, has_archive=True) super().__init__("openstego", input_img, output_dir, has_archive=True)
self.algo = 0 self.algo = 0
def build_cmd(self, password: Optional[str] = None) -> list[str]: def build_cmd(self, password: str | None = None) -> list[str]:
"""Iterator that return command for OpenStego for every algorithms""" """Return an OpenStego extraction command for the current algorithm."""
if password is None: if password is None:
password = "" password = ""
@@ -28,13 +26,12 @@ class OpenStegoAnalyzer(SubprocessAnalyzer):
cmd += [self.img, "-xd", str(self.get_extracted_dir()), "-p", password] cmd += [self.img, "-xd", str(self.get_extracted_dir()), "-p", password]
return cmd return cmd
def is_error(self, returncode: int, stdout: str, stderr: str, zip_exist: bool) -> bool: def is_error(self, returncode: int, stdout: str, stderr: str, *, zip_exist: bool) -> bool:
"""Check if the result is an error.""" """Check if the result is an error."""
return ( _ = returncode, stdout
len(list(self.get_extracted_dir().glob("*"))) == 0 and "Extracted file: " not in stderr return not zip_exist and "Extracted file: " not in stderr
)
def analyze(self, password: Optional[str] = None) -> None: def analyze(self, password: str | None = None) -> None:
"""Run the subprocess command and handle results.""" """Run the subprocess command and handle results."""
result: dict[str, Any] result: dict[str, Any]
try: try:
@@ -42,16 +39,18 @@ class OpenStegoAnalyzer(SubprocessAnalyzer):
if result["status"] == "error": # check second algorithm if failed if result["status"] == "error": # check second algorithm if failed
result = self.get_results(password) result = self.get_results(password)
self.update_result(result) self.update_result(result)
except Exception as e: except (RuntimeError, ValueError, OSError, TimeoutError) as e:
self.update_result({"status": "error", "error": str(e)}) self.update_result({"status": "error", "error": str(e)})
raise raise
def process_output(self, stdout: str, stderr: str) -> str | list[str] | dict[str, str]: def process_output(self, stdout: str, stderr: str) -> str | list[str] | dict[str, str]:
"""Process the stdout/stderr.""" """Process the stdout/stderr."""
_ = stdout
return stderr return stderr
def process_error(self, stdout: str, stderr: str) -> str: def process_error(self, stdout: str, stderr: str) -> str:
"""Process the stderr.""" """Process the stderr."""
_ = stdout
if "OpenStego is a steganography application" in stderr: if "OpenStego is a steganography application" in stderr:
return "OpenStego needs a password to work." return "OpenStego needs a password to work."
return stderr return stderr
+4 -7
View File
@@ -1,10 +1,6 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Outguess Analyzer for Image Submissions.""" """Outguess Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
from typing import Optional
from .base_analyzer import SubprocessAnalyzer from .base_analyzer import SubprocessAnalyzer
@@ -13,18 +9,19 @@ class OutguessAnalyzer(SubprocessAnalyzer):
"""Analyzer for outguess.""" """Analyzer for outguess."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the outguess analyzer."""
super().__init__("outguess", input_img, output_dir, has_archive=True) super().__init__("outguess", input_img, output_dir, has_archive=True)
def build_cmd(self, password: Optional[str] = None) -> list[str]: def build_cmd(self, password: str | None = None) -> list[str]:
"""Build the outguess extraction command."""
extracted_dir = self.get_extracted_dir() extracted_dir = self.get_extracted_dir()
assert extracted_dir is not None # since has_archive is True
out = str(extracted_dir / "outguess.data") out = str(extracted_dir / "outguess.data")
if password: if password:
return ["outguess", "-k", password, "-r", self.img, out] return ["outguess", "-k", password, "-r", self.img, out]
return ["outguess", "-r", self.img, out] return ["outguess", "-r", self.img, out]
def analyze_outguess(input_img: Path, output_dir: Path, password: Optional[str] = None) -> None: def analyze_outguess(input_img: Path, output_dir: Path, password: str | None = None) -> None:
"""Analyze an image submission using outguess.""" """Analyze an image submission using outguess."""
analyzer = OutguessAnalyzer(input_img, output_dir) analyzer = OutguessAnalyzer(input_img, output_dir)
if password: if password:
+11 -11
View File
@@ -1,12 +1,10 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0902,R0801
# mypy: disable-error-code=unused-awaitable
"""PCRT (PNG Check & Repair Tool) Analyzer for Image Submissions.""" """PCRT (PNG Check & Repair Tool) Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any
from aperisolve.utils.png import PNG
from ..utils.png import PNG
from .base_analyzer import SubprocessAnalyzer from .base_analyzer import SubprocessAnalyzer
@@ -14,6 +12,7 @@ class PCRTAnalyzer(SubprocessAnalyzer):
"""Analyzer for PCRT (PNG Check & Repair Tool).""" """Analyzer for PCRT (PNG Check & Repair Tool)."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the PCRT analyzer."""
super().__init__("pcrt", input_img, output_dir, has_archive=True) super().__init__("pcrt", input_img, output_dir, has_archive=True)
def _write_repaired_data(self, data: bytes) -> str: def _write_repaired_data(self, data: bytes) -> str:
@@ -33,8 +32,9 @@ class PCRTAnalyzer(SubprocessAnalyzer):
with extra_path.open("wb") as f: with extra_path.open("wb") as f:
f.write(data) f.write(data)
def get_results(self, password: Optional[str] = None) -> dict[str, Any]: def get_results(self, password: str | None = None) -> dict[str, Any]:
"""Analyze PNG and attempt repairs.""" """Analyze PNG and attempt repairs."""
_ = password
try: try:
with self.input_img.open("rb") as f: with self.input_img.open("rb") as f:
data = f.read() data = f.read()
@@ -52,11 +52,11 @@ class PCRTAnalyzer(SubprocessAnalyzer):
return result return result
result["status"] = "ok" result["status"] = "ok"
result["output"] = png.logs if png.logs else ["PNG appears valid, no repairs needed"] result["output"] = png.logs or ["PNG appears valid, no repairs needed"]
# Save repaired PNG if any fixes were made # Save repaired PNG if any fixes were made
if fixed and png.repaired_data: if fixed and png.repaired_data:
url = self._write_repaired_data(png.repaired_data) url = self._write_repaired_data(bytes(png.repaired_data))
result["note"] = "PNG was repaired and saved" result["note"] = "PNG was repaired and saved"
result["png_images"] = [url] result["png_images"] = [url]
@@ -67,11 +67,11 @@ class PCRTAnalyzer(SubprocessAnalyzer):
result["note"] = result.get("note", "") + " | Extra data found after IEND" result["note"] = result.get("note", "") + " | Extra data found after IEND"
result["download"] = f"/download/{self.output_dir.name}/{self.name}" result["download"] = f"/download/{self.output_dir.name}/{self.name}"
except (ValueError, OSError, RuntimeError, TypeError) as e:
return {"status": "error", "error": f"Analysis failed: {e!s}"}
else:
return result return result
except Exception as e:
return {"status": "error", "error": f"Analysis failed: {str(e)}"}
def analyze_pcrt(input_img: Path, output_dir: Path) -> None: def analyze_pcrt(input_img: Path, output_dir: Path) -> None:
"""Analyze an image submission using PCRT.""" """Analyze an image submission using PCRT."""
+5 -4
View File
@@ -1,6 +1,3 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Pngcheck Analyzer for Image Submissions.""" """Pngcheck Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
@@ -12,14 +9,18 @@ class PngcheckAnalyzer(SubprocessAnalyzer):
"""Analyzer for pngcheck.""" """Analyzer for pngcheck."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the pngcheck analyzer."""
super().__init__("pngcheck", input_img, output_dir) super().__init__("pngcheck", input_img, output_dir)
self.cmd = ["pngcheck", "-v", self.img] self.cmd = ["pngcheck", "-v", self.img]
def is_error(self, returncode: int, stdout: str, stderr: str, zip_exist: bool) -> bool: def is_error(self, returncode: int, stdout: str, stderr: str, *, zip_exist: bool) -> bool:
"""Check whether the file is an unsupported format."""
_ = returncode, stderr, zip_exist
return "this is neither a PNG or JNG image nor a MNG stream" in stdout return "this is neither a PNG or JNG image nor a MNG stream" in stdout
def process_error(self, stdout: str, stderr: str) -> str: def process_error(self, stdout: str, stderr: str) -> str:
"""Process the stderr.""" """Process the stderr."""
_ = stderr
if "this is neither a PNG or JNG image nor a MNG stream" in stdout: if "this is neither a PNG or JNG image nor a MNG stream" in stdout:
return "The file format of the file is not supported (PNG or JNG only)." return "The file format of the file is not supported (PNG or JNG only)."
return stdout return stdout
+13 -17
View File
@@ -1,11 +1,7 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Steghide Analyzer for Image Submissions.""" """Steghide Analyzer for Image Submissions."""
import re import re
from pathlib import Path from pathlib import Path
from typing import Optional
from .base_analyzer import SubprocessAnalyzer from .base_analyzer import SubprocessAnalyzer
@@ -14,30 +10,32 @@ class SteghideAnalyzer(SubprocessAnalyzer):
"""Analyzer for steghide.""" """Analyzer for steghide."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the steghide analyzer."""
super().__init__("steghide", input_img, output_dir, has_archive=True) super().__init__("steghide", input_img, output_dir, has_archive=True)
def build_cmd(self, password: Optional[str] = None) -> list[str]: def build_cmd(self, password: str | None = None) -> list[str]:
"""Build the steghide command for info or extraction."""
if password is None: if password is None:
password = "" password = ""
# First, get the embedded file name, and return the test cmd if failed # First, get the embedded file name, and return the test cmd if failed
cmd_test = ["steghide", "info", self.img, "-p", password] cmd_test = ["steghide", "info", self.img, "-p", password]
data = self.run_command(cmd_test, cwd=self.output_dir) data = self.run_command(cmd_test, cwd=self.output_dir)
err = self.is_error(data.returncode, data.stdout, data.stderr, False) err = self.is_error(data.returncode, data.stdout, data.stderr, zip_exist=False)
if err: if err:
return cmd_test return cmd_test
match = re.search(r'embedded file "([^"]+)"', data.stdout) match = re.search(r'embedded file "([^"]+)"', data.stdout)
assert match is not None if match is None:
return cmd_test
hidden_file = match.group(1) hidden_file = match.group(1)
safe_hidden_file = Path(hidden_file).name safe_hidden_file = Path(hidden_file).name
outfile = str(self.get_extracted_dir() / safe_hidden_file) outfile = str(self.get_extracted_dir() / safe_hidden_file)
cmd = ["steghide", "extract", "-sf", self.img, "-xf", outfile, "-p", password] return ["steghide", "extract", "-sf", self.img, "-xf", outfile, "-p", password]
return cmd
def is_error(self, returncode: int, stdout: str, stderr: str, zip_exist: bool) -> bool: def is_error(self, returncode: int, stdout: str, stderr: str, *, zip_exist: bool) -> bool:
"""Check if the result is an error.""" """Check if the result is an error."""
_ = returncode, zip_exist
match = re.search(r'embedded file "([^"]+)".*', stdout + stderr) match = re.search(r'embedded file "([^"]+)".*', stdout + stderr)
embedded_filename = match.group(1) if match else None embedded_filename = match.group(1) if match else None
@@ -49,20 +47,18 @@ class SteghideAnalyzer(SubprocessAnalyzer):
def process_output(self, stdout: str, stderr: str) -> str | list[str] | dict[str, str]: def process_output(self, stdout: str, stderr: str) -> str | list[str] | dict[str, str]:
"""Process the stdout into a list of lines.""" """Process the stdout into a list of lines."""
out = [] _ = stdout
for line in (stdout + "\n" + stderr).split("\n"): return [line for line in stderr.split("\n") if "wrote extracted data to" in line]
if "wrote extracted data to" in line:
out.append(line)
return out
def process_error(self, stdout: str, stderr: str) -> str: def process_error(self, stdout: str, stderr: str) -> str:
"""Process stderr.""" """Process stderr."""
_ = stdout
if "the file format of the file" in stderr and "not supported" in stderr: if "the file format of the file" in stderr and "not supported" in stderr:
return "The file format of the file is not supported (JPEG or BMP only)." return "The file format of the file is not supported (JPEG or BMP only)."
return stderr return stderr
def analyze_steghide(input_img: Path, output_dir: Path, password: Optional[str] = None) -> None: def analyze_steghide(input_img: Path, output_dir: Path, password: str | None = None) -> None:
"""Analyze an image submission using steghide.""" """Analyze an image submission using steghide."""
analyzer = SteghideAnalyzer(input_img, output_dir) analyzer = SteghideAnalyzer(input_img, output_dir)
if password: if password:
+1 -3
View File
@@ -1,6 +1,3 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Strings Analyzer for Image Submissions.""" """Strings Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
@@ -12,6 +9,7 @@ class StringsAnalyzer(SubprocessAnalyzer):
"""Analyzer for strings.""" """Analyzer for strings."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the strings analyzer."""
super().__init__("strings", input_img, output_dir) super().__init__("strings", input_img, output_dir)
self.cmd = ["strings", self.img] self.cmd = ["strings", self.img]
+4 -6
View File
@@ -1,10 +1,6 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""<toolname> Analyzer for Image Submissions.""" """<toolname> Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
from typing import Optional
from .base_analyzer import SubprocessAnalyzer from .base_analyzer import SubprocessAnalyzer
@@ -13,15 +9,17 @@ class TemplateAnalyzer(SubprocessAnalyzer):
"""Analyzer for <toolname>.""" """Analyzer for <toolname>."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the template analyzer."""
super().__init__("<toolname>", input_img, output_dir, has_archive=True) super().__init__("<toolname>", input_img, output_dir, has_archive=True)
def build_cmd(self, password: Optional[str] = None) -> list[str]: def build_cmd(self, password: str | None = None) -> list[str]:
"""Build command for a placeholder analyzer implementation."""
if password: if password:
return ["<toolname>", "-p", password, "-i", self.img] return ["<toolname>", "-p", password, "-i", self.img]
return ["<toolname>", "-i", self.img] return ["<toolname>", "-i", self.img]
def analyze_template(input_img: Path, output_dir: Path, password: Optional[str] = None) -> None: def analyze_template(input_img: Path, output_dir: Path, password: str | None = None) -> None:
"""Analyze an image submission using <toolname>.""" """Analyze an image submission using <toolname>."""
analyzer = TemplateAnalyzer(input_img, output_dir) analyzer = TemplateAnalyzer(input_img, output_dir)
if password: if password:
+4 -4
View File
@@ -1,6 +1,3 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Zsteg Analyzer for Image Submissions.""" """Zsteg Analyzer for Image Submissions."""
from pathlib import Path from pathlib import Path
@@ -12,10 +9,13 @@ class ZstegAnalyzer(SubprocessAnalyzer):
"""Analyzer for zsteg.""" """Analyzer for zsteg."""
def __init__(self, input_img: Path, output_dir: Path) -> None: def __init__(self, input_img: Path, output_dir: Path) -> None:
"""Initialize the zsteg analyzer."""
super().__init__("zsteg", input_img, output_dir) super().__init__("zsteg", input_img, output_dir)
self.cmd = ["zsteg", self.img] self.cmd = ["zsteg", self.img]
def is_error(self, returncode: int, stdout: str, stderr: str, zip_exist: bool) -> bool: def is_error(self, returncode: int, stdout: str, stderr: str, *, zip_exist: bool) -> bool:
"""Check whether zsteg reported an error."""
_ = returncode, zip_exist
return bool(stderr) or "PNG::NotSupported" in stdout[:100] return bool(stderr) or "PNG::NotSupported" in stdout[:100]
def process_error(self, stdout: str, stderr: str) -> str: def process_error(self, stdout: str, stderr: str) -> str:
+254 -257
View File
@@ -1,24 +1,19 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801,R0915
# mypy: disable-error-code=unused-awaitable
"""Aperi'Solve Flask application.""" """Aperi'Solve Flask application."""
import hashlib import hashlib
import json import json
import os
import shutil import shutil
import time import time
from datetime import datetime, timezone from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, cast
import sentry_sdk import sentry_sdk
from flask import Flask, Response, abort, jsonify, render_template, request, send_file from flask import Flask, Response, abort, jsonify, render_template, request, send_file
from redis import Redis from redis import Redis
from rq import Queue from rq import Queue
from sqlalchemy.exc import SQLAlchemyError
from .utils.sentry import initialize_sentry
initialize_sentry()
from .config import ( from .config import (
CUSTOM_EXTERNAL_SCRIPT, CUSTOM_EXTERNAL_SCRIPT,
@@ -34,12 +29,12 @@ from .config import (
WORKER_FILES, WORKER_FILES,
) )
from .models import Image, Submission, UploadLog, cleanup_old_entries, db from .models import Image, Submission, UploadLog, cleanup_old_entries, db
from .utils.sentry import initialize_sentry
def create_app() -> Flask: def _configure_app(app: Flask) -> None:
"""Create flask application with routes.""" """Configure Flask and extension settings."""
app = Flask(__name__) app.config["JSON_SORT_KEYS"] = False
app.json.sort_keys = False # type: ignore
app.config["PROJECT_VERSION"] = PROJECT_VERSION app.config["PROJECT_VERSION"] = PROJECT_VERSION
app.config["CUSTOM_EXTERNAL_SCRIPT"] = CUSTOM_EXTERNAL_SCRIPT app.config["CUSTOM_EXTERNAL_SCRIPT"] = CUSTOM_EXTERNAL_SCRIPT
app.config["SQLALCHEMY_DATABASE_URI"] = DB_URI app.config["SQLALCHEMY_DATABASE_URI"] = DB_URI
@@ -48,14 +43,59 @@ def create_app() -> Flask:
app.config["REDIS_QUEUE"] = Queue(connection=Redis(host="redis", port=6379)) app.config["REDIS_QUEUE"] = Queue(connection=Redis(host="redis", port=6379))
db.init_app(app) db.init_app(app)
def _extract_unique_ips(upload_logs: list[UploadLog]) -> set[str]:
"""Return unique non-empty uploader IP addresses from logs."""
return {str(log.ip_address) for log in upload_logs if log.ip_address}
def _archive_original_image(
original_image_path: Path,
image_hash: str,
submission_hash: str,
) -> None:
"""Archive original image file before deletion."""
if not original_image_path.exists():
return
REMOVED_IMAGES_FOLDER.mkdir(parents=True, exist_ok=True)
dt = datetime.now(UTC).isoformat()
archive_filename = f"{image_hash}_{submission_hash}_{dt}{original_image_path.suffix}"
archive_path = REMOVED_IMAGES_FOLDER / archive_filename
shutil.copy2(original_image_path, archive_path)
def _remove_result_folder(image_hash: str, submission_hash: str) -> None:
"""Delete result folder for a submission if it exists."""
result_path = RESULT_FOLDER / image_hash / submission_hash
if result_path.exists():
shutil.rmtree(result_path)
def _delete_submission_entities(
submission: Submission,
image: Image,
original_image_path: Path,
) -> None:
"""Delete submission and related image entity if applicable."""
db.session.delete(submission)
if len(image.submissions) <= 1:
if original_image_path.exists():
original_image_path.unlink()
db.session.delete(image)
db.session.commit()
def _register_error_handlers(app: Flask) -> None:
"""Register application-wide error handlers."""
@app.context_processor @app.context_processor
def inject_datetime() -> dict[str, Any]: def inject_datetime() -> dict[str, type[datetime]]:
"""Inject datetime into all templates.""" """Inject datetime into all templates."""
return {"datetime": datetime} return {"datetime": datetime}
@app.errorhandler(413) @app.errorhandler(413)
def too_large(_: Any) -> tuple[Response, int]: def too_large(_: object) -> tuple[Response, int]:
"""Error Handler for Max File Size.""" """Handle max upload size errors."""
sentry_sdk.set_context( sentry_sdk.set_context(
"upload", "upload",
{ {
@@ -67,10 +107,14 @@ def create_app() -> Flask:
return jsonify({"error": "Image size exceeded"}), 413 return jsonify({"error": "Image size exceeded"}), 413
@app.errorhandler(404) @app.errorhandler(404)
def not_found(_: Any) -> str: def not_found(_: object) -> str:
"""Error Handler for 404 not found.""" """Handle not found errors."""
return render_template("error.html", message="Resource not found", statuscode=404) return render_template("error.html", message="Resource not found", statuscode=404)
def _register_page_routes(app: Flask) -> None:
"""Register static page routes."""
@app.route("/") @app.route("/")
def index() -> str: def index() -> str:
"""Render the main index page.""" """Render the main index page."""
@@ -94,139 +138,138 @@ def create_app() -> Flask:
@app.route("/<hash_val>", methods=["GET"]) @app.route("/<hash_val>", methods=["GET"])
def result_page(hash_val: str) -> str: def result_page(hash_val: str) -> str:
"""Render the result page for a given submission hash.""" """Render the result page for a given submission hash."""
submission = Submission.query.get_or_404(hash_val) # type: ignore submission = Submission.query.get_or_404(hash_val)
Image.query.get_or_404(submission.image_hash) # type: ignore Image.query.get_or_404(submission.image_hash)
return render_template("result.html", hash_val=hash_val) return render_template("result.html", hash_val=hash_val)
@app.route("/ads.txt")
def google_ads() -> str:
"""Serve the Google Ads required file."""
return GOOGLE_ADS_TXT
def _upload_image(app: Flask) -> tuple[Response, int]:
"""Handle image upload and initiate analysis."""
cleanup_old_entries()
if "image" not in request.files:
return jsonify({"error": "No image provided"}), 400
image = request.files["image"]
password = request.form.get("password")
deep_analysis = request.form.get("deep") == "true"
if image.filename is None or image.filename == "":
return jsonify({"error": "No image provided"}), 400
if "." not in image.filename or Path(image.filename).suffix.lower() not in IMAGE_EXTENSIONS:
return jsonify({"error": "Unsupported file type"}), 400
image_data = image.read()
img_hash = hashlib.md5(image_data, usedforsecurity=False).hexdigest()
image_name = img_hash + "." + image.filename.rsplit(".", maxsplit=1)[-1].lower()
submission_data = (
image_data
+ image.filename.encode()
+ (password.encode() if password else b"")
+ (b"deep_analysis" if deep_analysis else b"")
)
submission_hash = hashlib.md5(submission_data, usedforsecurity=False).hexdigest()
submission_path = RESULT_FOLDER / img_hash / submission_hash
client_ip = request.headers.get("X-Forwarded-For", request.remote_addr)
if client_ip and "," in client_ip:
client_ip = client_ip.split(",")[0].strip()
user_agent = request.headers.get("User-Agent", "Unknown")
upload_log = UploadLog(
ip_address=client_ip,
user_agent=user_agent,
image_hash=img_hash,
submission_hash=submission_hash,
filename=image.filename,
)
db.session.add(upload_log)
db.session.commit()
submission = Submission.query.filter_by(hash=submission_hash).first()
if submission_path.exists() and submission is not None:
return jsonify({"submission_hash": submission.hash}), 200
new_img_path = RESULT_FOLDER / img_hash / image_name
if not new_img_path.exists():
new_img_path.parent.mkdir(parents=True, exist_ok=True)
with new_img_path.open("wb") as file_obj:
file_obj.write(image_data)
sub_img = Image.query.filter_by(hash=img_hash).first()
if sub_img is None:
sub_img = Image(
file=str(new_img_path),
hash=img_hash,
size=len(image_data),
upload_count=0,
first_submission_date=datetime.now(UTC),
last_submission_date=datetime.now(UTC),
)
db.session.add(sub_img)
db.session.commit()
sub_img_any = cast("Any", sub_img)
sub_img_any.upload_count = int(sub_img_any.upload_count or 0) + 1
db.session.commit()
submission_path.mkdir(parents=True, exist_ok=True)
if not submission:
submission = Submission(
filename=image.filename,
password=password,
deep_analysis=deep_analysis,
hash=submission_hash,
status="pending",
date=time.time(),
image_hash=sub_img.hash,
)
db.session.add(submission)
db.session.commit()
submission_any = cast("Any", submission)
submission_any.status = "pending"
db.session.commit()
app.config["REDIS_QUEUE"].enqueue(
"aperisolve.workers.analyze_image",
submission.hash,
job_timeout=300,
)
return jsonify({"submission_hash": submission.hash}), 200
def _register_submission_routes(app: Flask) -> None:
"""Register upload and status routes."""
@app.route("/upload", methods=["POST"]) @app.route("/upload", methods=["POST"])
def upload_image() -> tuple[Response, int]: def upload_image() -> tuple[Response, int]:
"""Handle image upload and initiate analysis.""" return _upload_image(app)
cleanup_old_entries()
if "image" not in request.files:
return jsonify({"error": "No image provided"}), 400
image = request.files["image"]
password = request.form.get("password")
deep_analysis = request.form.get("deep") == "true"
if image.filename is None or image.filename == "":
return jsonify({"error": "No image provided"}), 400
if "." not in image.filename or Path(image.filename).suffix.lower() not in IMAGE_EXTENSIONS:
return jsonify({"error": "Unsupported file type"}), 400
# Compute hashes and prepare submission
image_data = image.read()
img_hash = hashlib.md5(image_data).hexdigest()
image_name = img_hash + "." + image.filename.rsplit(".", maxsplit=1)[-1].lower()
submission_data = (
image_data
+ image.filename.encode()
+ (password.encode() if password else b"")
+ (b"deep_analysis" if deep_analysis else b"")
)
submission_hash = hashlib.md5(submission_data).hexdigest()
submission_path = RESULT_FOLDER / img_hash / submission_hash
# Log upload to database
client_ip = request.headers.get("X-Forwarded-For", request.remote_addr)
if client_ip and "," in client_ip:
client_ip = client_ip.split(",")[0].strip()
user_agent = request.headers.get("User-Agent", "Unknown")
upload_log = UploadLog(
ip_address=client_ip,
user_agent=user_agent,
image_hash=img_hash,
submission_hash=submission_hash,
filename=image.filename,
)
db.session.add(upload_log) # pylint: disable=no-member
db.session.commit() # pylint: disable=no-member
# Check if hash is present on the DB
submission: Submission = Submission.query.filter_by(hash=submission_hash).first()
# Only return if BOTH the file exists AND the DB entry exists
if submission_path.exists() and submission is not None:
return jsonify({"submission_hash": submission.hash}), 200
# Self-Healing Logic
new_img_path = RESULT_FOLDER / img_hash / image_name
# This fixes the "Ghost File" issue (DB exists, but file is missing)
if not new_img_path.exists():
new_img_path.parent.mkdir(parents=True, exist_ok=True)
with open(new_img_path, "wb") as f:
f.write(image_data)
# Try to find image in DB by hash
sub_img = Image.query.filter_by(hash=img_hash).first()
# If DB entry is missing (even if file exists on disk), create it!
if sub_img is None:
sub_img = Image(
file=str(new_img_path),
hash=img_hash,
size=len(image_data),
upload_count=0,
first_submission_date=datetime.now(timezone.utc),
last_submission_date=datetime.now(timezone.utc),
)
db.session.add(sub_img) # pylint: disable=no-member
db.session.commit() # pylint: disable=no-member
# Increment upload count for the image
sub_img.upload_count += 1
db.session.commit() # pylint: disable=no-member
# Create new Submission entry
submission_path.mkdir(parents=True, exist_ok=True)
# Only create a new submission if it does not already exist
# New submission for new image
if not submission:
submission = Submission(
filename=image.filename,
password=password,
deep_analysis=deep_analysis,
hash=submission_hash,
status="pending",
date=time.time(),
image_hash=sub_img.hash,
)
db.session.add(submission) # pylint: disable=no-member
db.session.commit() # pylint: disable=no-member
# Re-analysis case to prevent early return
submission.status = "pending" # type: ignore
db.session.commit() # pylint: disable=no-member
# Start the analysis jobs
app.config["REDIS_QUEUE"].enqueue(
"aperisolve.workers.analyze_image", submission.hash, job_timeout=300
)
# Return submission identifier
return jsonify({"submission_hash": submission.hash}), 200
@app.route("/status/<hash_val>", methods=["GET"]) @app.route("/status/<hash_val>", methods=["GET"])
def get_status(hash_val: str) -> Response: def get_status(hash_val: str) -> Response:
"""Get the processing status of a submission.""" """Get the processing status of a submission."""
submission = Submission.query.get_or_404(hash_val) # type: ignore submission = Submission.query.get_or_404(hash_val)
return jsonify({"status": submission.status}) # type: ignore return jsonify({"status": submission.status})
def _register_data_routes(app: Flask) -> None:
"""Register metadata, results, and download routes."""
@app.route("/infos/<hash_val>", methods=["GET"]) @app.route("/infos/<hash_val>", methods=["GET"])
def get_infos(hash_val: str) -> Response: def get_infos(hash_val: str) -> Response:
"""Get the metadata and information of a submission.""" """Get the metadata and information of a submission."""
submission = Submission.query.get_or_404(hash_val) submission = Submission.query.get_or_404(hash_val)
image = Image.query.get_or_404(submission.image_hash) # type: ignore image = Image.query.get_or_404(submission.image_hash)
names = [name for name in set(sub.filename for sub in image.submissions) if name] names = [name for name in {sub.filename for sub in image.submissions} if name]
passwords = [pwd for pwd in set(sub.password for sub in image.submissions) if pwd] passwords = [pwd for pwd in {sub.password for sub in image.submissions} if pwd]
return jsonify( return jsonify(
{ {
"image_path": "image/" + str(Path(image.file).name), "image_path": "image/" + str(Path(image.file).name),
@@ -238,32 +281,29 @@ def create_app() -> Flask:
"passwords": passwords, "passwords": passwords,
"removal_min_age_seconds": REMOVAL_MIN_AGE_SECONDS, "removal_min_age_seconds": REMOVAL_MIN_AGE_SECONDS,
"submission_date": submission.date, "submission_date": submission.date,
} },
) # type: ignore )
@app.route("/result/<hash_val>", methods=["GET"]) @app.route("/result/<hash_val>", methods=["GET"])
def get_result(hash_val: str) -> tuple[Response, int]: def get_result(hash_val: str) -> tuple[Response, int]:
"""Get the analysis results of a submission.""" """Get the analysis results of a submission."""
submission = Submission.query.get_or_404(hash_val) submission = Submission.query.get_or_404(hash_val)
image = Image.query.get_or_404(submission.image_hash) # type: ignore image = Image.query.get_or_404(submission.image_hash)
result_path = RESULT_FOLDER / str(image.hash) / str(submission.hash) / "results.json" result_path = RESULT_FOLDER / str(image.hash) / str(submission.hash) / "results.json"
if not result_path.exists(): if not result_path.exists():
return jsonify({"error": "Results not ready yet..."}), 425 return jsonify({"error": "Results not ready yet..."}), 425
with open(result_path, "r", encoding="utf-8") as f: with result_path.open(encoding="utf-8") as file_obj:
results = json.load(f) results = json.load(file_obj)
return jsonify({"results": results}), 200 return jsonify({"results": results}), 200
@app.route("/download/<hash_val>/<tool>") @app.route("/download/<hash_val>/<tool>")
def download_output(hash_val: str, tool: str) -> Response: def download_output(hash_val: str, tool: str) -> Response:
"""Download the output of a specific analyzer for a given submission hash.""" """Download output of a specific analyzer for a submission hash."""
submission = Submission.query.filter_by(hash=hash_val).first_or_404() submission = Submission.query.filter_by(hash=hash_val).first_or_404()
image = Image.query.get_or_404(submission.image_hash) # type: ignore image = Image.query.get_or_404(submission.image_hash)
output_dir = RESULT_FOLDER / str(image.hash) / str(submission.hash) output_dir = RESULT_FOLDER / str(image.hash) / str(submission.hash)
output_file = output_dir / f"{tool}.7z" output_file = output_dir / f"{tool}.7z"
@@ -272,35 +312,55 @@ def create_app() -> Flask:
return send_file(output_file, as_attachment=True) return send_file(output_file, as_attachment=True)
@app.route("/image/<img_name>")
@app.route("/image/<hash_val>/<img_name>")
def get_image(hash_val: str | None = None, img_name: str | None = None) -> Response:
"""Download an image for a submission hash or by direct image filename.
Usually this serves derived images generated by the decomposer analyzer.
"""
if img_name is None:
return abort(404, description="Image not found or unsupported format")
if hash_val is not None:
submission = Submission.query.filter_by(hash=hash_val).first_or_404()
image = Image.query.get_or_404(submission.image_hash)
output_dir = RESULT_FOLDER / str(image.hash) / str(submission.hash)
output_file = output_dir / Path(img_name).name
else:
hash_val = img_name.split(".")[0]
image = Image.query.filter_by(hash=hash_val).first_or_404()
output_file = Path(image.file)
if (not output_file.exists()) or (output_file.suffix.lower() not in IMAGE_EXTENSIONS):
return abort(404, description="Image not found or unsupported format")
return send_file(output_file, as_attachment=True)
def _register_management_routes(app: Flask) -> None:
"""Register removal and moderation routes."""
@app.route("/remove/<hash_val>", methods=["POST"]) @app.route("/remove/<hash_val>", methods=["POST"])
def remove_image(hash_val: str) -> tuple[Response, int]: def remove_image(hash_val: str) -> tuple[Response, int]:
"""Remove an image and associated results if criteria are met.""" """Remove an image and associated results if criteria are met."""
submission = Submission.query.get_or_404(hash_val)
image = Image.query.get_or_404(submission.image_hash)
submission = Submission.query.get_or_404(hash_val) # type: ignore age_seconds = time.time() - submission.date
image = Image.query.get_or_404(submission.image_hash) # type: ignore
# Check if image is at least 5 minutes old
current_time = time.time()
age_seconds = current_time - submission.date
if age_seconds < REMOVAL_MIN_AGE_SECONDS: if age_seconds < REMOVAL_MIN_AGE_SECONDS:
return ( return (
jsonify( jsonify(
{ {
"error": "Image must be at least 5 minutes old. " "error": "Image must be at least 5 minutes old. "
f"Current age: {int(age_seconds)}s" f"Current age: {int(age_seconds)}s",
} },
), ),
403, 403,
) )
# Get all IPs that uploaded this image
upload_logs = UploadLog.query.filter_by(image_hash=image.hash).all() upload_logs = UploadLog.query.filter_by(image_hash=image.hash).all()
unique_ips = set() unique_ips = _extract_unique_ips(upload_logs)
for log in upload_logs:
if log.ip_address:
unique_ips.add(log.ip_address)
# Check if only one IP has uploaded this image
if len(unique_ips) > 1: if len(unique_ips) > 1:
return ( return (
jsonify( jsonify(
@@ -308,87 +368,45 @@ def create_app() -> Flask:
"error": "Image uploaded from multiple IP addresses. Removal is not " "error": "Image uploaded from multiple IP addresses. Removal is not "
"allowed.", "allowed.",
"ip_count": len(unique_ips), "ip_count": len(unique_ips),
} },
), ),
403, 403,
) )
# Create archive directory if it doesn't exist
REMOVED_IMAGES_FOLDER.mkdir(parents=True, exist_ok=True)
# Archive the original image (copy to removed_images folder with metadata)
original_image_path = Path(image.file) original_image_path = Path(image.file)
if original_image_path.exists():
dt = datetime.now(timezone.utc).isoformat()
archive_filename = f"{image.hash}_{submission.hash}_{dt}{original_image_path.suffix}"
archive_path = REMOVED_IMAGES_FOLDER / archive_filename
try:
shutil.copy2(original_image_path, archive_path)
except Exception as e:
sentry_sdk.capture_exception(e)
return jsonify({"error": "Failed to archive image"}), 500
# Delete the results folder
result_path = RESULT_FOLDER / str(image.hash) / str(submission.hash)
try: try:
if result_path.exists(): _archive_original_image(original_image_path, image.hash, submission.hash)
shutil.rmtree(result_path) _remove_result_folder(str(image.hash), str(submission.hash))
except Exception as e: _delete_submission_entities(submission, image, original_image_path)
sentry_sdk.capture_exception(e) except (OSError, SQLAlchemyError) as exc:
return jsonify({"error": "Failed to delete results"}), 500 db.session.rollback()
sentry_sdk.capture_exception(exc)
# Delete submission from database return jsonify({"error": "Failed to remove image"}), 500
try:
db.session.delete(submission) # pylint: disable=no-member
# If this was the last submission for this image, delete the image entry too
if len(image.submissions) <= 1:
# Also delete the original image file
if original_image_path.exists():
original_image_path.unlink()
db.session.delete(image) # pylint: disable=no-member
db.session.commit() # pylint: disable=no-member
except Exception as e:
db.session.rollback() # pylint: disable=no-member
sentry_sdk.capture_exception(e)
return jsonify({"error": "Failed to update database"}), 500
# Note: UploadLog entries are intentionally kept for audit purposes
return jsonify({"message": "Image successfully removed"}), 200 return jsonify({"message": "Image successfully removed"}), 200
@app.route("/remove_password/<hash_val>", methods=["POST"]) @app.route("/remove_password/<hash_val>", methods=["POST"])
def remove_password(hash_val: str) -> tuple[Response, int]: def remove_password(hash_val: str) -> tuple[Response, int]:
"""Remove a password from a submission if criteria are met.""" """Remove a password from a submission if criteria are met."""
submission = Submission.query.get_or_404(hash_val)
submission = Submission.query.get_or_404(hash_val) # type: ignore
if not submission.password: if not submission.password:
return jsonify({"error": "No password to remove"}), 400 return jsonify({"error": "No password to remove"}), 400
# Check if submission is at least 5 minutes old age_seconds = time.time() - submission.date
current_time = time.time()
age_seconds = current_time - submission.date
if age_seconds < REMOVAL_MIN_AGE_SECONDS: if age_seconds < REMOVAL_MIN_AGE_SECONDS:
return ( return (
jsonify( jsonify(
{ {
"error": f"Submission must be at least 5 minutes old. " "error": "Submission must be at least 5 minutes old. "
f"Current age: {int(age_seconds)}s" f"Current age: {int(age_seconds)}s",
} },
), ),
403, 403,
) )
# Get all IPs that uploaded this specific submission (password combination)
upload_logs = UploadLog.query.filter_by(submission_hash=submission.hash).all() upload_logs = UploadLog.query.filter_by(submission_hash=submission.hash).all()
unique_ips = set() unique_ips = _extract_unique_ips(upload_logs)
for log in upload_logs:
if log.ip_address:
unique_ips.add(log.ip_address)
# Check if only one IP has uploaded this submission
if len(unique_ips) > 1: if len(unique_ips) > 1:
return ( return (
jsonify( jsonify(
@@ -396,57 +414,36 @@ def create_app() -> Flask:
"error": "Submission uploaded from multiple IP addresses. " "error": "Submission uploaded from multiple IP addresses. "
"Password removal is not allowed.", "Password removal is not allowed.",
"ip_count": len(unique_ips), "ip_count": len(unique_ips),
} },
), ),
403, 403,
) )
# Remove the password
try: try:
submission.password = None submission.password = None
db.session.commit() # pylint: disable=no-member db.session.commit()
except Exception as e: except SQLAlchemyError as exc:
db.session.rollback() # pylint: disable=no-member db.session.rollback()
sentry_sdk.capture_exception(e) sentry_sdk.capture_exception(exc)
return jsonify({"error": "Failed to remove password"}), 500 return jsonify({"error": "Failed to remove password"}), 500
return jsonify({"message": "Password successfully removed"}), 200 return jsonify({"message": "Password successfully removed"}), 200
@app.route("/image/<img_name>")
@app.route("/image/<hash_val>/<img_name>")
def get_image(hash_val: Optional[str] = None, img_name: Optional[str] = None) -> Response:
"""
Download the image with a specific name (usually ones generated with
decomposer) for a given submission hash.
"""
if img_name is None:
return abort(404, description="Image not found or unsupported format")
if hash_val is not None:
submission = Submission.query.filter_by(hash=hash_val).first_or_404()
image = Image.query.get_or_404(submission.image_hash) # type: ignore
output_dir = RESULT_FOLDER / str(image.hash) / str(submission.hash)
output_file = output_dir / Path(img_name).name
else:
hash_val = img_name.split(".")[0] # Extract hash from the image name
image = Image.query.filter_by(hash=hash_val).first_or_404()
output_file = Path(image.file) # type: ignore
if (not output_file.exists()) or (output_file.suffix.lower() not in IMAGE_EXTENSIONS):
return abort(404, description="Image not found or unsupported format")
return send_file(output_file, as_attachment=True)
@app.route("/ads.txt")
def google_ads() -> str:
"""Google Ads required file"""
return GOOGLE_ADS_TXT
def create_app() -> Flask:
"""Create flask application with routes."""
initialize_sentry()
app = Flask(__name__)
_configure_app(app)
_register_error_handlers(app)
_register_page_routes(app)
_register_submission_routes(app)
_register_data_routes(app)
_register_management_routes(app)
return app return app
if __name__ == "__main__": if __name__ == "__main__":
my_app = create_app() my_app = create_app()
my_app.run(host="0.0.0.0", port=5000, debug=FLASK_DEBUG) flask_host = os.getenv("FLASK_HOST", "127.0.0.1")
my_app.run(host=flask_host, port=5000, debug=FLASK_DEBUG)
+7 -4
View File
@@ -1,12 +1,15 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Aperi'Solve configuration variables.""" """Aperi'Solve configuration variables."""
from importlib.metadata import PackageNotFoundError, version
from os import getenv from os import getenv
from pathlib import Path from pathlib import Path
PROJECT_VERSION = getenv("PROJECT_VERSION", "development") try:
_package_version = version("aperisolve")
except PackageNotFoundError:
_package_version = "development"
PROJECT_VERSION = getenv("PROJECT_VERSION", _package_version)
REMOVAL_MIN_AGE_SECONDS = int(getenv("REMOVAL_MIN_AGE_SECONDS", "300")) # 5 minutes REMOVAL_MIN_AGE_SECONDS = int(getenv("REMOVAL_MIN_AGE_SECONDS", "300")) # 5 minutes
MAX_PENDING_TIME = int(getenv("MAX_PENDING_TIME", "600")) # 10 minutes by default MAX_PENDING_TIME = int(getenv("MAX_PENDING_TIME", "600")) # 10 minutes by default
+74 -102
View File
@@ -1,14 +1,12 @@
# flake8: noqa: E203,E501,W503 """Database models for the Aperi'Solve application."""
# pylint: disable=E1101,C0413,W0212,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""This module defines the database models for the Aperi'Solve application."""
import itertools import itertools
import shutil import shutil
import struct import struct
import time import time
import zlib import zlib
from datetime import datetime, timezone from datetime import UTC, datetime
from typing import cast
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import ( from sqlalchemy import (
@@ -21,6 +19,7 @@ from sqlalchemy import (
SmallInteger, SmallInteger,
String, String,
) )
from sqlalchemy.exc import SQLAlchemyError
from aperisolve.config import MAX_PENDING_TIME, MAX_STORE_TIME, RESULT_FOLDER from aperisolve.config import MAX_PENDING_TIME, MAX_STORE_TIME, RESULT_FOLDER
from aperisolve.utils.utils import get_resolutions, get_valid_depth_color_pairs from aperisolve.utils.utils import get_resolutions, get_valid_depth_color_pairs
@@ -28,29 +27,31 @@ from aperisolve.utils.utils import get_resolutions, get_valid_depth_color_pairs
db: SQLAlchemy = SQLAlchemy() db: SQLAlchemy = SQLAlchemy()
class Image(db.Model): # type: ignore class Image(db.Model):
"""Model representing an image file in the database.""" """Model representing an image file in the database."""
hash = Column(String(64), primary_key=True, unique=True, nullable=False) hash = Column(String(64), primary_key=True, unique=True, nullable=False)
file = Column(String(128), unique=True, nullable=False) file = Column(String(128), unique=True, nullable=False)
size = Column(Integer, nullable=False) size = Column(Integer, nullable=False)
first_submission_date = Column( first_submission_date = Column(
DateTime, nullable=False, default=lambda: datetime.now(timezone.utc) DateTime,
nullable=False,
default=lambda: datetime.now(UTC),
) )
last_submission_date = Column( last_submission_date = Column(
DateTime, DateTime,
nullable=False, nullable=False,
default=lambda: datetime.now(timezone.utc), default=lambda: datetime.now(UTC),
onupdate=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(UTC),
) )
upload_count = Column(Integer) upload_count = Column(Integer)
submissions = db.relationship("Submission", backref="image", lazy=True) submissions = db.relationship("Submission", backref="image", lazy=True)
class Submission(db.Model): # type: ignore class Submission(db.Model):
"""Model representing a file submission for analysis. """Model representing a file submission for analysis.
Submissions are defined with a filename, password, image content, and analysis
option. Submissions are defined with a filename, password, image content, and analysis option.
""" """
hash = Column(String(128), primary_key=True, unique=True, nullable=False) hash = Column(String(128), primary_key=True, unique=True, nullable=False)
@@ -58,13 +59,12 @@ class Submission(db.Model): # type: ignore
password = Column(String(128)) password = Column(String(128))
deep_analysis = Column(Boolean, default=False) deep_analysis = Column(Boolean, default=False)
status = Column(String(20), default="pending") status = Column(String(20), default="pending")
date = Column(Float, nullable=False, default=lambda: datetime.now(timezone.utc)) date = Column(Float, nullable=False, default=lambda: datetime.now(UTC))
# Foreign key to Image
image_hash = Column(String, db.ForeignKey("image.hash"), nullable=False) image_hash = Column(String, db.ForeignKey("image.hash"), nullable=False)
class IHDR(db.Model): # type: ignore class IHDR(db.Model):
"""IHDR CRC lookup table with direct parameter storage.""" """IHDR CRC lookup table with direct parameter storage."""
iid = Column(Integer, primary_key=True, autoincrement=True) iid = Column(Integer, primary_key=True, autoincrement=True)
@@ -77,23 +77,30 @@ class IHDR(db.Model): # type: ignore
def to_ihdr_bytes(self) -> bytes: def to_ihdr_bytes(self) -> bytes:
"""Convert database record to IHDR chunk data (13 bytes).""" """Convert database record to IHDR chunk data (13 bytes)."""
bit_depth = cast("int", self.bit_depth)
color_type = cast("int", self.color_type)
interlace = cast("int", self.interlace)
return ( return (
struct.pack(">I", self.width) struct.pack(">I", self.width)
+ struct.pack(">I", self.height) + struct.pack(">I", self.height)
+ bytes( + bytes(
[ [
self.bit_depth, bit_depth,
self.color_type, color_type,
0, # compression method (always 0) 0,
0, # filter method (always 0) 0,
self.interlace, interlace,
] ],
) )
) )
@staticmethod @staticmethod
def compute_crc( def compute_crc(
width: int, height: int, bit_depth: int, color_type: int, interlace: int width: int,
height: int,
bit_depth: int,
color_type: int,
interlace: int,
) -> int: ) -> int:
"""Compute CRC32 for IHDR chunk.""" """Compute CRC32 for IHDR chunk."""
ihdr_data = ( ihdr_data = (
@@ -104,53 +111,36 @@ class IHDR(db.Model): # type: ignore
return zlib.crc32(b"IHDR" + ihdr_data) & 0xFFFFFFFF return zlib.crc32(b"IHDR" + ihdr_data) & 0xFFFFFFFF
class UploadLog(db.Model): # type: ignore class UploadLog(db.Model):
"""Model representing upload activity logs.""" """Model representing upload activity logs."""
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
ip_address = Column(String(45), nullable=False) # IPv6 max length is 45 ip_address = Column(String(45), nullable=False)
user_agent = Column(String(512), nullable=True) user_agent = Column(String(512), nullable=True)
upload_time = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) upload_time = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
image_hash = Column(String(64), nullable=False) image_hash = Column(String(64), nullable=False)
submission_hash = Column(String(128), nullable=True) submission_hash = Column(String(128), nullable=True)
filename = Column(String(128), nullable=True) filename = Column(String(128), nullable=True)
def fill_ihdr_db() -> None: def fill_ihdr_db() -> None:
""" """Populate IHDR table with common PNG configurations."""
Populate IHDR table with common PNG configurations.
Creates entries for all combinations of:
- Common resolutions (from get_resolutions())
- Valid bit depth/color type pairs (from get_valid_depth_color_pairs())
- Interlace methods (0, 1)
Computes CRC for each combination and stores parameters directly.
"""
try: try:
# Skip if table already has entries (check before doing any work)
count_query = db.session.query(IHDR.iid).limit(1) count_query = db.session.query(IHDR.iid).limit(1)
if db.session.execute(count_query).first() is not None: if db.session.execute(count_query).first() is not None:
print("IHDR table already populated, skipping fill.")
return return
resolutions = get_resolutions() resolutions = get_resolutions()
bit_color_pairs = list(get_valid_depth_color_pairs()) bit_color_pairs = list(get_valid_depth_color_pairs())
interlace_methods = [0, 1] interlace_methods = [0, 1]
combinations = itertools.product(resolutions, bit_color_pairs, interlace_methods)
combinations = itertools.product(
resolutions,
bit_color_pairs,
interlace_methods,
)
count = 0 count = 0
previous_autoflush = db.session.autoflush
with db.session.no_autoflush: db.session.autoflush = False
try:
for (width, height), (bit_depth, color_type), interlace in combinations: for (width, height), (bit_depth, color_type), interlace in combinations:
# Compute CRC directly
crc = IHDR.compute_crc(width, height, bit_depth, color_type, interlace) crc = IHDR.compute_crc(width, height, bit_depth, color_type, interlace)
# Add new entry
db.session.add( db.session.add(
IHDR( IHDR(
crc=crc, crc=crc,
@@ -159,78 +149,60 @@ def fill_ihdr_db() -> None:
bit_depth=bit_depth, bit_depth=bit_depth,
color_type=color_type, color_type=color_type,
interlace=interlace, interlace=interlace,
) ),
) )
count += 1 count += 1
if count % 5000 == 0: if count % 5000 == 0:
print(f"Inserted {count} IHDR entries...")
db.session.commit() db.session.commit()
print(f"Inserted {count} total IHDR entries.") finally:
db.session.autoflush = previous_autoflush
except Exception as e: except SQLAlchemyError:
print(f"Error filling IHDR table: {e}")
db.session.rollback() db.session.rollback()
def cleanup_old_entries() -> None: def _cleanup_submissions(now: float) -> None:
""" """Delete stale or invalid submissions."""
Clean up old and incomplete entries from the database and file system. for submission in Submission.query.all():
if submission.status in ("pending", "running") and now - submission.date > MAX_PENDING_TIME:
db.session.delete(submission)
continue
This function performs two main cleanup operations: if submission.status == "done":
1. Submission cleanup:
- Deletes submissions with "pending" or "running" status that have exceeded
the maximum allowed processing time (MAX_PENDING_TIME).
- Deletes completed submissions ("done" status) that have missing or buggy
results (results.json file not found).
2. Image cleanup:
- Deletes images older than MAX_STORE_TIME along with all associated
submissions and their result folders.
- Deletes orphaned images (with no submissions) older than MAX_PENDING_TIME
and removes their associated result folders from the file system.
All database changes are committed at the end of the operation.
Returns:
None
"""
now = time.time()
for submission in Submission.query.all(): # type: ignore
if (
submission.status in ("pending", "running") # type: ignore
and now - submission.date > MAX_PENDING_TIME # type: ignore
):
# Processing took too long, delete
db.session.delete(submission) # pylint: disable=no-member
elif submission.status == "done": # type: ignore
# Search for buggy results, delete
result_path = RESULT_FOLDER / str(submission.image_hash) / str(submission.hash) result_path = RESULT_FOLDER / str(submission.image_hash) / str(submission.hash)
result_file = result_path / "results.json" result_file = result_path / "results.json"
if not result_file.exists(): if not result_file.exists():
db.session.delete(submission) # pylint: disable=no-member db.session.delete(submission)
shutil.rmtree(result_path) shutil.rmtree(result_path)
# Delete "old" images
for img in Image.query.all(): # type: ignore def _cleanup_images() -> None:
"""Delete old and orphaned images with their result folders."""
for img in Image.query.all():
if img.last_submission_date.tzinfo is None: if img.last_submission_date.tzinfo is None:
img_date = img.last_submission_date.replace(tzinfo=timezone.utc) img_date = img.last_submission_date.replace(tzinfo=UTC)
else: else:
img_date = img.last_submission_date img_date = img.last_submission_date
delay = datetime.now(timezone.utc) - img_date
img_fold = RESULT_FOLDER / img.hash delay = datetime.now(UTC) - img_date
img_folder = RESULT_FOLDER / img.hash
if delay.total_seconds() > MAX_STORE_TIME: if delay.total_seconds() > MAX_STORE_TIME:
for s in img.submissions: for submission in img.submissions:
db.session.delete(s) # pylint: disable=no-member db.session.delete(submission)
if img_fold.exists(): if img_folder.exists():
shutil.rmtree(img_fold) # type: ignore shutil.rmtree(img_folder)
db.session.delete(img) # pylint: disable=no-member db.session.delete(img)
continue
# Delete Images with missing submission
if len(img.submissions) == 0 and delay.total_seconds() > MAX_PENDING_TIME: if len(img.submissions) == 0 and delay.total_seconds() > MAX_PENDING_TIME:
if img_fold.exists(): if img_folder.exists():
shutil.rmtree(img_fold) # type: ignore shutil.rmtree(img_folder)
db.session.delete(img) # pylint: disable=no-member db.session.delete(img)
db.session.commit() # pylint: disable=no-member
def cleanup_old_entries() -> None:
"""Clean up old and incomplete entries from the database and file system."""
now = time.time()
_cleanup_submissions(now)
_cleanup_images()
db.session.commit()
-7
View File
@@ -1,7 +0,0 @@
black==25.12.0
isort==7.0.0
flake8==7.3.0
mypy==1.18.1
pylint==4.0.4
pre-commit==4.5.1
mypy-extensions
-11
View File
@@ -1,11 +0,0 @@
Flask
Flask-SQLAlchemy
numpy
redis
rq
psycopg2-binary
types-Flask-SQLAlchemy
sqlalchemy-stubs
Pillow
gunicorn
sentry-sdk[flask,sqlalchemy,rq]
+32 -4
View File
@@ -82,17 +82,19 @@ a:hover {
.title-text { .title-text {
color: var(--theme-green); color: var(--theme-green);
text-shadow: 0 0 0.3125rem var(--theme-green-shadow); text-shadow: 0 0 8px var(--theme-green-shadow2);
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 400;
line-height: 1; line-height: 1;
font-family: var(--font-base)
} }
.title-version { .title-version {
color: var(--theme-green); color: var(--theme-green);
text-shadow: 0 0 0.3125rem var(--theme-green-shadow); text-shadow: 0 0 4px var(--theme-green-shadow2);
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1; line-height: 1;
margin-top: 3px;
} }
/* ======== Background ======== */ /* ======== Background ======== */
@@ -125,6 +127,12 @@ form {
border-radius: var(--border-radius); border-radius: var(--border-radius);
} }
.question{
color : var(--theme-green);
font-weight: 400;
font-family: var(--font-base);
}
/* Drop Zone */ /* Drop Zone */
.drop-zone { .drop-zone {
background-color: var(--dark-bg); background-color: var(--dark-bg);
@@ -143,7 +151,7 @@ form {
.btn, .btn-primary, .btn-hack { .btn, .btn-primary, .btn-hack {
background-color: var(--theme-green); background-color: var(--theme-green);
color: #000; color: #000;
font-weight: bold; /* font-weight: bold; */
border: none; border: none;
text-shadow: none; text-shadow: none;
transition: box-shadow 0.3s ease; transition: box-shadow 0.3s ease;
@@ -154,6 +162,23 @@ form {
box-shadow: 0 0 0.625rem var(--theme-green-shadow2); box-shadow: 0 0 0.625rem var(--theme-green-shadow2);
} }
/* Ensure the inline browse button inside the drop-zone shows the same hover behaviour */
#browse-btn {
background-color: var(--theme-green);
color: #000;
border: none;
text-shadow: none;
transition: box-shadow 0.3s ease;
pointer-events: auto;
}
#browse-btn:hover {
background-color: var(--theme-green);
box-shadow: 0 0 0.625rem var(--theme-green-shadow2);
transform: translateY(-0.0625rem);
color: white;
}
/* Bootstrap color overrides */ /* Bootstrap color overrides */
.text-success, .bg-success { .text-success, .bg-success {
color: var(--theme-green) !important; color: var(--theme-green) !important;
@@ -486,6 +511,7 @@ footer {
.footer-text { .footer-text {
color: var(--theme-green); color: var(--theme-green);
font-size: 0.9rem; font-size: 0.9rem;
margin: 0 !important;
} }
.footer-link { .footer-link {
@@ -494,6 +520,7 @@ footer {
transition: color 0.3s, text-shadow 0.3s; transition: color 0.3s, text-shadow 0.3s;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
.footer-link:hover { .footer-link:hover {
@@ -666,6 +693,7 @@ footer {
padding: var(--spacing-sm); padding: var(--spacing-sm);
font-size: 0.85rem; font-size: 0.85rem;
} }
}
/* Phones */ /* Phones */
@media screen and (max-width: 36rem) { @media screen and (max-width: 36rem) {
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="icon" type="image/svg+xml" href="/static/img/logo.svg"> <link rel="icon" type="image/svg+xml" href="/static/img/logo.svg">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdnjs.cloudflare.com"> <link rel="preconnect" href="https://cdnjs.cloudflare.com">
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&display=swap" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" rel="stylesheet"> <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.0.1/css/all.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/aperisolve.css" rel="stylesheet"> <link href="/static/css/aperisolve.css" rel="stylesheet">
+1 -1
View File
@@ -7,7 +7,7 @@
</div> </div>
<!-- Links --> <!-- Links -->
<div class="d-flex align-items-center gap-3 flex-wrap"> <div class="d-flex align-items-center gap-3 flex-wrap link-container">
<a href="https://buymeacoffee.com/aperisolve" title="Buy Me A Coffee" class="footer-link"> <a href="https://buymeacoffee.com/aperisolve" title="Buy Me A Coffee" class="footer-link">
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" class="footer-icon-img" /> <img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" class="footer-icon-img" />
</a> </a>
+2 -2
View File
@@ -3,7 +3,7 @@
<form id="upload-form" class="card p-4 mb-4" method="POST" enctype="multipart/form-data"> <form id="upload-form" class="card p-4 mb-4" method="POST" enctype="multipart/form-data">
<!-- Description Section --> <!-- Description Section -->
<section class="mb-4"> <section class="mb-4">
<h2 class="h4 mb-3">What is this?</h2> <h2 class="h4 mb-3 question">What is this?</h2>
<p class="text-light-emphasis mb-0"> <p class="text-light-emphasis mb-0">
Aperi'Solve is an online platform which performs layer analysis on images. Aperi'Solve is an online platform which performs layer analysis on images.
The platform also uses zsteg, steghide, outguess, exiftool, binwalk, foremost and strings The platform also uses zsteg, steghide, outguess, exiftool, binwalk, foremost and strings
@@ -17,7 +17,7 @@
<div id="drop-zone" class="drop-zone border border-2 border-dashed rounded-3 p-4 text-center"> <div id="drop-zone" class="drop-zone border border-2 border-dashed rounded-3 p-4 text-center">
<p id="drag-msg" class="mb-3">📂 Drag and drop an image here or click to select</p> <p id="drag-msg" class="mb-3">📂 Drag and drop an image here or click to select</p>
<input type="file" id="image" name="image" accept="image/*" class="visually-hidden" required> <input type="file" id="image" name="image" accept="image/*" class="visually-hidden" required>
<button type="button" class="btn btn-secondary" id="browse-btn" aria-label="Choose image file"> <button type="button" class="btn" id="browse-btn" aria-label="Choose image file">
<i class="fa fa-folder-open me-2"></i>Choose an image <i class="fa fa-folder-open me-2"></i>Choose an image
</button> </button>
</div> </div>
+1
View File
@@ -0,0 +1 @@
"""Utility helpers used by AperiSolve modules."""
+12 -20
View File
@@ -1,5 +1,5 @@
""" """Database initialization script.
Database initialization script.
This script is meant to be run ONCE at deploy time, never from Gunicorn or runtime code. This script is meant to be run ONCE at deploy time, never from Gunicorn or runtime code.
If CLEAR_AT_RESTART is set, database will be reset at launch time. If CLEAR_AT_RESTART is set, database will be reset at launch time.
""" """
@@ -7,11 +7,12 @@ If CLEAR_AT_RESTART is set, database will be reset at launch time.
import os import os
from shutil import rmtree from shutil import rmtree
import sentry_sdk
from sqlalchemy import inspect from sqlalchemy import inspect
from ..app import create_app from aperisolve.app import create_app
from ..config import RESULT_FOLDER from aperisolve.config import RESULT_FOLDER
from ..models import db, fill_ihdr_db from aperisolve.models import db, fill_ihdr_db
def main() -> None: def main() -> None:
@@ -25,7 +26,6 @@ def main() -> None:
tables = inspector.get_table_names() tables = inspector.get_table_names()
if reset: if reset:
# Delete result folder content # Delete result folder content
if RESULT_FOLDER.exists(): if RESULT_FOLDER.exists():
for item in RESULT_FOLDER.iterdir(): for item in RESULT_FOLDER.iterdir():
@@ -34,28 +34,20 @@ def main() -> None:
rmtree(item) rmtree(item)
else: else:
item.unlink() item.unlink()
except Exception: except OSError as exc:
continue sentry_sdk.capture_exception(exc)
print("⚠️ CLEAR_AT_RESTART=1 detected")
print("💥 Dropping all tables...")
db.drop_all() db.drop_all()
print("🗄️ Recreating schema...")
db.create_all() db.create_all()
elif not tables:
db.create_all()
else: else:
if not tables: pass
print("🗄️ Creating database schema...")
db.create_all()
else:
print("🗄️ Database schema already exists")
print("📦 Filling IHDR static data...")
fill_ihdr_db() fill_ihdr_db()
print("✅ Database initialization complete") RESULT_FOLDER.mkdir(parents=True, exist_ok=True)
RESULT_FOLDER.mkdir(parents=True, exist_ok=True)
if __name__ == "__main__": if __name__ == "__main__":
+94 -69
View File
@@ -1,16 +1,14 @@
# flake8: noqa: E203,E501,W503 """PNG Class for analyzer modules."""
# pylint: disable=C0413,W0718,R0903,R0801,R0902
# mypy: disable-error-code=unused-awaitable
"""PNG Class for analyzer modules"""
import itertools import itertools
import re import re
import struct import struct
import zlib import zlib
from typing import Any, Optional from typing import Any
from aperisolve.app import create_app
from aperisolve.models import IHDR
from ..app import create_app
from ..models import IHDR
from .utils import int2hex, str2hex from .utils import int2hex, str2hex
__author__ = [ __author__ = [
@@ -20,11 +18,14 @@ __author__ = [
"sherlly (https://github.com/sherlly/PCRT)", "sherlly (https://github.com/sherlly/PCRT)",
] ]
IDAT_NOT_FOUND_OFFSET = -5
class PNG: class PNG:
"""PNG file analyzer and repair tool.""" """PNG file analyzer and repair tool."""
def __init__(self, data: bytes) -> None: def __init__(self, data: bytes) -> None:
"""Initialize PNG parser state for a binary payload."""
self.data = data self.data = data
self.width = self.height = self.bits = self.mode = 0 self.width = self.height = self.bits = self.mode = 0
self.compression = self.filter = self.interlace = self.channel = 0 self.compression = self.filter = self.interlace = self.channel = 0
@@ -47,7 +48,7 @@ class PNG:
"""Check if data contains PNG signature chunks.""" """Check if data contains PNG signature chunks."""
return all(p in data for p in [b"IHDR", b"IDAT", b"IEND"]) return all(p in data for p in [b"IHDR", b"IDAT", b"IEND"])
def _check_crc(self, chunk_type: bytes, chunk_data: bytes, checksum: bytes) -> Optional[bytes]: def _check_crc(self, chunk_type: bytes, chunk_data: bytes, checksum: bytes) -> bytes | None:
"""Check CRC of chunk.""" """Check CRC of chunk."""
calc_crc = struct.pack("!I", zlib.crc32(chunk_type + chunk_data)) calc_crc = struct.pack("!I", zlib.crc32(chunk_type + chunk_data))
return calc_crc if calc_crc != checksum else None return calc_crc if calc_crc != checksum else None
@@ -101,7 +102,8 @@ class PNG:
return True return True
def _find_ancillary( def _find_ancillary(
self, data: bytes self,
data: bytes,
) -> tuple[dict[bytes, list[bytes]], dict[bytes, list[bytes]], dict[bytes, Any]]: ) -> tuple[dict[bytes, list[bytes]], dict[bytes, list[bytes]], dict[bytes, Any]]:
"""Find ancillary chunks in PNG data with an optimized single pass.""" """Find ancillary chunks in PNG data with an optimized single pass."""
ancillary = [ ancillary = [
@@ -165,8 +167,10 @@ class PNG:
return txt_content, image_content, crcs return txt_content, image_content, crcs
def check_chunks(self) -> bool: def check_chunks(self) -> bool:
"""Copy ancillary chunks (like PLTE) from original data to repaired_data with crc """Copy ancillary chunks (like PLTE) from original data to repaired_data with CRC.
validation."""
validation.
"""
critical_ancillary = [ critical_ancillary = [
b"PLTE", b"PLTE",
b"tRNS", b"tRNS",
@@ -222,8 +226,51 @@ class PNG:
return True return True
def _lookup_ihdr_from_db(self, chunk_type: bytes, crc: bytes) -> bytes | None:
"""Try to recover IHDR bytes from known CRC values in the database."""
crc_int = struct.unpack("!I", crc)[0]
app = create_app()
with app.app_context():
matches = IHDR.query.filter_by(crc=crc_int).all()
if not matches:
return None
self._log(f"Found {len(matches)} matching IHDR configuration(s) in database")
match = matches[0]
test_ihdr = match.to_ihdr_bytes()
if self._check_crc(chunk_type, test_ihdr, crc):
self._log("Database match found but CRC verification failed")
return None
self._log(
f"Recovered IHDR: {match.width}x{match.height}, "
f"bit_depth={match.bit_depth}, "
f"color_type={match.color_type}, "
f"interlace={match.interlace}",
)
return test_ihdr
def _bruteforce_ihdr_dimensions(
self,
chunk_type: bytes,
ihdr: bytes,
crc: bytes,
) -> bytes | None:
"""Fallback recovery for width and height by brute-force CRC matching."""
self._log("No database match found, falling back to exhaustive search...")
for width in range(1, 5000):
for height in range(1, 5000):
test_ihdr = struct.pack(">I", width) + struct.pack(">I", height) + ihdr[16:21]
if not self._check_crc(chunk_type, test_ihdr, crc):
self._log(
f"Found correct dimensions via exhaustive search: {width}x{height}",
)
return test_ihdr
return None
def check_ihdr(self) -> bool: def check_ihdr(self) -> bool:
"""Check and repair IHDR chunk using database lookup - WITH OPTIMIZED EXHAUSTIVE SEARCH.""" """Check and repair IHDR chunk using database lookup and exhaustive fallback."""
pos, ihdr = self._find_ihdr(self.data) pos, ihdr = self._find_ihdr(self.data)
if pos == -1: if pos == -1:
self._error("Lost IHDR chunk") self._error("Lost IHDR chunk")
@@ -239,43 +286,11 @@ class PNG:
self._log(f"Chunk crc: {str2hex(crc)}, Correct crc: {str2hex(calc_crc)}") self._log(f"Chunk crc: {str2hex(crc)}, Correct crc: {str2hex(calc_crc)}")
self._log("Looking up CRC in database...") self._log("Looking up CRC in database...")
crc_int = struct.unpack("!I", crc)[0] if (recovered_ihdr := self._lookup_ihdr_from_db(chunk_type, crc)) or (
recovered_ihdr := self._bruteforce_ihdr_dimensions(chunk_type, ihdr, crc)
# Database lookup ):
app = create_app() ihdr = ihdr[:8] + recovered_ihdr + crc
with app.app_context(): fixed = True
matches = IHDR.query.filter_by(crc=crc_int).all()
if matches:
self._log(f"Found {len(matches)} matching IHDR configuration(s) in database")
match = matches[0]
test_ihdr = match.to_ihdr_bytes()
if not self._check_crc(chunk_type, test_ihdr, crc):
ihdr = ihdr[:8] + test_ihdr + crc
self._log(
f"Recovered IHDR: {match.width}x{match.height}, "
f"bit_depth={match.bit_depth}, "
f"color_type={match.color_type}, "
f"interlace={match.interlace}"
)
fixed = True
else:
self._log("Database match found but CRC verification failed")
# Fallback: exhaustive search if database lookup fails
if not fixed:
self._log("No database match found, falling back to exhaustive search...")
for w in range(1, 5000):
for h in range(1, 5000):
test_ihdr = struct.pack(">I", w) + struct.pack(">I", h) + ihdr[16:21]
if not self._check_crc(chunk_type, test_ihdr, crc):
ihdr = ihdr[:8] + test_ihdr + crc
self._log(f"Found correct dimensions via exhaustive search: {w}x{h}")
fixed = True
break
if fixed:
break
if not fixed: if not fixed:
self._error("Could not recover IHDR dimensions") self._error("Could not recover IHDR dimensions")
@@ -289,8 +304,12 @@ class PNG:
return fixed return fixed
def _fix_dos2unix( def _fix_dos2unix(
self, chunk_type: bytes, chunk_data: bytes, crc: bytes, count: int self,
) -> Optional[bytes]: chunk_type: bytes,
chunk_data: bytes,
crc: bytes,
count: int,
) -> bytes | None:
"""Fix DOS to Unix line ending conversion.""" """Fix DOS to Unix line ending conversion."""
pos_list = [] pos_list = []
pos = -1 pos = -1
@@ -307,7 +326,7 @@ class PNG:
def check_idat(self) -> bool: def check_idat(self) -> bool:
"""Check and repair IDAT chunks.""" """Check and repair IDAT chunks."""
idat_begin = self.data.find(b"IDAT") - 4 idat_begin = self.data.find(b"IDAT") - 4
if idat_begin == -5: if idat_begin == IDAT_NOT_FOUND_OFFSET:
self._error("Lost all IDAT chunks") self._error("Lost all IDAT chunks")
return False return False
@@ -322,43 +341,50 @@ class PNG:
for i, pos1 in enumerate(pos_list): for i, pos1 in enumerate(pos_list):
if i + 1 == len(pos_list): if i + 1 == len(pos_list):
idat_table.append( idat_table.append(
self.data[pos1 - 4 : pos_iend - 4 if pos_iend != -1 else len(self.data)] self.data[pos1 - 4 : pos_iend - 4 if pos_iend != -1 else len(self.data)],
) )
else: else:
idat_table.append(self.data[pos1 - 4 : pos_list[i + 1] - 4]) idat_table.append(self.data[pos1 - 4 : pos_list[i + 1] - 4])
offset = idat_begin offset = idat_begin
fixed = False fixed = False
for idat_chunk in idat_table: for chunk in idat_table:
length = struct.unpack("!I", idat_chunk[:4])[0] current_chunk = chunk
chunk_type, chunk_data, crc = idat_chunk[4:8], idat_chunk[8:-4], idat_chunk[-4:] length = struct.unpack("!I", current_chunk[:4])[0]
chunk_type, chunk_data, crc = (
current_chunk[4:8],
current_chunk[8:-4],
current_chunk[-4:],
)
if length != len(chunk_data): if length != len(chunk_data):
self._log(f"Error IDAT chunk data length at offset {int2hex(offset)}") self._log(f"Error IDAT chunk data length at offset {int2hex(offset)}")
self._log(f"Length: {int2hex(length)}, Actual: {int2hex(len(chunk_data))}") self._log(f"Length: {int2hex(length)}, Actual: {int2hex(len(chunk_data))}")
if fixed_data := self._fix_dos2unix( if fixed_data := self._fix_dos2unix(
chunk_type, chunk_data, crc, abs(length - len(chunk_data)) chunk_type,
chunk_data,
crc,
abs(length - len(chunk_data)),
): ):
idat_chunk = idat_chunk[:8] + fixed_data + idat_chunk[-4:] current_chunk = current_chunk[:8] + fixed_data + current_chunk[-4:]
self._log("Successfully recovered IDAT chunk data (DOS->Unix fix)") self._log("Successfully recovered IDAT chunk data (DOS->Unix fix)")
fixed = True fixed = True
else: else:
self._log("Failed to fix IDAT chunk, using original") self._log("Failed to fix IDAT chunk, using original")
else: elif calc_crc := self._check_crc(chunk_type, chunk_data, crc):
if calc_crc := self._check_crc(chunk_type, chunk_data, crc): self._log(f"Error IDAT CRC at offset {int2hex(offset + 8 + length)}")
self._log(f"Error IDAT CRC at offset {int2hex(offset + 8 + length)}") self._log(f"Chunk crc: {str2hex(crc)}, Correct: {str2hex(calc_crc)}")
self._log(f"Chunk crc: {str2hex(crc)}, Correct: {str2hex(calc_crc)}") current_chunk = current_chunk[:-4] + calc_crc
idat_chunk = idat_chunk[:-4] + calc_crc self._log("Successfully fixed CRC")
self._log("Successfully fixed CRC") fixed = True
fixed = True
self.repaired_data.extend(idat_chunk) self.repaired_data.extend(current_chunk)
offset += len(chunk_data) + 12 offset += len(chunk_data) + 12
self._log(f"IDAT chunk check complete at offset {int2hex(idat_begin)}") self._log(f"IDAT chunk check complete at offset {int2hex(idat_begin)}")
return fixed return fixed
def check_iend(self) -> tuple[bool, Optional[bytes]]: def check_iend(self) -> tuple[bool, bytes | None]:
"""Check and repair IEND chunk.""" """Check and repair IEND chunk."""
standard_iend = b"\x00\x00\x00\x00IEND\xae\x42\x60\x82" standard_iend = b"\x00\x00\x00\x00IEND\xae\x42\x60\x82"
pos = self.data.find(b"IEND") pos = self.data.find(b"IEND")
@@ -385,9 +411,8 @@ class PNG:
self._log("IEND chunk check complete") self._log("IEND chunk check complete")
return fixed, extra_data return fixed, extra_data
def repair(self) -> tuple[bool, Optional[bytes]]: def repair(self) -> tuple[bool, bytes | None]:
"""Run full PNG repair process.""" """Run full PNG repair process."""
if not self._check_format(self.data): if not self._check_format(self.data):
self._error("File may not be a PNG image") self._error("File may not be a PNG image")
return False, None return False, None
+5 -8
View File
@@ -1,6 +1,3 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Sentry initialization for Aperisolve.""" """Sentry initialization for Aperisolve."""
import os import os
@@ -10,6 +7,7 @@ from sentry_sdk.integrations.flask import FlaskIntegration
from sentry_sdk.integrations.rq import RqIntegration from sentry_sdk.integrations.rq import RqIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from sentry_sdk.integrations.threading import ThreadingIntegration from sentry_sdk.integrations.threading import ThreadingIntegration
from sentry_sdk.utils import BadDsn
SENTRY_DSN = os.environ.get("SENTRY_DSN") SENTRY_DSN = os.environ.get("SENTRY_DSN")
SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT", "development") SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT", "development")
@@ -20,7 +18,6 @@ SENTRY_RELEASE = os.environ.get("SENTRY_RELEASE", "1.0.0")
def initialize_sentry() -> None: def initialize_sentry() -> None:
"""Initialize Sentry SDK once, safely.""" """Initialize Sentry SDK once, safely."""
# No DSN → do nothing # No DSN → do nothing
# Prevent double initialization (Flask + RQ, CLI, tests, etc.) # Prevent double initialization (Flask + RQ, CLI, tests, etc.)
if not SENTRY_DSN or sentry_sdk.Hub.current.client is not None: if not SENTRY_DSN or sentry_sdk.Hub.current.client is not None:
@@ -42,7 +39,7 @@ def initialize_sentry() -> None:
send_default_pii=False, send_default_pii=False,
enable_tracing=True, enable_tracing=True,
) )
except sentry_sdk.utils.BadDsn as e: except BadDsn:
print(f"Sentry DSN is invalid, skipping Sentry initialization: {e}") sentry_sdk.capture_message("Invalid Sentry DSN configuration", level="warning")
except Exception as e: except RuntimeError as exc:
print(f"Sentry initialization failed: {e}") sentry_sdk.capture_exception(exc)
+12 -13
View File
@@ -1,11 +1,10 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Utility functions for analyzers modules.""" """Utility functions for analyzers modules."""
import binascii import binascii
import math import math
from typing import Iterable from collections.abc import Iterable
MAX_RESOLUTION_SIZE = 10_000
def str2hex(s: bytes) -> str: def str2hex(s: bytes) -> str:
@@ -15,12 +14,11 @@ def str2hex(s: bytes) -> str:
def int2hex(i: int) -> str: def int2hex(i: int) -> str:
"""Convert int to hex string with 0x prefix.""" """Convert int to hex string with 0x prefix."""
return "0x" + hex(i)[2:].upper() return f"0x{i:X}"
def get_resolutions() -> list[tuple[int, int]]: def get_resolutions() -> list[tuple[int, int]]:
""" """Generate a sorted list of common display and image resolutions.
Generate a sorted list of common display and image resolutions.
Creates a comprehensive set of resolution tuples by combining various base widths Creates a comprehensive set of resolution tuples by combining various base widths
with standard aspect ratios used in screens, digital displays, photography, and with standard aspect ratios used in screens, digital displays, photography, and
@@ -42,14 +40,15 @@ def get_resolutions() -> list[tuple[int, int]]:
Returns: Returns:
list[tuple[int, int]]: Sorted list of (width, height) resolution tuples list[tuple[int, int]]: Sorted list of (width, height) resolution tuples
with heights constrained between 1 and 10000 pixels. with heights constrained between 1 and 10000 pixels.
""" """
base_widths = ( base_widths = (
list(range(16, 257, 16)) # 16 → 256 list(range(16, 257, 16)) # 16 → 256
+ list(range(320, 1025, 32)) # 320 → 1024 + list(range(320, 1025, 32)) # 320 → 1024
+ list(range(1280, 2561, 64)) # 1280 → 2560 + list(range(1280, 2561, 64)) # 1280 → 2560
+ list(range(3000, 4097, 128)) # 3K–4K + list(range(3000, 4097, 128)) # 3K-4K
+ list(range(5120, 8193, 256)) # 5K–8K + list(range(5120, 8193, 256)) # 5K-8K
+ [10000] # upper bound + [MAX_RESOLUTION_SIZE] # upper bound
) )
aspect_ratios = [ aspect_ratios = [
# Screens / digital # Screens / digital
@@ -74,15 +73,14 @@ def get_resolutions() -> list[tuple[int, int]]:
for w in base_widths: for w in base_widths:
for ar_w, ar_h in aspect_ratios: for ar_w, ar_h in aspect_ratios:
h = math.floor(round(w * ar_h / ar_w)) h = math.floor(round(w * ar_h / ar_w))
if 1 <= h <= 10000: if 1 <= h <= MAX_RESOLUTION_SIZE:
resolutions.add((w, h)) resolutions.add((w, h))
resolutions.add((h, w)) # portrait / landscape resolutions.add((h, w)) # portrait / landscape
return sorted(resolutions) return sorted(resolutions)
def get_valid_depth_color_pairs() -> Iterable[tuple[int, int]]: def get_valid_depth_color_pairs() -> Iterable[tuple[int, int]]:
""" """Generate valid combinations of color and bit depth values.
Generate valid combinations of color and bit depth values.
Yields tuples of (depth, color) representing valid color mode and bit depth Yields tuples of (depth, color) representing valid color mode and bit depth
combinations. Supports grayscale (0), RGB (2, 3, 4, 6) color modes with combinations. Supports grayscale (0), RGB (2, 3, 4, 6) color modes with
@@ -91,6 +89,7 @@ def get_valid_depth_color_pairs() -> Iterable[tuple[int, int]]:
Yields: Yields:
tuple: A tuple of (depth, color) where depth is an integer representing tuple: A tuple of (depth, color) where depth is an integer representing
bit depth and color is an integer representing the color mode. bit depth and color is an integer representing the color mode.
""" """
valid_color_depth = { valid_color_depth = {
0: [1, 2, 4, 8, 16], 0: [1, 2, 4, 8, 16],
-3
View File
@@ -1,6 +1,3 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Aperi'Solve WSGI runner.""" """Aperi'Solve WSGI runner."""
from aperisolve.app import create_app from aperisolve.app import create_app
+28 -62
View File
@@ -1,19 +1,13 @@
# flake8: noqa: E203,E501,W503
# pylint: disable=C0413,W0718,R0903,R0801
# mypy: disable-error-code=unused-awaitable
"""Asynchronous worker for analyzing image submissions.""" """Asynchronous worker for analyzing image submissions."""
import threading import threading
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any
import sentry_sdk import sentry_sdk
from sqlalchemy.exc import SQLAlchemyError
from .utils.sentry import initialize_sentry from .analyzers.binwalk import analyze_binwalk
initialize_sentry()
from .analyzers.binwalk import analyze_binwalk # pylint: disable=C0413
from .analyzers.color_remapping import analyze_color_remapping from .analyzers.color_remapping import analyze_color_remapping
from .analyzers.decomposer import analyze_decomposer from .analyzers.decomposer import analyze_decomposer
from .analyzers.exiftool import analyze_exiftool from .analyzers.exiftool import analyze_exiftool
@@ -32,62 +26,40 @@ from .analyzers.zsteg import analyze_zsteg
from .app import create_app from .app import create_app
from .config import RESULT_FOLDER from .config import RESULT_FOLDER
from .models import Image, Submission, db from .models import Image, Submission, db
from .utils.sentry import initialize_sentry
def analyze_image(submission_hash: str) -> None: def analyze_image(submission_hash: str) -> None:
""" """Analyze an image submission by running multiple analysis tools concurrently."""
Analyze an image submission by running multiple analysis tools concurrently. initialize_sentry()
This function retrieves a submission and its associated image from the database,
then executes various image analysis tools in parallel threads. The analysis tools
include binwalk, decomposer, exiftool, foremost, strings, pngcheck, steghide,
zsteg, and image resize. If deep analysis is enabled, additional tools like
outguess are included.
Args:
submission_hash (str): The unique hash identifier of the submission to analyze.
Returns:
None
Raises:
Implicitly catches and handles exceptions during analysis, setting submission
status to "error" if an exception occurs.
Side Effects:
- Queries the database for the submission and associated image
- Updates submission status in the database ("running" -> "completed" or "error")
- Creates result directories and generates analysis output files
- Modifies the database session and commits changes
"""
app = create_app() app = create_app()
with app.app_context(): with app.app_context():
submission: Submission = Submission.query.get(submission_hash) # type: ignore submission = Submission.query.get(submission_hash)
image = Image.query.get_or_404(submission.image_hash) # type: ignore if submission is None:
sentry_sdk.capture_message(f"Submission not found: {submission_hash}", level="warning")
if not submission or not image: # No submission found
sentry_sdk.capture_message(
f"Submission or image not found: {submission_hash}", level="warning"
)
return return
submission.status = "running" # type: ignore image = Image.query.get_or_404(submission.image_hash)
db.session.commit() # pylint: disable=no-member submission.status = "running"
db.session.commit()
try: try:
img_path: Path = Path(image.file) img_path = Path(image.file)
result_path: Path = RESULT_FOLDER / str(image.hash) / str(submission.hash) result_path = RESULT_FOLDER / str(image.hash) / str(submission.hash)
result_path.mkdir(parents=True, exist_ok=True) result_path.mkdir(parents=True, exist_ok=True)
threads: list[threading.Thread] = [] threads: list[threading.Thread] = []
def run_analyzer(analyzer_func: Any, *args: Any) -> None: def run_analyzer(analyzer_func: Callable[..., None], *args: object) -> None:
"""Run an analyzer function in a separate thread.""" """Run an analyzer function in a separate thread."""
analyzer_name = analyzer_func.__name__.replace("analyze_", "") analyzer_name = getattr(
analyzer_func,
"__name__",
analyzer_func.__class__.__name__,
).replace("analyze_", "")
try: try:
analyzer_func(*args) analyzer_func(*args)
except Exception as e: except (RuntimeError, ValueError, OSError, TypeError) as exc:
print(f"Error in {analyzer_name}: {e}")
with sentry_sdk.push_scope() as scope: with sentry_sdk.push_scope() as scope:
scope.set_tag("analyzer", analyzer_name) scope.set_tag("analyzer", analyzer_name)
scope.set_tag("submission_hash", submission_hash) scope.set_tag("submission_hash", submission_hash)
@@ -102,7 +74,7 @@ def analyze_image(submission_hash: str) -> None:
}, },
) )
scope.fingerprint = ["analyzer-error", analyzer_name] scope.fingerprint = ["analyzer-error", analyzer_name]
sentry_sdk.capture_exception(e) sentry_sdk.capture_exception(exc)
analyzers = [ analyzers = [
(analyze_binwalk, img_path, result_path), (analyze_binwalk, img_path, result_path),
@@ -122,13 +94,8 @@ def analyze_image(submission_hash: str) -> None:
(analyze_zsteg, img_path, result_path), (analyze_zsteg, img_path, result_path),
] ]
# Deep analysis only
if submission.deep_analysis: if submission.deep_analysis:
analyzers.extend( analyzers.append((analyze_outguess, img_path, result_path))
[
(analyze_outguess, img_path, result_path),
]
)
for analyzer in analyzers: for analyzer in analyzers:
thread = threading.Thread(target=run_analyzer, args=analyzer) thread = threading.Thread(target=run_analyzer, args=analyzer)
@@ -138,11 +105,10 @@ def analyze_image(submission_hash: str) -> None:
for thread in threads: for thread in threads:
thread.join() thread.join()
submission.status = "completed" # type: ignore submission.status = "completed"
except Exception as e: except (RuntimeError, ValueError, OSError, TypeError, SQLAlchemyError) as exc:
sentry_sdk.capture_exception(e) sentry_sdk.capture_exception(exc)
submission.status = "error" # type: ignore submission.status = "error"
finally: finally:
db.session.commit() # pylint: disable=no-member db.session.commit()
# Flush Sentry events - wait up to 5 seconds for events to be sent
sentry_sdk.flush(timeout=5) sentry_sdk.flush(timeout=5)
+7
View File
@@ -6,6 +6,13 @@ services:
restart: always restart: always
ports: ports:
- "${WEB_APP_PORT:-5000}:5000" - "${WEB_APP_PORT:-5000}:5000"
labels:
- homepage.name=AperiSolve
- homepage.group=Steganography
- homepage.icon=aperisolve.png
- homepage.href=http://localhost:${WEB_APP_PORT:-5000}
- homepage.description=Online platform for steganographic analysis
- homepage.server=my-docker
volumes: volumes:
- apresults:/app/aperisolve/results - apresults:/app/aperisolve/results
depends_on: depends_on:
+7 -13
View File
@@ -1,21 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# lint.sh - Run all formatters, linters, and type checks according to pyproject.toml # lint.sh - Run linting and type checks according to pyproject.toml
set -euo pipefail set -euo pipefail
echo "🖤 Running Black..." echo "🧹 Running Ruff lint checks..."
black . ruff check .
echo "📦 Running Isort..." echo "🎨 Running Ruff format checks..."
isort . ruff format . --check
echo "🐍 Running Flake8..." echo "🔍 Running ty type checks..."
flake8 --max-line-length 100 --extend-ignore=E203,E501,W503 . ty check aperisolve
echo "🔍 Running Mypy..."
mypy .
echo "⚡ Running Pylint..."
pylint aperisolve/
echo "✅ All checks passed!" echo "✅ All checks passed!"
+30 -33
View File
@@ -1,9 +1,9 @@
# Minimal Python project configuration for linters and type checkers. # Minimal Python project configuration for linters and type checkers.
# This file centralizes config for black, isort, flake8, mypy, pylint. # This file centralizes config for ruff and ty.
[project] [project]
name = "aperisolve" name = "aperisolve"
version = "3.1.3" version = "3.2.1"
description = "Open-source steganalysis web platform for automated image analysis and steganography detection" description = "Open-source steganalysis web platform for automated image analysis and steganography detection"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -11,37 +11,34 @@ license = {text = "MIT"}
authors = [ authors = [
{name = "Zeecka"} {name = "Zeecka"}
] ]
dependencies = [
"Flask",
"Flask-SQLAlchemy",
"numpy",
"redis",
"rq",
"psycopg2-binary",
"Pillow",
"gunicorn",
"sentry-sdk[flask,sqlalchemy,rq]"
]
[tool.black] [project.optional-dependencies]
dev = [
"ruff",
"ty",
"pre-commit",
"types-Flask-SQLAlchemy",
"sqlalchemy-stubs"
]
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.ruff]
line-length = 100 line-length = 100
target-version = ["py312"] target-version = "py312"
[tool.isort] [tool.ruff.lint]
profile = "black" select = ["ALL"]
[tool.flake8]
extend-ignore = ["E203", "E501", "W503"]
max-line-length = 100
[tool.mypy]
disallow_any_generics = true
disallow_untyped_defs = true
follow_imports = "skip"
ignore_missing_imports = true
disable_error_code = ["unused-awaitable"]
[tool.pylint]
disable = ["W0511", "W0718", "R0801", "R0903", "R0914"]
[tool.pre-commit]
skip = true
[tool.doc]
description = """
Linting and type checking configuration for this project.
- Black: code formatting
- Isort: import sorting
- Flake8: code style
- Mypy: static typing
- Pylint: code quality
"""
Generated
+919
View File
@@ -0,0 +1,919 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "aperisolve"
version = "3.2.0"
source = { editable = "." }
dependencies = [
{ name = "flask" },
{ name = "flask-sqlalchemy" },
{ name = "gunicorn" },
{ name = "numpy" },
{ name = "pillow" },
{ name = "psycopg2-binary" },
{ name = "redis" },
{ name = "rq" },
{ name = "sentry-sdk", extra = ["flask", "rq", "sqlalchemy"] },
]
[package.optional-dependencies]
dev = [
{ name = "pre-commit" },
{ name = "ruff" },
{ name = "sqlalchemy-stubs" },
{ name = "ty" },
{ name = "types-flask-sqlalchemy" },
]
[package.metadata]
requires-dist = [
{ name = "flask" },
{ name = "flask-sqlalchemy" },
{ name = "gunicorn" },
{ name = "numpy" },
{ name = "pillow" },
{ name = "pre-commit", marker = "extra == 'dev'" },
{ name = "psycopg2-binary" },
{ name = "redis" },
{ name = "rq" },
{ name = "ruff", marker = "extra == 'dev'" },
{ name = "sentry-sdk", extras = ["flask", "sqlalchemy", "rq"] },
{ name = "sqlalchemy-stubs", marker = "extra == 'dev'" },
{ name = "ty", marker = "extra == 'dev'" },
{ name = "types-flask-sqlalchemy", marker = "extra == 'dev'" },
]
provides-extras = ["dev"]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" },
]
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "croniter"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil" },
{ name = "pytz" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/2f/44d1ae153a0e27be56be43465e5cb39b9650c781e001e7864389deb25090/croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577", size = 64481, upload-time = "2024-12-17T17:17:47.32Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/4b/290b4c3efd6417a8b0c284896de19b1d5855e6dbdb97d2a35e68fa42de85/croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368", size = 25468, upload-time = "2024-12-17T17:17:45.359Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]]
name = "filelock"
version = "3.24.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/73/92/a8e2479937ff39185d20dd6a851c1a63e55849e447a55e798cc2e1f49c65/filelock-3.24.3.tar.gz", hash = "sha256:011a5644dc937c22699943ebbfc46e969cdde3e171470a6e40b9533e5a72affa", size = 37935, upload-time = "2026-02-19T00:48:20.543Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/0f/5d0c71a1aefeb08efff26272149e07ab922b64f46c63363756224bd6872e/filelock-3.24.3-py3-none-any.whl", hash = "sha256:426e9a4660391f7f8a810d71b0555bce9008b0a1cc342ab1f6947d37639e002d", size = 24331, upload-time = "2026-02-19T00:48:18.465Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "flask-sqlalchemy"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" },
]
[[package]]
name = "greenlet"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" },
{ url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" },
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
{ url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" },
{ url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]
[[package]]
name = "gunicorn"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
]
[[package]]
name = "identify"
version = "2.6.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[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 = "librt"
version = "0.8.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" },
{ url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" },
{ url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" },
{ url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" },
{ url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" },
{ url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" },
{ url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" },
{ url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" },
{ url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" },
{ url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" },
{ url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" },
{ url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" },
{ url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" },
{ url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" },
{ url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" },
{ url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" },
{ url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" },
{ url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" },
{ url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" },
{ url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" },
{ url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" },
{ url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" },
{ url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" },
{ url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" },
{ url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" },
{ url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" },
{ url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" },
{ url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" },
{ url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" },
{ url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" },
{ url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" },
{ url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" },
{ url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" },
{ url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" },
{ url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" },
{ url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" },
{ url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" },
{ url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" },
{ url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" },
{ url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" },
{ url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" },
{ url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mypy"
version = "1.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" },
{ url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" },
{ url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" },
{ url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" },
{ url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" },
{ url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" },
{ url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" },
{ url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" },
{ url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" },
{ url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" },
{ url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" },
{ url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" },
{ url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" },
{ url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" },
{ url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" },
{ url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" },
{ url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" },
]
[[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 = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]]
name = "numpy"
version = "2.4.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" },
{ url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" },
{ url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" },
{ url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" },
{ url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" },
{ url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" },
{ url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" },
{ url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" },
{ url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" },
{ url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" },
{ url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" },
{ url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" },
{ url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" },
{ url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" },
{ url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" },
{ url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" },
{ url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" },
{ url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" },
{ url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" },
{ url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" },
{ url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" },
{ url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" },
{ url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" },
{ url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" },
{ url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" },
{ url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" },
{ url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
{ url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
{ url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
{ url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
{ url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
{ url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
{ url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
{ url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
{ url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
{ url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
{ url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
{ url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
{ url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
{ url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
{ url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
{ url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
{ url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pathspec"
version = "1.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" },
]
[[package]]
name = "pillow"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
]
[[package]]
name = "pre-commit"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
]
[[package]]
name = "psycopg2-binary"
version = "2.9.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" },
{ url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
{ url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
{ url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
{ url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
{ url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
{ url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
{ url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "pytz"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "redis"
version = "7.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/32/6fac13a11e73e1bc67a2ae821a72bfe4c2d8c4c48f0267e4a952be0f1bae/redis-7.2.0.tar.gz", hash = "sha256:4dd5bf4bd4ae80510267f14185a15cba2a38666b941aff68cccf0256b51c1f26", size = 4901247, upload-time = "2026-02-16T17:16:22.797Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/cf/f6180b67f99688d83e15c84c5beda831d1d341e95872d224f87ccafafe61/redis-7.2.0-py3-none-any.whl", hash = "sha256:01f591f8598e483f1842d429e8ae3a820804566f1c73dca1b80e23af9fba0497", size = 394898, upload-time = "2026-02-16T17:16:20.693Z" },
]
[[package]]
name = "rq"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "croniter" },
{ name = "redis" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/6f/a2848f5ba0ca7f1f879c7ad44a2e7b06b98197a7da39be39eda775807f33/rq-2.6.1.tar.gz", hash = "sha256:db5c0d125ac9dbd4438f9a5225ea3e64050542b416fd791d424e2ab5b2853289", size = 675386, upload-time = "2025-11-22T06:45:16.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/cc/919ccbf0c9b4f8b0f68c3f53e6d8e1e94af4d74cee4e6d3cb2e81f7d0da9/rq-2.6.1-py3-none-any.whl", hash = "sha256:5cc88d3bb5263a407fb2ba2dc6fe8dc710dae94b6f74396cdfe1b32beded9408", size = 112578, upload-time = "2025-11-22T06:45:13.529Z" },
]
[[package]]
name = "ruff"
version = "0.15.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/04/eab13a954e763b0606f460443fcbf6bb5a0faf06890ea3754ff16523dce5/ruff-0.15.2.tar.gz", hash = "sha256:14b965afee0969e68bb871eba625343b8673375f457af4abe98553e8bbb98342", size = 4558148, upload-time = "2026-02-19T22:32:20.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/70/3a4dc6d09b13cb3e695f28307e5d889b2e1a66b7af9c5e257e796695b0e6/ruff-0.15.2-py3-none-linux_armv6l.whl", hash = "sha256:120691a6fdae2f16d65435648160f5b81a9625288f75544dc40637436b5d3c0d", size = 10430565, upload-time = "2026-02-19T22:32:41.824Z" },
{ url = "https://files.pythonhosted.org/packages/71/0b/bb8457b56185ece1305c666dc895832946d24055be90692381c31d57466d/ruff-0.15.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a89056d831256099658b6bba4037ac6dd06f49d194199215befe2bb10457ea5e", size = 10820354, upload-time = "2026-02-19T22:32:07.366Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c1/e0532d7f9c9e0b14c46f61b14afd563298b8b83f337b6789ddd987e46121/ruff-0.15.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e36dee3a64be0ebd23c86ffa3aa3fd3ac9a712ff295e192243f814a830b6bd87", size = 10170767, upload-time = "2026-02-19T22:32:13.188Z" },
{ url = "https://files.pythonhosted.org/packages/47/e8/da1aa341d3af017a21c7a62fb5ec31d4e7ad0a93ab80e3a508316efbcb23/ruff-0.15.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9fb47b6d9764677f8c0a193c0943ce9a05d6763523f132325af8a858eadc2b9", size = 10529591, upload-time = "2026-02-19T22:32:02.547Z" },
{ url = "https://files.pythonhosted.org/packages/93/74/184fbf38e9f3510231fbc5e437e808f0b48c42d1df9434b208821efcd8d6/ruff-0.15.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f376990f9d0d6442ea9014b19621d8f2aaf2b8e39fdbfc79220b7f0c596c9b80", size = 10260771, upload-time = "2026-02-19T22:32:36.938Z" },
{ url = "https://files.pythonhosted.org/packages/05/ac/605c20b8e059a0bc4b42360414baa4892ff278cec1c91fff4be0dceedefd/ruff-0.15.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2dcc987551952d73cbf5c88d9fdee815618d497e4df86cd4c4824cc59d5dd75f", size = 11045791, upload-time = "2026-02-19T22:32:31.642Z" },
{ url = "https://files.pythonhosted.org/packages/fd/52/db6e419908f45a894924d410ac77d64bdd98ff86901d833364251bd08e22/ruff-0.15.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:42a47fd785cbe8c01b9ff45031af875d101b040ad8f4de7bbb716487c74c9a77", size = 11879271, upload-time = "2026-02-19T22:32:29.305Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d8/7992b18f2008bdc9231d0f10b16df7dda964dbf639e2b8b4c1b4e91b83af/ruff-0.15.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbe9f49354866e575b4c6943856989f966421870e85cd2ac94dccb0a9dcb2fea", size = 11303707, upload-time = "2026-02-19T22:32:22.492Z" },
{ url = "https://files.pythonhosted.org/packages/d7/02/849b46184bcfdd4b64cde61752cc9a146c54759ed036edd11857e9b8443b/ruff-0.15.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a672c82b5f9887576087d97be5ce439f04bbaf548ee987b92d3a7dede41d3a", size = 11149151, upload-time = "2026-02-19T22:32:44.234Z" },
{ url = "https://files.pythonhosted.org/packages/70/04/f5284e388bab60d1d3b99614a5a9aeb03e0f333847e2429bebd2aaa1feec/ruff-0.15.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ecc64f46f7019e2bcc3cdc05d4a7da958b629a5ab7033195e11a438403d956", size = 11091132, upload-time = "2026-02-19T22:32:24.691Z" },
{ url = "https://files.pythonhosted.org/packages/fa/ae/88d844a21110e14d92cf73d57363fab59b727ebeabe78009b9ccb23500af/ruff-0.15.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8dcf243b15b561c655c1ef2f2b0050e5d50db37fe90115507f6ff37d865dc8b4", size = 10504717, upload-time = "2026-02-19T22:32:26.75Z" },
{ url = "https://files.pythonhosted.org/packages/64/27/867076a6ada7f2b9c8292884ab44d08fd2ba71bd2b5364d4136f3cd537e1/ruff-0.15.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dab6941c862c05739774677c6273166d2510d254dac0695c0e3f5efa1b5585de", size = 10263122, upload-time = "2026-02-19T22:32:10.036Z" },
{ url = "https://files.pythonhosted.org/packages/e7/ef/faf9321d550f8ebf0c6373696e70d1758e20ccdc3951ad7af00c0956be7c/ruff-0.15.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b9164f57fc36058e9a6806eb92af185b0697c9fe4c7c52caa431c6554521e5c", size = 10735295, upload-time = "2026-02-19T22:32:39.227Z" },
{ url = "https://files.pythonhosted.org/packages/2f/55/e8089fec62e050ba84d71b70e7834b97709ca9b7aba10c1a0b196e493f97/ruff-0.15.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:80d24fcae24d42659db7e335b9e1531697a7102c19185b8dc4a028b952865fd8", size = 11241641, upload-time = "2026-02-19T22:32:34.617Z" },
{ url = "https://files.pythonhosted.org/packages/23/01/1c30526460f4d23222d0fabd5888868262fd0e2b71a00570ca26483cd993/ruff-0.15.2-py3-none-win32.whl", hash = "sha256:fd5ff9e5f519a7e1bd99cbe8daa324010a74f5e2ebc97c6242c08f26f3714f6f", size = 10507885, upload-time = "2026-02-19T22:32:15.635Z" },
{ url = "https://files.pythonhosted.org/packages/5c/10/3d18e3bbdf8fc50bbb4ac3cc45970aa5a9753c5cb51bf9ed9a3cd8b79fa3/ruff-0.15.2-py3-none-win_amd64.whl", hash = "sha256:d20014e3dfa400f3ff84830dfb5755ece2de45ab62ecea4af6b7262d0fb4f7c5", size = 11623725, upload-time = "2026-02-19T22:32:04.947Z" },
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.53.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" },
]
[package.optional-dependencies]
flask = [
{ name = "blinker" },
{ name = "flask" },
{ name = "markupsafe" },
]
rq = [
{ name = "rq" },
]
sqlalchemy = [
{ name = "sqlalchemy" },
]
[[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 = "sqlalchemy"
version = "2.0.46"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/35/d16bfa235c8b7caba3730bba43e20b1e376d2224f407c178fbf59559f23e/sqlalchemy-2.0.46-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a9a72b0da8387f15d5810f1facca8f879de9b85af8c645138cba61ea147968c", size = 2153405, upload-time = "2026-01-21T19:05:54.143Z" },
{ url = "https://files.pythonhosted.org/packages/06/6c/3192e24486749862f495ddc6584ed730c0c994a67550ec395d872a2ad650/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2347c3f0efc4de367ba00218e0ae5c4ba2306e47216ef80d6e31761ac97cb0b9", size = 3334702, upload-time = "2026-01-21T18:46:45.384Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a2/b9f33c8d68a3747d972a0bb758c6b63691f8fb8a49014bc3379ba15d4274/sqlalchemy-2.0.46-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9094c8b3197db12aa6f05c51c05daaad0a92b8c9af5388569847b03b1007fb1b", size = 3347664, upload-time = "2026-01-21T18:40:09.979Z" },
{ url = "https://files.pythonhosted.org/packages/aa/d2/3e59e2a91eaec9db7e8dc6b37b91489b5caeb054f670f32c95bcba98940f/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37fee2164cf21417478b6a906adc1a91d69ae9aba8f9533e67ce882f4bb1de53", size = 3277372, upload-time = "2026-01-21T18:46:47.168Z" },
{ url = "https://files.pythonhosted.org/packages/dd/dd/67bc2e368b524e2192c3927b423798deda72c003e73a1e94c21e74b20a85/sqlalchemy-2.0.46-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1e14b2f6965a685c7128bd315e27387205429c2e339eeec55cb75ca4ab0ea2e", size = 3312425, upload-time = "2026-01-21T18:40:11.548Z" },
{ url = "https://files.pythonhosted.org/packages/43/82/0ecd68e172bfe62247e96cb47867c2d68752566811a4e8c9d8f6e7c38a65/sqlalchemy-2.0.46-cp312-cp312-win32.whl", hash = "sha256:412f26bb4ba942d52016edc8d12fb15d91d3cd46b0047ba46e424213ad407bcb", size = 2113155, upload-time = "2026-01-21T18:42:49.748Z" },
{ url = "https://files.pythonhosted.org/packages/bc/2a/2821a45742073fc0331dc132552b30de68ba9563230853437cac54b2b53e/sqlalchemy-2.0.46-cp312-cp312-win_amd64.whl", hash = "sha256:ea3cd46b6713a10216323cda3333514944e510aa691c945334713fca6b5279ff", size = 2140078, upload-time = "2026-01-21T18:42:51.197Z" },
{ url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" },
{ url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" },
{ url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" },
{ url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" },
{ url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" },
{ url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" },
{ url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" },
{ url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" },
{ url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" },
{ url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" },
{ url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f8/5ecdfc73383ec496de038ed1614de9e740a82db9ad67e6e4514ebc0708a3/sqlalchemy-2.0.46-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:56bdd261bfd0895452006d5316cbf35739c53b9bb71a170a331fa0ea560b2ada", size = 2152079, upload-time = "2026-01-21T19:05:58.477Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bf/eba3036be7663ce4d9c050bc3d63794dc29fbe01691f2bf5ccb64e048d20/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33e462154edb9493f6c3ad2125931e273bbd0be8ae53f3ecd1c161ea9a1dd366", size = 3272216, upload-time = "2026-01-21T18:46:52.634Z" },
{ url = "https://files.pythonhosted.org/packages/05/45/1256fb597bb83b58a01ddb600c59fe6fdf0e5afe333f0456ed75c0f8d7bd/sqlalchemy-2.0.46-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcdce05f056622a632f1d44bb47dbdb677f58cad393612280406ce37530eb6d", size = 3277208, upload-time = "2026-01-21T18:40:16.38Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a0/2053b39e4e63b5d7ceb3372cface0859a067c1ddbd575ea7e9985716f771/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e84b09a9b0f19accedcbeff5c2caf36e0dd537341a33aad8d680336152dc34e", size = 3221994, upload-time = "2026-01-21T18:46:54.622Z" },
{ url = "https://files.pythonhosted.org/packages/1e/87/97713497d9502553c68f105a1cb62786ba1ee91dea3852ae4067ed956a50/sqlalchemy-2.0.46-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4f52f7291a92381e9b4de9050b0a65ce5d6a763333406861e33906b8aa4906bf", size = 3243990, upload-time = "2026-01-21T18:40:18.253Z" },
{ url = "https://files.pythonhosted.org/packages/a8/87/5d1b23548f420ff823c236f8bea36b1a997250fd2f892e44a3838ca424f4/sqlalchemy-2.0.46-cp314-cp314-win32.whl", hash = "sha256:70ed2830b169a9960193f4d4322d22be5c0925357d82cbf485b3369893350908", size = 2114215, upload-time = "2026-01-21T18:42:55.232Z" },
{ url = "https://files.pythonhosted.org/packages/3a/20/555f39cbcf0c10cf452988b6a93c2a12495035f68b3dbd1a408531049d31/sqlalchemy-2.0.46-cp314-cp314-win_amd64.whl", hash = "sha256:3c32e993bc57be6d177f7d5d31edb93f30726d798ad86ff9066d75d9bf2e0b6b", size = 2139867, upload-time = "2026-01-21T18:42:56.474Z" },
{ url = "https://files.pythonhosted.org/packages/3e/f0/f96c8057c982d9d8a7a68f45d69c674bc6f78cad401099692fe16521640a/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dafb537740eef640c4d6a7c254611dca2df87eaf6d14d6a5fca9d1f4c3fc0fa", size = 3561202, upload-time = "2026-01-21T18:33:10.337Z" },
{ url = "https://files.pythonhosted.org/packages/d7/53/3b37dda0a5b137f21ef608d8dfc77b08477bab0fe2ac9d3e0a66eaeab6fc/sqlalchemy-2.0.46-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42a1643dc5427b69aca967dae540a90b0fbf57eaf248f13a90ea5930e0966863", size = 3526296, upload-time = "2026-01-21T18:45:12.657Z" },
{ url = "https://files.pythonhosted.org/packages/33/75/f28622ba6dde79cd545055ea7bd4062dc934e0621f7b3be2891f8563f8de/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ff33c6e6ad006bbc0f34f5faf941cfc62c45841c64c0a058ac38c799f15b5ede", size = 3470008, upload-time = "2026-01-21T18:33:11.725Z" },
{ url = "https://files.pythonhosted.org/packages/a9/42/4afecbbc38d5e99b18acef446453c76eec6fbd03db0a457a12a056836e22/sqlalchemy-2.0.46-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82ec52100ec1e6ec671563bbd02d7c7c8d0b9e71a0723c72f22ecf52d1755330", size = 3476137, upload-time = "2026-01-21T18:45:15.001Z" },
{ url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" },
]
[[package]]
name = "sqlalchemy-stubs"
version = "0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/60/db082788267740b17eac2c00666bbea1c8c5a94b569e8b1ea76b0cf42d57/sqlalchemy-stubs-0.4.tar.gz", hash = "sha256:c665d6dd4482ef642f01027fa06c3d5e91befabb219dc71fc2a09e7d7695f7ae", size = 70682, upload-time = "2021-01-12T14:02:04.438Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/ae/cb215ab25b76228bc90c90444b87e323ffba58c212321a53d5bc92903098/sqlalchemy_stubs-0.4-py3-none-any.whl", hash = "sha256:5eec7aa110adf9b957b631799a72fef396b23ff99fe296df726645d01e312aa5", size = 116067, upload-time = "2021-01-12T14:02:02.723Z" },
]
[[package]]
name = "ty"
version = "0.0.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/15/9682700d8d60fdca7afa4febc83a2354b29cdcd56e66e19c92b521db3b39/ty-0.0.18.tar.gz", hash = "sha256:04ab7c3db5dcbcdac6ce62e48940d3a0124f377c05499d3f3e004e264ae94b83", size = 5214774, upload-time = "2026-02-20T21:51:31.173Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/d8/920460d4c22ea68fcdeb0b2fb53ea2aeb9c6d7875bde9278d84f2ac767b6/ty-0.0.18-py3-none-linux_armv6l.whl", hash = "sha256:4e5e91b0a79857316ef893c5068afc4b9872f9d257627d9bc8ac4d2715750d88", size = 10280825, upload-time = "2026-02-20T21:51:25.03Z" },
{ url = "https://files.pythonhosted.org/packages/83/56/62587de582d3d20d78fcdddd0594a73822ac5a399a12ef512085eb7a4de6/ty-0.0.18-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee0e578b3f8416e2d5416da9553b78fd33857868aa1384cb7fefeceee5ff102d", size = 10118324, upload-time = "2026-02-20T21:51:22.27Z" },
{ url = "https://files.pythonhosted.org/packages/2f/2d/dbdace8d432a0755a7417f659bfd5b8a4261938ecbdfd7b42f4c454f5aa9/ty-0.0.18-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3f7a0487d36b939546a91d141f7fc3dbea32fab4982f618d5b04dc9d5b6da21e", size = 9605861, upload-time = "2026-02-20T21:51:16.066Z" },
{ url = "https://files.pythonhosted.org/packages/6b/d9/de11c0280f778d5fc571393aada7fe9b8bc1dd6a738f2e2c45702b8b3150/ty-0.0.18-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5e2fa8d45f57ca487a470e4bf66319c09b561150e98ae2a6b1a97ef04c1a4eb", size = 10092701, upload-time = "2026-02-20T21:51:26.862Z" },
{ url = "https://files.pythonhosted.org/packages/0f/94/068d4d591d791041732171e7b63c37a54494b2e7d28e88d2167eaa9ad875/ty-0.0.18-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d75652e9e937f7044b1aca16091193e7ef11dac1c7ec952b7fb8292b7ba1f5f2", size = 10109203, upload-time = "2026-02-20T21:51:11.59Z" },
{ url = "https://files.pythonhosted.org/packages/34/e4/526a4aa56dc0ca2569aaa16880a1ab105c3b416dd70e87e25a05688999f3/ty-0.0.18-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:563c868edceb8f6ddd5e91113c17d3676b028f0ed380bdb3829b06d9beb90e58", size = 10614200, upload-time = "2026-02-20T21:51:20.298Z" },
{ url = "https://files.pythonhosted.org/packages/fd/3d/b68ab20a34122a395880922587fbfc3adf090d22e0fb546d4d20fe8c2621/ty-0.0.18-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:502e2a1f948bec563a0454fc25b074bf5cf041744adba8794d024277e151d3b0", size = 11153232, upload-time = "2026-02-20T21:51:14.121Z" },
{ url = "https://files.pythonhosted.org/packages/68/ea/678243c042343fcda7e6af36036c18676c355878dcdcd517639586d2cf9e/ty-0.0.18-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc881dea97021a3aa29134a476937fd8054775c4177d01b94db27fcfb7aab65b", size = 10832934, upload-time = "2026-02-20T21:51:32.92Z" },
{ url = "https://files.pythonhosted.org/packages/d8/bd/7f8d647cef8b7b346c0163230a37e903c7461c7248574840b977045c77df/ty-0.0.18-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:421fcc3bc64cab56f48edb863c7c1c43649ec4d78ff71a1acb5366ad723b6021", size = 10700888, upload-time = "2026-02-20T21:51:09.673Z" },
{ url = "https://files.pythonhosted.org/packages/6e/06/cb3620dc48c5d335ba7876edfef636b2f4498eff4a262ff90033b9e88408/ty-0.0.18-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0fe5038a7136a0e638a2fb1ad06e3d3c4045314c6ba165c9c303b9aeb4623d6c", size = 10078965, upload-time = "2026-02-20T21:51:07.678Z" },
{ url = "https://files.pythonhosted.org/packages/60/27/c77a5a84533fa3b685d592de7b4b108eb1f38851c40fac4e79cc56ec7350/ty-0.0.18-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d123600a52372677613a719bbb780adeb9b68f47fb5f25acb09171de390e0035", size = 10134659, upload-time = "2026-02-20T21:51:18.311Z" },
{ url = "https://files.pythonhosted.org/packages/43/6e/60af6b88c73469e628ba5253a296da6984e0aa746206f3034c31f1a04ed1/ty-0.0.18-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bb4bc11d32a1bf96a829bf6b9696545a30a196ac77bbc07cc8d3dfee35e03723", size = 10297494, upload-time = "2026-02-20T21:51:39.631Z" },
{ url = "https://files.pythonhosted.org/packages/33/90/612dc0b68224c723faed6adac2bd3f930a750685db76dfe17e6b9e534a83/ty-0.0.18-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dda2efbf374ba4cd704053d04e32f2f784e85c2ddc2400006b0f96f5f7e4b667", size = 10791944, upload-time = "2026-02-20T21:51:37.13Z" },
{ url = "https://files.pythonhosted.org/packages/0d/da/f4ada0fd08a9e4138fe3fd2bcd3797753593f423f19b1634a814b9b2a401/ty-0.0.18-py3-none-win32.whl", hash = "sha256:c5768607c94977dacddc2f459ace6a11a408a0f57888dd59abb62d28d4fee4f7", size = 9677964, upload-time = "2026-02-20T21:51:42.039Z" },
{ url = "https://files.pythonhosted.org/packages/5e/fa/090ed9746e5c59fc26d8f5f96dc8441825171f1f47752f1778dad690b08b/ty-0.0.18-py3-none-win_amd64.whl", hash = "sha256:b78d0fa1103d36fc2fce92f2092adace52a74654ab7884d54cdaec8eb5016a4d", size = 10636576, upload-time = "2026-02-20T21:51:29.159Z" },
{ url = "https://files.pythonhosted.org/packages/92/4f/5dd60904c8105cda4d0be34d3a446c180933c76b84ae0742e58f02133713/ty-0.0.18-py3-none-win_arm64.whl", hash = "sha256:01770c3c82137c6b216aa3251478f0b197e181054ee92243772de553d3586398", size = 10095449, upload-time = "2026-02-20T21:51:34.914Z" },
]
[[package]]
name = "types-flask-sqlalchemy"
version = "2.5.9.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/41/e805d6f7730c73aea3ec5bbe744034d36b707cdcd0446f8d221d790b9bcd/types-Flask-SQLAlchemy-2.5.9.4.tar.gz", hash = "sha256:69b4c9880d2431b49c60dde09ce38696d7353bbef255a3a1b50a2f98b2002109", size = 4062, upload-time = "2023-05-10T15:23:29.477Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/15/725badd897a82d0794b07b0d976d1e0621336b4414bfb8ffd118463c3b2c/types_Flask_SQLAlchemy-2.5.9.4-py3-none-any.whl", hash = "sha256:16c3c52d2f44703dec639a71b04c0c909da55329b7bfe3a04adaee945a77477a", size = 4160, upload-time = "2023-05-10T15:23:28.43Z" },
]
[[package]]
name = "types-sqlalchemy"
version = "1.4.53.38"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/ce/0b7fa93dbc49b328807dedef8006223129e5a9543c74727dd7658eca5a9d/types-SQLAlchemy-1.4.53.38.tar.gz", hash = "sha256:5bb7463537e04e1aa5a3557eb725930df99226dcfd3c9bf93008025bfe5c169e", size = 118888, upload-time = "2023-05-01T15:15:28.572Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/4f/033a839a35ecbc5f71c0d74655d1237e909651aaebe7ceb825ac45cfdb4b/types_SQLAlchemy-1.4.53.38-py3-none-any.whl", hash = "sha256:7e60e74f823931cc9a9e8adb0a4c05e5533e6708b8a266807893a739faf4eaaa", size = 187916, upload-time = "2023-05-01T15:15:26.02Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "virtualenv"
version = "20.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d2/03/a94d404ca09a89a7301a7008467aed525d4cdeb9186d262154dd23208709/virtualenv-20.38.0.tar.gz", hash = "sha256:94f39b1abaea5185bf7ea5a46702b56f1d0c9aa2f41a6c2b8b0af4ddc74c10a7", size = 5864558, upload-time = "2026-02-19T07:48:02.385Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/394801755d4c8684b655d35c665aea7836ec68320304f62ab3c94395b442/virtualenv-20.38.0-py3-none-any.whl", hash = "sha256:d6e78e5889de3a4742df2d3d44e779366325a90cf356f15621fddace82431794", size = 5837778, upload-time = "2026-02-19T07:47:59.778Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
]