diff --git a/calm-calatheas/.devcontainer/devcontainer.json b/calm-calatheas/.devcontainer/devcontainer.json new file mode 100644 index 00000000..66071997 --- /dev/null +++ b/calm-calatheas/.devcontainer/devcontainer.json @@ -0,0 +1,93 @@ +{ + "customizations": { + "vscode": { + "extensions": [ + "bierner.markdown-mermaid", + "charliermarsh.ruff", + "DavidAnson.vscode-markdownlint", + "esbenp.prettier-vscode", + "GitHub.vscode-pull-request-github", + "tamasfe.even-better-toml", + "-ms-python.autopep8" + ], + "settings": { + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "[toml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[yaml]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "files.exclude": { + "**/.pytest_cache": true, + "**/.ruff_cache": true, + "**/__pycache__": true, + ".venv": true, + "node_modules": true, + "site": true + }, + "files.insertFinalNewline": true, + "files.watcherExclude": { + "**/.pytest_cache": true, + "**/.ruff_cache": true, + "**/__pycache__": true, + "**/dist": true, + ".git/objects/**": true, + ".git/subtree-cache/**": true, + ".venv": true, + "node_modules": true, + "site": true + }, + "python.analysis.typeCheckingMode": "standard", + "python.defaultInterpreterPath": "${containerWorkspaceFolder}/.venv/bin/python", + "python.testing.pytestEnabled": true, + "python.testing.unittestEnabled": false + } + } + }, + "features": { + "ghcr.io/devcontainers-extra/features/apt-packages": { + "packages": ["gnupg2", "graphviz"] + }, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/node:1": { + "version": "lts" + }, + "ghcr.io/devcontainers/features/python:1": { + "toolsToInstall": ["uv"], + "version": "3.13" + } + }, + "forwardPorts": [8000, 9000], + "image": "mcr.microsoft.com/devcontainers/base:noble", + "name": "Calm Calatheas 🪴", + "onCreateCommand": { + "npm": "bash .devcontainer/npm.sh", + "uv": "bash .devcontainer/uv.sh" + }, + "portsAttributes": { + "8000": { + "label": "Development Server", + "onAutoForward": "notify" + }, + "9000": { + "label": "Documentation Server", + "onAutoForward": "notify" + } + }, + "postCreateCommand": { + "playwright": "bash .devcontainer/playwright.sh", + "pre-commit": "bash .devcontainer/pre-commit.sh" + }, + "runArgs": ["--gpus", "all"] +} diff --git a/calm-calatheas/.devcontainer/npm.sh b/calm-calatheas/.devcontainer/npm.sh new file mode 100755 index 00000000..7ffc1b66 --- /dev/null +++ b/calm-calatheas/.devcontainer/npm.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# Install or update dependencies +npm install diff --git a/calm-calatheas/.devcontainer/playwright.sh b/calm-calatheas/.devcontainer/playwright.sh new file mode 100755 index 00000000..0aa5ad7a --- /dev/null +++ b/calm-calatheas/.devcontainer/playwright.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +# Install playwright dependencies +uv run playwright install --with-deps diff --git a/calm-calatheas/.devcontainer/pre-commit.sh b/calm-calatheas/.devcontainer/pre-commit.sh new file mode 100755 index 00000000..cc98e504 --- /dev/null +++ b/calm-calatheas/.devcontainer/pre-commit.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Mark the current directory as safe for Git operations +git config --global --add safe.directory $PWD + +# Install pre-commit hooks using uv +uv run pre-commit install diff --git a/calm-calatheas/.devcontainer/uv.sh b/calm-calatheas/.devcontainer/uv.sh new file mode 100755 index 00000000..e24759bd --- /dev/null +++ b/calm-calatheas/.devcontainer/uv.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +# Install Python dependencies using uv +uv venv --allow-existing +uv sync diff --git a/calm-calatheas/.envrc b/calm-calatheas/.envrc new file mode 100644 index 00000000..894571bf --- /dev/null +++ b/calm-calatheas/.envrc @@ -0,0 +1,3 @@ +source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k=" + +use devenv diff --git a/calm-calatheas/.gitattributes b/calm-calatheas/.gitattributes new file mode 100644 index 00000000..6313b56c --- /dev/null +++ b/calm-calatheas/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/calm-calatheas/.github/workflows/lint.yaml b/calm-calatheas/.github/workflows/lint.yaml new file mode 100644 index 00000000..71d6e3ef --- /dev/null +++ b/calm-calatheas/.github/workflows/lint.yaml @@ -0,0 +1,57 @@ +name: Lint + +on: + push: + branches: + - main + pull_request: + +concurrency: lint-${{ github.sha }} + +jobs: + lint: + runs-on: ubuntu-latest + + env: + PYTHON_VERSION: "3.13" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install uv + run: python -m pip install uv + + - name: Cache the virtualenv + uses: actions/cache@v4 + with: + path: ./.venv + key: ${{ runner.os }}-venv-${{ hashFiles('**/uv.lock') }} + + - name: Install Python dependencies + run: uv venv --allow-existing && uv sync + + - name: Update GITHUB_PATH + run: echo "$(uv python find)" >> $GITHUB_PATH + + - name: Setup Node.js and dependencies + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + registry-url: "https://npm.pkg.github.com" + + - name: Install Node.js dependencies + run: npm install + shell: bash + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 + + - name: Build the documentation + run: uv run task build-docs diff --git a/calm-calatheas/.github/workflows/publish-docs.yaml b/calm-calatheas/.github/workflows/publish-docs.yaml new file mode 100644 index 00000000..6701fa28 --- /dev/null +++ b/calm-calatheas/.github/workflows/publish-docs.yaml @@ -0,0 +1,50 @@ +name: Publish Documentation + +concurrency: + group: "docs" + +on: + push: + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + + permissions: + contents: write + pages: write + + env: + PYTHON_VERSION: "3.13" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - run: git config --global user.email "calm-calatheas@github.com" + - run: git config --global user.name "Calm Calatheas" + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install uv + run: python -m pip install uv + + - name: Cache the virtualenv + uses: actions/cache@v4 + with: + path: ./.venv + key: ${{ runner.os }}-venv-${{ hashFiles('**/uv.lock') }} + + - name: Install Python dependencies + run: uv venv --allow-existing && uv sync + + - name: Publish documentation + run: uv run mkdocs gh-deploy + shell: bash diff --git a/calm-calatheas/.gitignore b/calm-calatheas/.gitignore new file mode 100644 index 00000000..4d8efb00 --- /dev/null +++ b/calm-calatheas/.gitignore @@ -0,0 +1,321 @@ +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# Playwright +test-results/ + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +# Devenv +.devenv* +devenv.local.nix + +# Direnv +.direnv diff --git a/calm-calatheas/.markdownlint.json b/calm-calatheas/.markdownlint.json new file mode 100644 index 00000000..76de1448 --- /dev/null +++ b/calm-calatheas/.markdownlint.json @@ -0,0 +1,16 @@ +{ + "MD013": { + "code_blocks": false, + "line_length": 119, + "tables": false + }, + "MD024": { + "siblings_only": true + }, + "MD030": false, + "MD033": { + "allowed_elements": ["div", "img", "figcaption", "figure", "source", "video"] + }, + "MD046": false, + "default": true +} diff --git a/calm-calatheas/.pre-commit-config.yaml b/calm-calatheas/.pre-commit-config.yaml new file mode 100644 index 00000000..518d9e4c --- /dev/null +++ b/calm-calatheas/.pre-commit-config.yaml @@ -0,0 +1,47 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-merge-conflict + name: Check for merge conflicts + - id: check-json + name: Check for JSON syntax errors + - id: check-toml + name: Check for TOML syntax errors + - id: check-yaml + name: Check for YAML syntax errors + args: [--unsafe] + - id: end-of-file-fixer + name: Ensure files end with a newline + - id: trailing-whitespace + name: Trim trailing whitespace + args: [--markdown-linebreak-ext=md] + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.1.0 + hooks: + - id: prettier + name: Format code with Prettier + additional_dependencies: + - prettier@3.6.2 + - prettier-plugin-sort-json@4.1.1 + - prettier-plugin-toml@2.0.6 + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.3 + hooks: + - id: ruff + name: Check for Python linting errors + args: [--fix] + - id: ruff-format + name: Format Python code + + - repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.403 + hooks: + - id: pyright + name: Check for Python type errors + entry: uv run pyright + language: system + "types_or": [python, pyi] + require_serial: true diff --git a/calm-calatheas/.prettierignore b/calm-calatheas/.prettierignore new file mode 100644 index 00000000..8cc9258d --- /dev/null +++ b/calm-calatheas/.prettierignore @@ -0,0 +1,3 @@ +# Add files here to ignore them from prettier formatting +/dist +/coverage diff --git a/calm-calatheas/.prettierrc b/calm-calatheas/.prettierrc new file mode 100644 index 00000000..a17940f3 --- /dev/null +++ b/calm-calatheas/.prettierrc @@ -0,0 +1,11 @@ +{ + "jsonRecursiveSort": true, + "plugins": ["prettier-plugin-sort-json", "prettier-plugin-toml"], + "printWidth": 119, + "proseWrap": "preserve", + "reorderKeys": true, + "semi": true, + "singleQuote": false, + "tabWidth": 4, + "useTabs": false +} diff --git a/calm-calatheas/Dockerfile b/calm-calatheas/Dockerfile new file mode 100644 index 00000000..94d6fb3f --- /dev/null +++ b/calm-calatheas/Dockerfile @@ -0,0 +1,56 @@ +# Use the official Python image as a base image for building the application +FROM python:3.13 AS build-image + +# Set the working directory +WORKDIR /build + +# Copy the wheel file into the container. +COPY --chown=root:root --chmod=0755 ./dist/*.whl . + +# Install package dependencies, then clean up. Use a cache mount to speed up the process. +RUN --mount=type=cache,mode=0755,target=/root/.cache/pip \ + pip install --target=./ ./*.whl && \ + rm ./*.whl + +# Use a slim version of the base Python image to reduce the final image size +FROM python:3.13-slim + +# Add image metadata +LABEL org.opencontainers.image.authors="Calm Calatheas" +LABEL org.opencontainers.image.description="This is the app built by the Calm Calatheas team for the Python Discord Code Jam 2025" +LABEL org.opencontainers.image.licenses=MIT +LABEL org.opencontainers.image.source=https://github.com/cj12-calm-calatheas/code-jam-12 +LABEL org.opencontainers.image.title="Calm Calatheas App" + +# Add a non-root user and group +RUN addgroup calm-calatheas && \ + adduser --ingroup calm-calatheas calm-calatheas + +# Install required system dependencies +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && \ + apt-get install --no-install-recommends --no-install-suggests -y curl + +# Set the working directory +WORKDIR /application + +# Copy in the static assets for the app +COPY --chown=root:root --chmod=0755 ./app ./app + +# Copy in the built dependencies +COPY --chown=root:root --chmod=0755 --from=build-image /build ./ + +# Switch to the non-root user +USER calm-calatheas + +# Set default values for the environment variables +ENV HOST=0.0.0.0 +ENV PORT=8000 + +# Configure a healthcheck to ensure the application is running +HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=6 \ + CMD ["sh", "-c", "curl --fail \"http://localhost:${PORT}/healthcheck\" || exit 1"] + +# Start the application +ENTRYPOINT [ "python", "-m", "calm_calatheas" ] diff --git a/calm-calatheas/LICENSE.txt b/calm-calatheas/LICENSE.txt new file mode 100644 index 00000000..a3c2eed9 --- /dev/null +++ b/calm-calatheas/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2025 Calm Calatheas 🪴 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/calm-calatheas/README.md b/calm-calatheas/README.md new file mode 100644 index 00000000..05822f50 --- /dev/null +++ b/calm-calatheas/README.md @@ -0,0 +1,37 @@ +# Pokedexter + +_Who's that Pokémon?_ + +Ever wondered if your cat could be a Pokémon? Curious about what’s hiding in your cupboard? **Pokedexter** is an AI-powered +Pokédex that helps you discover Pokémon wherever you are. Just snap a photo, and Pokedexter will identify the Pokémon—maybe +even ones you never expected! + +Open Pokedexter on your phone and start discovering Pokémon all around you! + +## Documentation + +For detailed documentation, please refer to our [GitHub Pages site](https://cj12-calm-calatheas.github.io/code-jam-12/). + +## Quick Start + +Get started quickly with the following resources: + +- [Introduction](https://cj12-calm-calatheas.github.io/code-jam-12/) +- [User Guide](https://cj12-calm-calatheas.github.io/code-jam-12/user-guide/) +- [Setup Guide](https://cj12-calm-calatheas.github.io/code-jam-12/setup-guide/) +- [Contributor Guide](https://cj12-calm-calatheas.github.io/code-jam-12/contributor-guide/) +- [Design Documentation](https://cj12-calm-calatheas.github.io/code-jam-12/design/) +- [Code Reference](https://cj12-calm-calatheas.github.io/code-jam-12/code/) + +## About the Team + +This project has been built by the Calm Calatheas team for the [Python Discord Code Jam 2025](https://pythondiscord.com/events/code-jams/12/). +Please feel free to reach out if you have any questions, or need a hand with anything! + +| | Name | Contributions | +| --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------ | +| [TFBlunt](https://github.com/thijsfranck) | [TFBlunt](https://github.com/thijsfranck) | Team Lead, Frontend, Description Generation, Docs | +| [leoluy](https://github.com/leolhuile) | [leoluy](https://github.com/leolhuile) | Description Generation, Model Selection, Frontend Mockup, Ideation | +| [zike01](https://github.com/Zike01) | [Zike01](https://github.com/Zike01) | Ideation | +| [Flonc](https://github.com/FloncDev) | [Flonc](https://github.com/FloncDev) | Initial Frontend Prototype | +| [esmaycat](https://github.com/esmaycat) | [esmaycat](https://github.com/esmaycat) | Object Detection, Transformers.js integration, Favourites Feature | diff --git a/calm-calatheas/app/assets/logo-128x128.png b/calm-calatheas/app/assets/logo-128x128.png new file mode 100644 index 00000000..51375e09 Binary files /dev/null and b/calm-calatheas/app/assets/logo-128x128.png differ diff --git a/calm-calatheas/app/assets/logo-16x16.png b/calm-calatheas/app/assets/logo-16x16.png new file mode 100644 index 00000000..c156031c Binary files /dev/null and b/calm-calatheas/app/assets/logo-16x16.png differ diff --git a/calm-calatheas/app/assets/logo-48x48.png b/calm-calatheas/app/assets/logo-48x48.png new file mode 100644 index 00000000..287c4ca3 Binary files /dev/null and b/calm-calatheas/app/assets/logo-48x48.png differ diff --git a/calm-calatheas/app/assets/pokemongb.ttf b/calm-calatheas/app/assets/pokemongb.ttf new file mode 100644 index 00000000..b5025f06 Binary files /dev/null and b/calm-calatheas/app/assets/pokemongb.ttf differ diff --git a/calm-calatheas/app/favicon.ico b/calm-calatheas/app/favicon.ico new file mode 100644 index 00000000..c156031c Binary files /dev/null and b/calm-calatheas/app/favicon.ico differ diff --git a/calm-calatheas/app/frontend/__init__.py b/calm-calatheas/app/frontend/__init__.py new file mode 100644 index 00000000..3fd20621 --- /dev/null +++ b/calm-calatheas/app/frontend/__init__.py @@ -0,0 +1,3 @@ +from .app import App + +__all__ = ["App"] diff --git a/calm-calatheas/app/frontend/app.py b/calm-calatheas/app/frontend/app.py new file mode 100644 index 00000000..c1aa0204 --- /dev/null +++ b/calm-calatheas/app/frontend/app.py @@ -0,0 +1,95 @@ +from typing import override + +import reactivex.operators as op +from js import Event, document +from pyodide.ffi.wrappers import add_event_listener + +from frontend.base import Component +from frontend.components import Footer, Header, LoadingCaptionModel, Pokemon +from frontend.services import pokemon + +TEMPLATE = """ +
+
+
+
+
+
+
+

Welcome to your Pokedex!

+

Take a picture or upload an image to discover the Pokemon inside.

+
+
+
+

Your Pokemon

+
+
+ +
+
+
+
+
+
+ +
+""" + + +class App(Component): + """The main application class.""" + + @override + def build(self) -> str: + return TEMPLATE + + @override + def pre_destroy(self) -> None: + self._footer.destroy() + self._header.destroy() + self._loading_caption_model.destroy() + + @override + def on_render(self) -> None: + self._footer = Footer(document.getElementById("app-footer")) + self._footer.render() + + self._header = Header(document.getElementById("app-header")) + self._header.render() + + self._notifications = document.getElementById("notifications") + self._loading_caption_model = LoadingCaptionModel(self._notifications) + + self._pokemon = Pokemon(document.getElementById("pokemon")) + self._pokemon.render() + + self._pokemon_refresh = document.getElementById("pokemon-refresh") + self._pokemon_refresh_icon = document.getElementById("pokemon-refresh-icon") + add_event_listener(self._pokemon_refresh, "click", self._on_pokemon_refresh) + + # Update the UI whenever the loading state changes + pokemon.is_refreshing.pipe( + op.take_until(self.destroyed), + ).subscribe(lambda is_refreshing: self._handle_pokemon_is_refreshing(is_refreshing=is_refreshing)) + + def _on_pokemon_refresh(self, event: Event) -> None: + """Refresh the list of Pokemon.""" + if event.currentTarget.hasAttribute("disabled"): # type: ignore[currentTarget is available] + return + + pokemon.refresh() + + def _handle_pokemon_is_refreshing(self, *, is_refreshing: bool) -> None: + """Spin the refresh icon while the Pokemon list is being refreshed.""" + if is_refreshing: + self._pokemon_refresh.setAttribute("disabled", "") + self._pokemon_refresh_icon.classList.add("fa-spin") + else: + self._pokemon_refresh.removeAttribute("disabled") + self._pokemon_refresh_icon.classList.remove("fa-spin") diff --git a/calm-calatheas/app/frontend/base/__init__.py b/calm-calatheas/app/frontend/base/__init__.py new file mode 100644 index 00000000..18b1cb04 --- /dev/null +++ b/calm-calatheas/app/frontend/base/__init__.py @@ -0,0 +1,4 @@ +from .component import Component +from .service import Service + +__all__ = ["Component", "Service"] diff --git a/calm-calatheas/app/frontend/base/component.py b/calm-calatheas/app/frontend/base/component.py new file mode 100644 index 00000000..33a7b96f --- /dev/null +++ b/calm-calatheas/app/frontend/base/component.py @@ -0,0 +1,65 @@ +from abc import ABC, abstractmethod +from typing import cast +from uuid import uuid4 + +from js import DOMParser +from pyodide.ffi import JsDomElement +from reactivex import Subject + + +class Component(ABC): + """A base class for all components.""" + + parser = DOMParser.new() # type: ignore[] + + def __init__(self, root: JsDomElement) -> None: + self.destroyed = Subject[None]() + self.element: JsDomElement | None = None + self.guid = uuid4() + self.root = root + + @abstractmethod + def build(self) -> str: + """Build the component's template and output it as an HTML string.""" + + def destroy(self) -> None: + """Destroy the component and clean up resources.""" + self.destroyed.on_next(None) + self.destroyed.dispose() + self.remove() + self.on_destroy() + + def on_destroy(self) -> None: + """Hook to perform actions after the component is destroyed.""" + return + + def on_render(self) -> None: + """Hook to perform actions after rendering the component.""" + return + + def pre_destroy(self) -> None: + """Hook to perform actions before the component is destroyed.""" + return + + def pre_render(self) -> None: + """Hook to perform actions before rendering the component.""" + return + + def remove(self) -> None: + """Remove the component's element from the DOM.""" + if self.element: + self.element.remove() # type: ignore[remove method is available] + + def render(self) -> None: + """Create a new DOM element for the component and append it to the root element.""" + self.pre_render() + + # Render the given template as an HTML document + template = self.build() + document = self.parser.parseFromString(template, "text/html") + + # Take the first child of the body and append it to the root element + self.element = cast("JsDomElement", document.body.firstChild) + self.root.appendChild(self.element) + + self.on_render() diff --git a/calm-calatheas/app/frontend/base/service.py b/calm-calatheas/app/frontend/base/service.py new file mode 100644 index 00000000..b234b3f3 --- /dev/null +++ b/calm-calatheas/app/frontend/base/service.py @@ -0,0 +1,18 @@ +from reactivex import Subject + + +class Service: + """Base class for all services.""" + + def __init__(self) -> None: + self.destroyed = Subject[None]() + + def destroy(self) -> None: + """Destroy the service.""" + self.destroyed.on_next(None) + self.destroyed.dispose() + self.on_destroy() + + def on_destroy(self) -> None: + """Hook to perform actions after the service is destroyed.""" + return diff --git a/calm-calatheas/app/frontend/components/__init__.py b/calm-calatheas/app/frontend/components/__init__.py new file mode 100644 index 00000000..13275351 --- /dev/null +++ b/calm-calatheas/app/frontend/components/__init__.py @@ -0,0 +1,9 @@ +from .description import Description +from .description_dropdown import DescriptionDropdown +from .footer import Footer +from .header import Header +from .loading_caption_model import LoadingCaptionModel +from .pokemon import Pokemon +from .theme import Theme + +__all__ = ["Description", "DescriptionDropdown", "Footer", "Header", "LoadingCaptionModel", "Pokemon", "Theme"] diff --git a/calm-calatheas/app/frontend/components/camera.py b/calm-calatheas/app/frontend/components/camera.py new file mode 100644 index 00000000..ae7e1a5e --- /dev/null +++ b/calm-calatheas/app/frontend/components/camera.py @@ -0,0 +1,135 @@ +from typing import TYPE_CHECKING, Optional, cast, override + +import reactivex.operators as op +from js import Blob, Event, MediaStream, document +from pyodide.ffi import JsDomElement, create_once_callable +from pyodide.ffi.wrappers import add_event_listener + +from frontend.base import Component +from frontend.services import Camera as CameraService +from frontend.services import reader + +if TYPE_CHECKING: + from js import JsVideoElement + + +TEMPLATE = """ + +""" + + +class Camera(Component): + """Component for displaying the camera feed.""" + + def __init__(self, root: JsDomElement) -> None: + super().__init__(root) + self._camera = CameraService() + + @override + def build(self) -> str: + return TEMPLATE + + @override + def on_destroy(self) -> None: + self._camera.destroy() + + @override + def on_render(self) -> None: + self._camera_capture = document.getElementById("camera-capture") + self._camera_container = document.getElementById("camera-container") + self._camera_close = document.getElementById("camera-close") + self._camera_stream = cast("JsVideoElement", document.getElementById("camera-stream")) + self._camera_switch = document.getElementById("camera-switch") + + add_event_listener(self._camera_capture, "click", self._handle_capture) + add_event_listener(self._camera_close, "click", self._handle_close) + add_event_listener(self._camera_switch, "click", self._handle_toggle_facing_mode) + + # Update the UI whenever the media stream is being acquired + self._camera.is_acquiring_media_stream.pipe( + op.take_until(self.destroyed), + ).subscribe(lambda status: self._handle_is_acquiring_media_stream(status=status)) + + # Update the UI whenever the media stream is available + self._camera.media_stream.pipe( + op.take_until(self.destroyed), + ).subscribe(self._handle_media_stream) + + self._camera.acquire_media_stream() + + def _handle_capture(self, event: Event) -> None: + """Capture a snapshot from the camera stream.""" + if event.currentTarget.hasAttribute("disabled"): # type: ignore[currentTarget is available] + return + + canvas = document.createElement("canvas") + canvas.width = self._camera_stream.videoWidth + canvas.height = self._camera_stream.videoHeight + + context = canvas.getContext("2d") + + context.drawImage( + self._camera_stream, + 0, + 0, + self._camera_stream.videoWidth, + self._camera_stream.videoHeight, + ) + + canvas.toBlob(create_once_callable(self._handle_capture_success), "image/png") + + def _handle_capture_success(self, blob: Blob) -> None: + """Send the captured image to the reader.""" + reader.read(blob) + self.destroy() + + def _handle_close(self, _: Event) -> None: + """Close the camera modal.""" + self.destroy() + + def _handle_is_acquiring_media_stream(self, *, status: bool) -> None: + """Set the spinner on the capture button.""" + if status: + self._camera_capture.classList.add("is-loading") + else: + self._camera_capture.classList.remove("is-loading") + + def _handle_media_stream(self, stream: Optional[MediaStream]) -> None: + """ + Set the camera stream source. + + If no source is given, disable the controls and show a loading indicator. + """ + self._camera_stream.srcObject = stream + + if not stream: + self._camera_capture.setAttribute("disabled", "") + self._camera_switch.setAttribute("disabled", "") + self._camera_container.classList.add("is-skeleton") + else: + self._camera_capture.removeAttribute("disabled") + self._camera_switch.removeAttribute("disabled") + self._camera_container.classList.remove("is-skeleton") + + def _handle_toggle_facing_mode(self, event: Event) -> None: + """Switch the preferred facing mode between user and environment.""" + if event.currentTarget.hasAttribute("disabled"): # type: ignore[currentTarget is available] + return + + self._camera.toggle_facing_mode() diff --git a/calm-calatheas/app/frontend/components/description.py b/calm-calatheas/app/frontend/components/description.py new file mode 100644 index 00000000..bcae56d0 --- /dev/null +++ b/calm-calatheas/app/frontend/components/description.py @@ -0,0 +1,173 @@ +from typing import override + +from js import document +from pyodide.ffi import JsDomElement + +from frontend.base import Component +from frontend.components.description_dropdown import DescriptionDropdown +from frontend.models import PokemonRecord + +LOADING_TEMPLATE = """ +
+
+
+

+
+
+
+

Name

+

Category

+
+ Type +
+
+
+
+ +
+
+
+
+
+
+ Ability +
+
+
+
+ Habitat +
+
+
+
+ Height +
+
+
+
+ Weight +
+
+
+
+""" + +TYPE_TEMPLATE = """ +{type_name} +""" + +TEMPLATE = """ +
+
+
+

+ {name} +

+
+
+
+

+ {name} + + + +

+

The {category} Pokemon

+
{types}
+
+
+
+ +
+
+
+

{flavor_text}

+
+
+
+
+ Ability + {ability} +
+
+
+
+ Habitat + {habitat} +
+
+
+
+ Height + {height} m +
+
+
+
+ Weight + {weight} kg +
+
+
+
+""" + + +class Description(Component): + """Test component to demonstrate the descriptions service.""" + + def __init__(self, root: JsDomElement, description: PokemonRecord | None) -> None: + super().__init__(root) + self._description = description + + @override + def build(self) -> str: + if not self._description: + return LOADING_TEMPLATE + + types = "\n".join( + TYPE_TEMPLATE.format(type_class=type_, type_name=type_.capitalize()) for type_ in self._description.types + ) + + return TEMPLATE.format( + guid=self.guid, + favourite_icon_class="" if self._description.favourite else "is-hidden", + image_url=self._description.img_url, + name=self._description.name, + category=self._description.category.capitalize(), + types=types, + flavor_text=self._description.flavor_text, + ability=self._description.ability.capitalize(), + habitat=self._description.habitat.capitalize(), + height=self._description.height, + weight=self._description.weight, + ) + + @override + def on_render(self) -> None: + if not self._description: + return + + self._description_dropdown = DescriptionDropdown( + document.getElementById(f"dropdown-{self.guid}"), + self._description, + ) + + self._description_dropdown.render() + + @override + def pre_destroy(self) -> None: + self._description_dropdown.destroy() diff --git a/calm-calatheas/app/frontend/components/description_dropdown.py b/calm-calatheas/app/frontend/components/description_dropdown.py new file mode 100644 index 00000000..2b5e199a --- /dev/null +++ b/calm-calatheas/app/frontend/components/description_dropdown.py @@ -0,0 +1,61 @@ +from typing import override +from uuid import uuid4 + +from js import Event, document +from pyodide.ffi import JsDomElement +from pyodide.ffi.wrappers import add_event_listener + +from frontend.base import Component +from frontend.models import PokemonRecord +from frontend.services import pokemon + +TEMPLATE = """ + +""" + + +class DescriptionDropdown(Component): + """Dropdown for Pokemon descriptions.""" + + def __init__(self, root: JsDomElement, description: PokemonRecord) -> None: + super().__init__(root) + self._description = description + self._favourite_guid = uuid4() + self._delete_guid = uuid4() + + @override + def build(self) -> str: + return TEMPLATE.format( + favourite_guid=self._favourite_guid, + favourite_text="Unfavourite" if self._description.favourite else "Favourite", + delete_guid=self._delete_guid, + ) + + @override + def on_render(self) -> None: + self._delete_button = document.getElementById(f"delete-{self._delete_guid}") + self._favourite_button = document.getElementById(f"favourite-{self._favourite_guid}") + + add_event_listener(self._delete_button, "click", self._on_delete_button_click) + add_event_listener(self._favourite_button, "click", self._on_favourite_button_click) + + def _on_delete_button_click(self, _: Event) -> None: + pokemon.delete(self._description.name) + + def _on_favourite_button_click(self, _: Event) -> None: + self._description.favourite = not self._description.favourite + pokemon.put(self._description) diff --git a/calm-calatheas/app/frontend/components/footer.py b/calm-calatheas/app/frontend/components/footer.py new file mode 100644 index 00000000..a4cf42ae --- /dev/null +++ b/calm-calatheas/app/frontend/components/footer.py @@ -0,0 +1,112 @@ +from typing import TYPE_CHECKING, cast, override + +import reactivex.operators as op +from js import Event, document +from pyodide.ffi import JsDomElement +from pyodide.ffi.wrappers import add_event_listener +from reactivex import combine_latest + +from frontend.base import Component +from frontend.services import caption, pokemon, reader + +from .camera import Camera + +if TYPE_CHECKING: + from js import JsButtonElement, JsFileInputElement + +TEMPLATE = """ + +""" + + +class Footer(Component): + """Footer for the application.""" + + def __init__(self, root: JsDomElement) -> None: + super().__init__(root) + self._overlay: Camera | None = None + + @override + def build(self) -> str: + return TEMPLATE + + @override + def pre_destroy(self) -> None: + if self._overlay: + self._overlay.destroy() + + @override + def on_render(self) -> None: + self._camera_button = cast("JsButtonElement", document.getElementById("camera-button")) + self._file_input = cast("JsFileInputElement", document.getElementById("file-input")) + self._upload_button = cast("JsButtonElement", document.getElementById("upload-button")) + + add_event_listener(self._camera_button, "click", self._on_camera_button_click) + add_event_listener(self._file_input, "change", self._on_file_input_change) + add_event_listener(self._upload_button, "click", self._on_upload_button_click) + + # Disable the controls while the model is loading or generating + combine_latest(caption.is_loading_model, pokemon.is_generating).pipe( + op.map(lambda is_loading: any(is_loading)), + op.take_until(self.destroyed), + ).subscribe(lambda is_loading: self._handle_is_loading(is_loading=is_loading)) + + def _handle_is_loading(self, *, is_loading: bool) -> None: + """Handle the loading state of the footer.""" + if is_loading: + self._camera_button.setAttribute("disabled", "") + self._file_input.setAttribute("disabled", "") + self._upload_button.setAttribute("disabled", "") + else: + self._camera_button.removeAttribute("disabled") + self._file_input.removeAttribute("disabled") + self._upload_button.removeAttribute("disabled") + + def _on_camera_button_click(self, event: Event) -> None: + """Open the camera modal.""" + if event.currentTarget.hasAttribute("disabled"): # type: ignore[currentTarget is available] + return + + self._overlay = Camera(self.root) + self._overlay.render() + + def _on_file_input_change(self, event: Event) -> None: + """Send the selected file to the reader.""" + if event.target.hasAttribute("disabled"): + return + + files = self._file_input.files + + if files.length: + reader.read(files.item(0)) + + def _on_upload_button_click(self, event: Event) -> None: + """Trigger the hidden file input.""" + if event.currentTarget.hasAttribute("disabled"): # type: ignore[currentTarget is available] + return + + self._file_input.click() diff --git a/calm-calatheas/app/frontend/components/header.py b/calm-calatheas/app/frontend/components/header.py new file mode 100644 index 00000000..acefa3d1 --- /dev/null +++ b/calm-calatheas/app/frontend/components/header.py @@ -0,0 +1,78 @@ +from typing import TYPE_CHECKING, cast, override + +from js import Event, document +from pyodide.ffi import JsDomElement +from pyodide.ffi.wrappers import add_event_listener + +from frontend.base import Component + +from .theme import Theme + +if TYPE_CHECKING: + from js import JsAnchorElement + +TEMPLATE = """ + +""" + + +class Header(Component): + """The main header for the application.""" + + def __init__(self, root: JsDomElement) -> None: + super().__init__(root) + self._expanded = False + + @override + def build(self) -> str: + return TEMPLATE + + @override + def pre_destroy(self) -> None: + self._theme_selector.destroy() + + @override + def on_render(self) -> None: + self._theme_selector = Theme(document.getElementById("navbar-end")) + self._theme_selector.render() + + self._main_navigation = document.getElementById("main-navigation") + self._navbar_burger = cast("JsAnchorElement", document.getElementById("navbar-burger")) + + add_event_listener(self._navbar_burger, "click", self._toggle_navbar) + + @property + def expanded(self) -> bool: + """Whether or not the navbar menu is expanded.""" + return self._expanded + + @expanded.setter + def expanded(self, value: bool) -> None: + self._expanded = value + + if value: + self._main_navigation.classList.add("is-active") + self._navbar_burger.classList.add("is-active") + else: + self._main_navigation.classList.remove("is-active") + self._navbar_burger.classList.remove("is-active") + + def _toggle_navbar(self, _: Event) -> None: + """Toggle the navbar menu.""" + self.expanded = not self.expanded diff --git a/calm-calatheas/app/frontend/components/loading_caption_model.py b/calm-calatheas/app/frontend/components/loading_caption_model.py new file mode 100644 index 00000000..c361edd4 --- /dev/null +++ b/calm-calatheas/app/frontend/components/loading_caption_model.py @@ -0,0 +1,40 @@ +from typing import override + +import reactivex.operators as op +from pyodide.ffi import JsDomElement + +from frontend.base import Component +from frontend.services import caption + +TEMPLATE = """ +
+

Loading the model for generating captions

+ +
+""" + + +class LoadingCaptionModel(Component): + """A component that shows a loading indicator while the caption model is being loaded.""" + + def __init__(self, root: JsDomElement) -> None: + super().__init__(root) + + # Update the UI whenever the loading state changes + caption.is_loading_model.pipe( + op.distinct_until_changed(), + op.take_until(self.destroyed), + ).subscribe( + lambda is_loading: self._handle_is_loading_update(is_loading=is_loading), + ) + + @override + def build(self) -> str: + return TEMPLATE + + def _handle_is_loading_update(self, *, is_loading: bool) -> None: + """Show the notification while the model is loading.""" + if is_loading: + self.render() + else: + self.remove() diff --git a/calm-calatheas/app/frontend/components/pokemon.py b/calm-calatheas/app/frontend/components/pokemon.py new file mode 100644 index 00000000..7ea309f4 --- /dev/null +++ b/calm-calatheas/app/frontend/components/pokemon.py @@ -0,0 +1,85 @@ +from typing import override + +import reactivex.operators as op +from js import document +from pyodide.ffi import JsDomElement +from reactivex import combine_latest + +from frontend.base import Component +from frontend.components import Description +from frontend.models import PokemonRecord +from frontend.services import pokemon + +EMPTY_PLACEHOLDER_TEMPLATE = """ +

Nothing to show here yet!

+""" + +TEMPLATE = """ +
+

Nothing to show here yet!

+
+""" + + +class Pokemon(Component): + """The list of Pokemon.""" + + def __init__(self, root: JsDomElement) -> None: + super().__init__(root) + self._current_pokemon = [] + + @override + def build(self) -> str: + return TEMPLATE + + @override + def on_render(self) -> None: + self._pokemon_grid = document.getElementById("pokemon-grid") + + # Update the UI whenever the list of pokemon or the loading state changes + combine_latest(pokemon.pokemon, pokemon.is_generating).pipe( + op.take_until(self.destroyed), + ).subscribe(lambda params: self._render_pokemon(params[0], is_generating=params[1])) + + def _render_pokemon(self, pokemon: list[PokemonRecord], *, is_generating: bool) -> None: + """Render the given list of Pokemon.""" + for component in self._current_pokemon: + component.destroy() + + while cell := self._pokemon_grid.firstChild: + self._pokemon_grid.removeChild(cell) + + self._current_pokemon = [] + + if not (pokemon or is_generating): + self._pokemon_grid.innerHTML = EMPTY_PLACEHOLDER_TEMPLATE # type: ignore[innerHTML is available] + return + + for item in pokemon: + cell = document.createElement("div") + cell.classList.add("cell") + + description = Description(cell, item) + + self._pokemon_grid.appendChild(cell) + self._current_pokemon.append(description) + + description.render() + + if is_generating: + self._render_generating_placeholder() + + def _render_generating_placeholder(self) -> None: + """Render a placeholder in the Pokemon grid while generating.""" + if placeholder := document.getElementById("pokemon-empty-placeholder"): + placeholder.remove() # type: ignore[remove is available] + + cell = document.createElement("div") + cell.classList.add("cell") + + # Create a description component with no data to show loading state + description = Description(cell, None) + description.render() + + self._pokemon_grid.prepend(cell) # type: ignore[prepend is available] + self._current_pokemon.append(description) diff --git a/calm-calatheas/app/frontend/components/theme.py b/calm-calatheas/app/frontend/components/theme.py new file mode 100644 index 00000000..a78b96cd --- /dev/null +++ b/calm-calatheas/app/frontend/components/theme.py @@ -0,0 +1,84 @@ +from typing import TYPE_CHECKING, cast, override + +import reactivex.operators as op +from js import Event, document +from pyodide.ffi.wrappers import add_event_listener + +from frontend.base import Component +from frontend.services import Theme_, theme + +if TYPE_CHECKING: + from js import JsAnchorElement + +TEMPLATE = """ + +""" + + +class Theme(Component): + """A component for selecting the theme.""" + + @override + def build(self) -> str: + return TEMPLATE + + @override + def on_destroy(self) -> None: + self._current_theme_listener.dispose() + + @override + def on_render(self) -> None: + self._select_theme_light = cast("JsAnchorElement", document.getElementById("select-theme-light")) + self._select_theme_dark = cast("JsAnchorElement", document.getElementById("select-theme-dark")) + self._select_theme_auto = cast("JsAnchorElement", document.getElementById("select-theme-auto")) + + add_event_listener(self._select_theme_light, "click", self._set_theme_light) + add_event_listener(self._select_theme_dark, "click", self._set_theme_dark) + add_event_listener(self._select_theme_auto, "click", self._set_theme_auto) + + # Update the UI whenever the current theme changes + self._current_theme_listener = theme.current.pipe( + op.take_until(self.destroyed), + ).subscribe(lambda theme: self._update_current_theme(theme)) + + def _set_theme_light(self, _: Event) -> None: + """Set the theme to light.""" + theme.current.on_next("light") + + def _set_theme_dark(self, _: Event) -> None: + """Set the theme to dark.""" + theme.current.on_next("dark") + + def _set_theme_auto(self, _: Event) -> None: + """Set the theme to auto.""" + theme.current.on_next(None) + + def _update_current_theme(self, theme: Theme_) -> None: + """Set the active state on the appropriate theme selector.""" + if theme == "light": + self._select_theme_light.classList.add("is-active") + self._select_theme_dark.classList.remove("is-active") + self._select_theme_auto.classList.remove("is-active") + elif theme == "dark": + self._select_theme_dark.classList.add("is-active") + self._select_theme_light.classList.remove("is-active") + self._select_theme_auto.classList.remove("is-active") + else: + self._select_theme_auto.classList.add("is-active") + self._select_theme_light.classList.remove("is-active") + self._select_theme_dark.classList.remove("is-active") diff --git a/calm-calatheas/app/frontend/models/__init__.py b/calm-calatheas/app/frontend/models/__init__.py new file mode 100644 index 00000000..bab02b48 --- /dev/null +++ b/calm-calatheas/app/frontend/models/__init__.py @@ -0,0 +1,3 @@ +from .pokemon_description import PokemonDescription, PokemonRecord, PokemonType + +__all__ = ["PokemonDescription", "PokemonRecord", "PokemonType"] diff --git a/calm-calatheas/app/frontend/models/pokemon_description.py b/calm-calatheas/app/frontend/models/pokemon_description.py new file mode 100644 index 00000000..13554d42 --- /dev/null +++ b/calm-calatheas/app/frontend/models/pokemon_description.py @@ -0,0 +1,48 @@ +from datetime import UTC, datetime +from enum import StrEnum, auto + +from pydantic import BaseModel, Field + + +class PokemonType(StrEnum): + """An enumeration of Pokemon types.""" + + BUG = auto() + DARK = auto() + DRAGON = auto() + ELECTRIC = auto() + FAIRY = auto() + FIGHTING = auto() + FIRE = auto() + FLYING = auto() + GHOST = auto() + GRASS = auto() + GROUND = auto() + ICE = auto() + NORMAL = auto() + POISON = auto() + PSYCHIC = auto() + ROCK = auto() + STEEL = auto() + WATER = auto() + + +class PokemonDescription(BaseModel): + """A description of a Pokemon, as generated by the API.""" + + ability: str = Field() + category: str = Field() + flavor_text: str = Field() + habitat: str = Field() + height: float = Field() + name: str = Field() + types: set[PokemonType] = Field() + weight: float = Field() + + +class PokemonRecord(PokemonDescription): + """A description of a Pokemon with an image and timestamp, as stored in the database.""" + + img_url: str = Field() + favourite: bool = Field(default=False) + timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) diff --git a/calm-calatheas/app/frontend/services/__init__.py b/calm-calatheas/app/frontend/services/__init__.py new file mode 100644 index 00000000..4410555a --- /dev/null +++ b/calm-calatheas/app/frontend/services/__init__.py @@ -0,0 +1,24 @@ +from .camera import Camera +from .caption import Caption, caption +from .database import Database, database +from .description import Description, description +from .pokemon import Pokemon, pokemon +from .reader import Reader, reader +from .theme import Theme, Theme_, theme + +__all__ = [ + "Camera", + "Caption", + "Database", + "Description", + "Pokemon", + "Reader", + "Theme", + "Theme_", + "caption", + "database", + "description", + "pokemon", + "reader", + "theme", +] diff --git a/calm-calatheas/app/frontend/services/camera.py b/calm-calatheas/app/frontend/services/camera.py new file mode 100644 index 00000000..723deca0 --- /dev/null +++ b/calm-calatheas/app/frontend/services/camera.py @@ -0,0 +1,96 @@ +from typing import Literal, Optional, cast, override + +from js import MediaStream, console, localStorage, navigator +from pyodide.webloop import PyodideFuture +from reactivex import Observable, Subject, empty +from reactivex import operators as op +from reactivex.subject import BehaviorSubject + +from frontend.base import Service + +FACING_MODES = {"user", "environment"} +LOCAL_STORAGE_KEY = "preferred_facing_mode" + +type FacingMode = Literal["user", "environment"] + + +class Camera(Service): + """A service for accessing the user's camera.""" + + def __init__(self) -> None: + super().__init__() + + self.media_stream = BehaviorSubject[Optional[MediaStream]](value=None) + self.is_acquiring_media_stream = BehaviorSubject[bool](value=False) + + self._acquire = Subject[None]() + + # Whenever acquisition is triggered, attempt to acquire the media stream + self._acquire.pipe( + op.do_action(lambda _: self.is_acquiring_media_stream.on_next(value=True)), + op.flat_map_latest( + lambda _: self._acquire_media_stream().finally_( + lambda: self.is_acquiring_media_stream.on_next(value=False), + ), + ), + op.catch(lambda err, _: self._handle_acquisition_error(err)), + op.take_until(self.destroyed), + ).subscribe(self.media_stream) + + def dispose_media_stream(self) -> None: + """Stop all tracks in the media stream and notify subscribers.""" + if not (camera_stream := self.media_stream.value): + return + + for track in camera_stream.getTracks(): + track.stop() + + self.media_stream.on_next(None) + + @override + def on_destroy(self) -> None: + self.dispose_media_stream() + self._acquire.dispose() + self.media_stream.dispose() + + def acquire_media_stream(self) -> None: + """Trigger the process of acquiring the media stream.""" + if self.media_stream.value: + return + + self._acquire.on_next(None) + + def toggle_facing_mode(self) -> None: + """Switch the preferred facing mode between user and environment.""" + self._preferred_facing_mode = "environment" if self._preferred_facing_mode == "user" else "user" + + self.dispose_media_stream() + self.acquire_media_stream() + + def _acquire_media_stream(self) -> PyodideFuture[MediaStream]: + """ + Get the user's media stream. + + Requests video access from the user. If access is not granted, a DOMException will be raised. + """ + constraints = {"video": {"facingMode": self._preferred_facing_mode}} + return navigator.mediaDevices.getUserMedia(constraints) + + def _handle_acquisition_error(self, err: Exception) -> Observable: + """Handle errors that occur while acquiring the media stream.""" + console.error("Error acquiring media stream:", err) + return empty() + + @property + def _preferred_facing_mode(self) -> FacingMode: + """ + Return the preferred facing mode for the camera. + + Save the user preference in local storage to ensure it persists across sessions. + """ + mode = localStorage.getItem(LOCAL_STORAGE_KEY) + return cast("FacingMode", mode) if mode in FACING_MODES else "user" + + @_preferred_facing_mode.setter + def _preferred_facing_mode(self, value: FacingMode) -> None: + localStorage.setItem(LOCAL_STORAGE_KEY, value) diff --git a/calm-calatheas/app/frontend/services/caption.py b/calm-calatheas/app/frontend/services/caption.py new file mode 100644 index 00000000..f7fd9ac5 --- /dev/null +++ b/calm-calatheas/app/frontend/services/caption.py @@ -0,0 +1,89 @@ +from asyncio import Future, create_task +from collections.abc import Callable +from typing import TYPE_CHECKING, override + +from js import console, window +from reactivex import Observable, combine_latest, empty, from_future, of +from reactivex import operators as op +from reactivex.subject import BehaviorSubject, ReplaySubject + +from frontend.base import Service + +from .reader import reader + +if TYPE_CHECKING: + from transformers_js import ModelOutput + +type Model = Callable[[str], Future[ModelOutput]] + +MODEL_NAME = "Xenova/vit-gpt2-image-captioning" + + +class Caption(Service): + """Service to generate captions for images.""" + + def __init__(self) -> None: + super().__init__() + + self.captions = ReplaySubject[str]() + self.model = ReplaySubject[Model]() + + self.is_generating_caption = BehaviorSubject[bool](value=False) + self.is_loading_model = BehaviorSubject[bool](value=False) + + # Load the captioning model on startup + of(MODEL_NAME).pipe( + op.do_action(lambda _: self.is_loading_model.on_next(value=True)), + op.flat_map_latest( + lambda model_name: from_future(create_task(self._load_model(model_name))).pipe( + op.finally_action( + lambda: self.is_loading_model.on_next(value=False), + ), + ), + ), + op.catch(lambda err, _: self._handle_load_model_error(err)), + op.take_until(self.destroyed), + ).subscribe(self.model) + + # Generate captions whenever an image is available and the model is loaded + combine_latest(reader.object_urls, self.model).pipe( + op.do_action(lambda _: self.is_generating_caption.on_next(value=True)), + op.flat_map_latest( + lambda params: from_future(create_task(self._caption(*params))).pipe( + op.finally_action( + lambda: self.is_generating_caption.on_next(value=False), + ), + ), + ), + op.catch(lambda err, _: self._handle_caption_error(err)), + op.take_until(self.destroyed), + ).subscribe(self.captions) + + @override + def on_destroy(self) -> None: + self.captions.dispose() + self.model.dispose() + self.is_generating_caption.dispose() + self.is_loading_model.dispose() + + async def _caption(self, url: str, model: Model) -> str: + """Generate a caption for the image at the given URL.""" + output = await model(url) + return output.at(0).generated_text + + def _handle_caption_error(self, err: Exception) -> Observable: + """Handle errors that occur while generating captions.""" + console.error("Failed to generate caption:", err) + return empty() + + def _handle_load_model_error(self, err: Exception) -> Observable: + """Handle errors that occur while loading the model.""" + console.error("Failed to load model:", err) + return empty() + + async def _load_model(self, model_name: str) -> Model: + """Load the given model.""" + return await window.pipeline("image-to-text", model_name, {"dtype": "q8", "device": "wasm"}) + + +caption = Caption() diff --git a/calm-calatheas/app/frontend/services/database.py b/calm-calatheas/app/frontend/services/database.py new file mode 100644 index 00000000..d2fcf1f3 --- /dev/null +++ b/calm-calatheas/app/frontend/services/database.py @@ -0,0 +1,192 @@ +import asyncio +from typing import TYPE_CHECKING, override + +from js import JSON, Event, console, indexedDB +from pyodide.ffi.wrappers import add_event_listener + +from frontend.base import Service +from frontend.models import PokemonRecord + +if TYPE_CHECKING: + from js import IDBDatabase + +_COLLECTION_NAME = "pokemon" +_DB_NAME = "calm_calatheas" +_DB_VERSION = 1 +_READY = asyncio.Event() + + +class DatabaseNotInitializedError(Exception): + """Error raised when the database is not initialized.""" + + def __init__(self) -> None: + super().__init__("Database is not initialized.") + + +class Database(Service): + """Service for interacting with IndexedDB.""" + + def __init__(self) -> None: + super().__init__() + + self._db: IDBDatabase | None = None + + open_ = indexedDB.open(_DB_NAME, _DB_VERSION) + + add_event_listener(open_, "success", self._handle_open_success) + add_event_listener(open_, "upgradeneeded", self._handle_open_upgrade_needed) + + @override + def on_destroy(self) -> None: + if self._db: + self._db.close() + + async def delete(self, name: str) -> None: + """Delete a Pokemon.""" + await _READY.wait() + + if not self._db: + raise DatabaseNotInitializedError + + future = asyncio.Future[None]() + + transaction = self._db.transaction(_COLLECTION_NAME, "readwrite") + store = transaction.objectStore(_COLLECTION_NAME) + + query = store.delete(name) + + def on_complete(_: Event) -> None: + transaction.close() + + def on_error(_: Event) -> None: + future.set_exception(Exception(f"Failed to delete pokemon {name}")) + + def on_success(_: Event) -> None: + future.set_result(None) + + add_event_listener(query, "complete", on_complete) + add_event_listener(query, "error", on_error) + add_event_listener(query, "success", on_success) + + return await future + + async def find_all(self) -> list[PokemonRecord]: + """Find all Pokemon.""" + await _READY.wait() + + if not self._db: + raise DatabaseNotInitializedError + + future = asyncio.Future[list[PokemonRecord]]() + + transaction = self._db.transaction(_COLLECTION_NAME, "readonly") + store = transaction.objectStore(_COLLECTION_NAME) + + query = store.getAll() + + def on_complete(_: Event) -> None: + transaction.close() + + def on_error(_: Event) -> None: + future.set_result([]) + + def on_success(event: Event) -> None: + # Some serialization and deserialization magic to ensure the data is accepted by Pydantic + result = [ + PokemonRecord.model_validate_json(JSON.stringify(item)) + for item in event.target.result # type: ignore[result is available] + ] + future.set_result(result) + + add_event_listener(query, "complete", on_complete) + add_event_listener(query, "error", on_error) + add_event_listener(query, "success", on_success) + + return await future + + async def find_one(self, name: str) -> PokemonRecord | None: + """Find a single Pokemon.""" + await _READY.wait() + + if not self._db: + raise DatabaseNotInitializedError + + future = asyncio.Future[PokemonRecord | None]() + + transaction = self._db.transaction(_COLLECTION_NAME, "readonly") + store = transaction.objectStore(_COLLECTION_NAME) + + query = store.get(name) + + def on_complete(_: Event) -> None: + transaction.close() + + def on_error(_: Event) -> None: + future.set_result(None) + + def on_success(event: Event) -> None: + # Some serialization and deserialization magic to ensure the data is accepted by Pydantic + result = PokemonRecord.model_validate_json(JSON.stringify(event.target.result)) # type: ignore[result is available] + future.set_result(result) + + add_event_listener(query, "complete", on_complete) + add_event_listener(query, "error", on_error) + add_event_listener(query, "success", on_success) + + return await future + + async def put(self, description: PokemonRecord) -> None: + """Store a Pokemon.""" + await _READY.wait() + + if not self._db: + raise DatabaseNotInitializedError + + future = asyncio.Future[None]() + + transaction = self._db.transaction(_COLLECTION_NAME, "readwrite") + store = transaction.objectStore(_COLLECTION_NAME) + + # Some serialization and deserialization magic to ensure the data is accepted by IndexedDB + query = store.put(JSON.parse(description.model_dump_json())) + + def on_complete(_: Event) -> None: + transaction.close() + + def on_error(_: Event) -> None: + future.set_exception(Exception(f"Failed to store pokemon {description.name}")) + + def on_success(_: Event) -> None: + future.set_result(None) + + add_event_listener(query, "complete", on_complete) + add_event_listener(query, "error", on_error) + add_event_listener(query, "success", on_success) + + return await future + + def _handle_open_success(self, event: Event) -> None: + """Handle the successful opening of the database.""" + self._db = event.target.result # type: ignore[result is available] + console.log("Opened IndexedDB.") + + _READY.set() + + def _handle_open_upgrade_needed(self, event: Event) -> None: + """Handle the upgrade needed event.""" + self._db = event.target.result # type: ignore[result is available] + + if not self._db: + raise DatabaseNotInitializedError + + self._db.createObjectStore(_COLLECTION_NAME, {"keyPath": "name"}) + + add_event_listener(event.target.transaction, "complete", self._handle_upgrade_transaction_complete) # type: ignore[transaction is available] + + def _handle_upgrade_transaction_complete(self, _: Event) -> None: + """Handle the completion of the upgrade transaction.""" + console.log("Initialized IndexedDB.") + _READY.set() + + +database = Database() diff --git a/calm-calatheas/app/frontend/services/description.py b/calm-calatheas/app/frontend/services/description.py new file mode 100644 index 00000000..4a2185de --- /dev/null +++ b/calm-calatheas/app/frontend/services/description.py @@ -0,0 +1,58 @@ +from asyncio import create_task + +from js import console +from pyodide.http import pyfetch +from reactivex import Observable, empty, from_future +from reactivex import operators as op +from reactivex.subject import BehaviorSubject, ReplaySubject + +from frontend.base import Service +from frontend.models import PokemonDescription + +from .caption import caption + + +class Description(Service): + """Service to generate descriptions from captions.""" + + def __init__(self) -> None: + super().__init__() + + self.is_generating_description = BehaviorSubject[bool](value=False) + self.descriptions = ReplaySubject[PokemonDescription]() + + # Generate descriptions whenever a new caption is available + caption.captions.pipe( + op.do_action(lambda _: self.is_generating_description.on_next(value=True)), + op.flat_map_latest( + lambda caption: from_future(create_task(self._describe(caption))).pipe( + op.finally_action( + lambda: self.is_generating_description.on_next(value=False), + ), + ), + ), + op.catch(lambda err, _: self._handle_description_error(err)), + op.take_until(self.destroyed), + ).subscribe(self.descriptions) + + async def _describe(self, caption: str) -> PokemonDescription: + """Generate a description from the given caption.""" + console.log("Generating description for caption:", caption) + + response = await pyfetch(f"/describe?prompt={caption}") + response.raise_for_status() + + data = await response.json() + description = PokemonDescription.model_validate(data) + + console.log("Generated description:", description.model_dump_json()) + + return description + + def _handle_description_error(self, err: Exception) -> Observable: + """Handle errors that occur while generating descriptions.""" + console.error("Failed to generate description:", err) + return empty() + + +description = Description() diff --git a/calm-calatheas/app/frontend/services/pokemon.py b/calm-calatheas/app/frontend/services/pokemon.py new file mode 100644 index 00000000..7a8561b1 --- /dev/null +++ b/calm-calatheas/app/frontend/services/pokemon.py @@ -0,0 +1,121 @@ +from asyncio import create_task +from typing import override + +from js import console +from reactivex import Observable, combine_latest, empty, from_future +from reactivex import operators as op +from reactivex.subject import BehaviorSubject, Subject + +from frontend.base import Service +from frontend.models import PokemonRecord + +from .caption import caption +from .database import database +from .description import description +from .reader import reader + + +class Pokemon(Service): + """Service that maintains a list of the user's current Pokemon.""" + + def __init__(self) -> None: + super().__init__() + + self.is_generating = BehaviorSubject[bool](value=False) + self.is_refreshing = BehaviorSubject[bool](value=False) + self.pokemon = BehaviorSubject[list[PokemonRecord]](value=[]) + + self._delete = Subject[str]() + self._put = Subject[PokemonRecord]() + self._refresh = Subject[None]() + + # Combine the loading states from all relevant sources + combine_latest( + caption.is_generating_caption, + description.is_generating_description, + reader.is_reading, + ).pipe( + op.map(lambda is_loading: any(is_loading)), + op.distinct_until_changed(), + op.take_until(self.destroyed), + ).subscribe(self.is_generating) + + # Whenever a new description is available, get the corresponding image url and create an new database record + description.descriptions.pipe( + op.with_latest_from(reader.object_urls), + op.map(lambda params: PokemonRecord(**params[0].model_dump(), img_url=params[1])), + op.take_until(self.destroyed), + ).subscribe(lambda pokemon: self.put(pokemon)) + + # On put, update the database with the given record + self._put.pipe( + op.flat_map_latest(lambda pokemon: from_future(create_task(database.put(pokemon)))), + op.catch(lambda err, _: self._handle_update_error(err)), + op.take_until(self.destroyed), + ).subscribe(lambda _: self.refresh()) + + # On delete, remove the Pokemon from the list and trigger a refresh + self._delete.pipe( + op.flat_map_latest(lambda name: from_future(create_task(database.delete(name)))), + op.catch(lambda err, _: self._handle_delete_error(err)), + op.take_until(self.destroyed), + ).subscribe(lambda _: self.refresh()) + + # On refresh, retrieve the current list of Pokemon from the database. Sort the list by timestamp + self._refresh.pipe( + op.do_action(lambda _: self.is_refreshing.on_next(value=True)), + op.flat_map_latest( + lambda _: from_future(create_task(database.find_all())).pipe( + op.finally_action(lambda: self.is_refreshing.on_next(value=False)), + ), + ), + op.catch(lambda err, _: self._handle_refresh_error(err)), + op.map(lambda pokemon: sorted(pokemon, key=lambda p: p.timestamp, reverse=True)), + op.take_until(self.destroyed), + ).subscribe(self.pokemon) + + # Trigger a refresh on startup + self.refresh() + + @override + def on_destroy(self) -> None: + self.is_generating.dispose() + self.is_refreshing.dispose() + self.pokemon.dispose() + self._delete.dispose() + self._put.dispose() + self._refresh.dispose() + + def delete(self, name: str) -> None: + """Delete the pokemon with the given name.""" + self._delete.on_next(name) + + def put(self, pokemon: PokemonRecord) -> None: + """Update the database with the given pokemon.""" + self._put.on_next(pokemon) + + def refresh(self) -> None: + """Trigger a refresh of the list.""" + self._refresh.on_next(None) + + def _handle_delete_error(self, err: Exception) -> Observable: + """Handle errors that occur while deleting a Pokemon.""" + console.error("Failed to delete pokemon:", err) + return empty() + + def _handle_favourite_error(self, err: Exception) -> Observable: + console.error("Failed to favourite pokemon", err) + return empty() + + def _handle_refresh_error(self, err: Exception) -> Observable: + """Handle errors that occur while refreshing the list of Pokemon.""" + console.error("Failed to refresh list of pokemon:", err) + return empty() + + def _handle_update_error(self, err: Exception) -> Observable: + """Handle errors that occur while updating a Pokemon.""" + console.error("Failed to update pokemon:", err) + return empty() + + +pokemon = Pokemon() diff --git a/calm-calatheas/app/frontend/services/reader.py b/calm-calatheas/app/frontend/services/reader.py new file mode 100644 index 00000000..58f33a02 --- /dev/null +++ b/calm-calatheas/app/frontend/services/reader.py @@ -0,0 +1,67 @@ +from asyncio import Future +from typing import Union, override + +from js import Blob, File, FileReader, console +from pyodide.ffi.wrappers import add_event_listener +from reactivex import Observable, empty, from_future +from reactivex import operators as op +from reactivex.subject import BehaviorSubject, ReplaySubject, Subject + +from frontend.base import Service + +type Readable = Union[Blob, File] + + +class Reader(Service): + """Service for reading files and generating object URLs.""" + + def __init__(self) -> None: + super().__init__() + + self.is_reading = BehaviorSubject[bool](value=False) + self.object_urls = ReplaySubject[str]() + + self._read = Subject[Readable]() + + # On read, generate an object URL for the object + self._read.pipe( + op.do_action(lambda _: self.is_reading.on_next(value=True)), + op.flat_map_latest( + lambda file_: from_future(self._generate_object_url(file_)).pipe( + op.finally_action(lambda: self.is_reading.on_next(value=False)), + ), + ), + op.catch(lambda err, _: self._handle_reader_error(err)), + op.take_until(self.destroyed), + ).subscribe(self.object_urls) + + @override + def on_destroy(self) -> None: + self._read.dispose() + self.is_reading.dispose() + self.object_urls.dispose() + + def read(self, object_: Readable) -> None: + """Upload an object and trigger further processing.""" + self._read.on_next(object_) + + def _handle_reader_error(self, err: Exception) -> Observable: + """Handle errors that occur while reading objects.""" + console.error("Error reading object:", err) + return empty() + + def _generate_object_url(self, object_: Readable) -> Future[str]: + """Read an object and return its object URL.""" + result = Future() + + reader = FileReader.new() + + add_event_listener(reader, "load", lambda _: result.set_result(reader.result)) # type: ignore[FileReader also supported] + add_event_listener(reader, "error", lambda e: result.set_exception(e)) # type: ignore[FileReader also supported] + + reader.readAsDataURL(object_) + + return result + + +reader = Reader() diff --git a/calm-calatheas/app/frontend/services/theme.py b/calm-calatheas/app/frontend/services/theme.py new file mode 100644 index 00000000..decdbe23 --- /dev/null +++ b/calm-calatheas/app/frontend/services/theme.py @@ -0,0 +1,61 @@ +from typing import Literal, cast, override + +import reactivex.operators as op +from js import document, localStorage +from reactivex.subject import BehaviorSubject + +from frontend.base import Service + +type Theme_ = Literal["light", "dark"] | None + +ATTRIBUTE_NAME = "data-theme" + + +def _update_document_theme(theme: Theme_) -> None: + """Set the theme of the document.""" + if theme: + document.documentElement.setAttribute(ATTRIBUTE_NAME, theme) # type: ignore[setAttribute not defined] + else: + document.documentElement.removeAttribute(ATTRIBUTE_NAME) # type: ignore[removeAttribute not defined] + + +LOCAL_STORAGE_KEY = "theme" + + +def _update_local_storage(theme: Theme_) -> None: + """Set the theme in local storage.""" + if theme: + localStorage.setItem(LOCAL_STORAGE_KEY, theme) + else: + localStorage.removeItem(LOCAL_STORAGE_KEY) + + +class Theme(Service): + """Service to manage the theme of the application.""" + + def __init__(self) -> None: + super().__init__() + + self.current = BehaviorSubject[Theme_]( + cast("Theme_", theme) + if (theme := localStorage.getItem(LOCAL_STORAGE_KEY)) and theme in {"light", "dark"} + else None, + ) + + # Update the document theme whenever the current theme changes + self.current.pipe( + op.take_until(self.destroyed), + ).subscribe(_update_document_theme) + + # Update the local storage whenever the current theme changes + self.current.pipe( + op.take_until(self.destroyed), + ).subscribe(_update_local_storage) + + @override + def on_destroy(self) -> None: + """Clean up the theme service.""" + self.current.dispose() + + +theme = Theme() diff --git a/calm-calatheas/app/index.html b/calm-calatheas/app/index.html new file mode 100644 index 00000000..e3b859ed --- /dev/null +++ b/calm-calatheas/app/index.html @@ -0,0 +1,31 @@ + + + + + + Calm Calatheas + + + + + + + + + + + diff --git a/calm-calatheas/app/main.py b/calm-calatheas/app/main.py new file mode 100644 index 00000000..a5a35686 --- /dev/null +++ b/calm-calatheas/app/main.py @@ -0,0 +1,12 @@ +from frontend import App +from js import document + + +def bootstrap() -> None: + """Bootstrap the application to the DOM.""" + app = App(document.body) + app.render() + + +if __name__ == "__main__": + bootstrap() diff --git a/calm-calatheas/app/manifest.json b/calm-calatheas/app/manifest.json new file mode 100644 index 00000000..36de9e18 --- /dev/null +++ b/calm-calatheas/app/manifest.json @@ -0,0 +1,25 @@ +{ + "background_color": "white", + "display": "standalone", + "icons": [ + { + "sizes": "16x16", + "src": "/assets/logo-16x16.png", + "type": "image/png" + }, + { + "sizes": "48x48", + "src": "/assets/logo-48x48.png", + "type": "image/png" + }, + { + "sizes": "128x128", + "src": "/assets/logo-128x128.png", + "type": "image/png" + } + ], + "name": "Pokedexter", + "short_name": "Pokedexter", + "start_url": ".", + "theme_color": "black" +} diff --git a/calm-calatheas/app/pyscript.toml b/calm-calatheas/app/pyscript.toml new file mode 100644 index 00000000..b31310a8 --- /dev/null +++ b/calm-calatheas/app/pyscript.toml @@ -0,0 +1,34 @@ +docked = false +terminal = false + +packages = ['pydantic', 'reactivex'] + +[[fetch]] +files = [ + '__init__.py', + 'app.py', + 'base/__init__.py', + 'base/component.py', + 'base/service.py', + 'components/__init__.py', + 'components/camera.py', + 'components/footer.py', + 'components/header.py', + 'components/loading_caption_model.py', + 'components/pokemon.py', + 'components/theme.py', + 'components/description.py', + 'components/description_dropdown.py', + 'models/__init__.py', + 'models/pokemon_description.py', + 'services/__init__.py', + 'services/camera.py', + 'services/caption.py', + 'services/database.py', + 'services/description.py', + 'services/pokemon.py', + 'services/reader.py', + 'services/theme.py', +] +from = 'frontend' +to_folder = 'frontend' diff --git a/calm-calatheas/app/styles/theme.css b/calm-calatheas/app/styles/theme.css new file mode 100644 index 00000000..85dc9856 --- /dev/null +++ b/calm-calatheas/app/styles/theme.css @@ -0,0 +1,165 @@ +@import "https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css"; + +:root { + --bulma-primary-h: 359deg; + --bulma-primary-s: 91%; + --bulma-primary-l: 56%; + --bulma-success-h: 120deg; + --bulma-success-s: 50%; + --bulma-success-l: 40%; + --bulma-warning-h: 52deg; + --bulma-warning-l: 50%; + --bulma-danger-h: 0deg; + --bulma-danger-l: 50%; + + --type-bug: #94a034; + --type-dark: #4d3f3e; + --type-dragon: #4c60a9; + --type-electric: #f2c340; + --type-fairy: #ba7fb5; + --type-fighting: #f08833; + --type-fire: #d43a30; + --type-flying: #8eb8e3; + --type-ghost: #6b426e; + --type-grass: #5d9d3c; + --type-ground: #895229; + --type-ice: #78ccf0; + --type-normal: #a2a2a2; + --type-poison: #6d4b97; + --type-psychic: #dc4d79; + --type-rock: #ada984; + --type-steel: #74a2b9; + --type-water: #4c79bc; +} + +@font-face { + font-family: "pokemongb"; + src: url("/assets/pokemongb.ttf"); +} + +body { + font-family: "pokemongb"; +} + +#app-container { + max-height: 100vh; +} + +#app-body { + align-items: unset; + display: block; + flex-shrink: unset; + overflow-y: auto; +} + +.modal { + animation: fadeIn 0.2s; +} + +.notification { + animation: fadeIn 0.2s; +} + +.pokemon-description { + animation: fadeIn 0.2s; +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +.tag.type-bug { + background-color: var(--type-bug); + color: hsl(0, 0%, 100%); +} + +.tag.type-dark { + background-color: var(--type-dark); + color: hsl(0, 0%, 100%); +} + +.tag.type-dragon { + background-color: var(--type-dragon); + color: hsl(0, 0%, 100%); +} + +.tag.type-electric { + background-color: var(--type-electric); + color: hsl(0, 0%, 4%); +} + +.tag.type-fairy { + background-color: var(--type-fairy); + color: hsl(0, 0%, 4%); +} + +.tag.type-fighting { + background-color: var(--type-fighting); + color: hsl(0, 0%, 4%); +} + +.tag.type-fire { + background-color: var(--type-fire); + color: hsl(0, 0%, 100%); +} + +.tag.type-flying { + background-color: var(--type-flying); + color: hsl(0, 0%, 4%); +} + +.tag.type-ghost { + background-color: var(--type-ghost); + color: hsl(0, 0%, 100%); +} + +.tag.type-grass { + background-color: var(--type-grass); + color: hsl(0, 0%, 100%); +} + +.tag.type-ground { + background-color: var(--type-ground); + color: hsl(0, 0%, 100%); +} + +.tag.type-ice { + background-color: var(--type-ice); + color: hsl(0, 0%, 4%); +} + +.tag.type-normal { + background-color: var(--type-normal); + color: hsl(0, 0%, 4%); +} + +.tag.type-poison { + background-color: var(--type-poison); + color: hsl(0, 0%, 100%); +} + +.tag.type-psychic { + background-color: var(--type-psychic); + color: hsl(0, 0%, 100%); +} + +.tag.type-rock { + background-color: var(--type-rock); + color: hsl(0, 0%, 4%); +} + +.tag.type-steel { + background-color: var(--type-steel); + color: hsl(0, 0%, 4%); +} + +.tag.type-water { + background-color: var(--type-water); + color: hsl(0, 0%, 100%); +} diff --git a/calm-calatheas/calm_calatheas/__init__.py b/calm-calatheas/calm_calatheas/__init__.py new file mode 100644 index 00000000..cd0ecd76 --- /dev/null +++ b/calm-calatheas/calm_calatheas/__init__.py @@ -0,0 +1,4 @@ +from .app import app +from .settings import settings + +__all__ = ["app", "settings"] diff --git a/calm-calatheas/calm_calatheas/__main__.py b/calm-calatheas/calm_calatheas/__main__.py new file mode 100644 index 00000000..7be6879b --- /dev/null +++ b/calm-calatheas/calm_calatheas/__main__.py @@ -0,0 +1,5 @@ +from uvicorn import run + +from calm_calatheas import app, settings + +run(app=app, host=settings.host, port=settings.port) diff --git a/calm-calatheas/calm_calatheas/app.py b/calm-calatheas/calm_calatheas/app.py new file mode 100644 index 00000000..6f25da76 --- /dev/null +++ b/calm-calatheas/calm_calatheas/app.py @@ -0,0 +1,34 @@ +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Mount, Route +from starlette.staticfiles import StaticFiles + +from .model import generate_description +from .settings import settings + + +async def describe(request: Request) -> Response: + """Handle GET requests to the /describe endpoint.""" + user_prompt = request.query_params.get("prompt", "") + + if not user_prompt: + return Response("Missing prompt", status_code=400) + + description = generate_description(user_prompt) + + return Response(description.model_dump_json(), media_type="application/json") + + +async def healthcheck(_: Request) -> Response: + """Handle GET requests to the /healthcheck endpoint.""" + return Response("OK", media_type="text/plain") + + +routes = [ + Route("/describe", endpoint=describe, methods=["GET"]), + Route("/healthcheck", endpoint=healthcheck, methods=["GET"]), + Mount("/", app=StaticFiles(directory=settings.static_files_path, html=True), name="static"), +] + +app = Starlette(routes=routes) diff --git a/calm-calatheas/calm_calatheas/logger.py b/calm-calatheas/calm_calatheas/logger.py new file mode 100644 index 00000000..454366f8 --- /dev/null +++ b/calm-calatheas/calm_calatheas/logger.py @@ -0,0 +1,6 @@ +import logging + +from .settings import settings + +logging.basicConfig(level=settings.log_level) +LOGGER = logging.getLogger("calm_calatheas") diff --git a/calm-calatheas/calm_calatheas/model.py b/calm-calatheas/calm_calatheas/model.py new file mode 100644 index 00000000..63eab33f --- /dev/null +++ b/calm-calatheas/calm_calatheas/model.py @@ -0,0 +1,194 @@ +import json +from enum import StrEnum, auto +from functools import lru_cache +from typing import override + +from pydantic import BaseModel, Field, ValidationError +from transformers import AutoModelForCausalLM, AutoTokenizer + +from .logger import LOGGER + + +class PokemonType(StrEnum): + """An enumeration of Pokemon types.""" + + BUG = auto() + DARK = auto() + DRAGON = auto() + ELECTRIC = auto() + FAIRY = auto() + FIGHTING = auto() + FIRE = auto() + FLYING = auto() + GHOST = auto() + GRASS = auto() + GROUND = auto() + ICE = auto() + NORMAL = auto() + POISON = auto() + PSYCHIC = auto() + ROCK = auto() + STEEL = auto() + WATER = auto() + + @classmethod + @override + def _missing_(cls, value: object) -> "PokemonType | None": + """ + The model will sometimes generate types that don't match the enum exactly. + + Try normalizing the input by converting it to lowercase. + """ + if isinstance(value, str): + value = value.lower() + + for member in cls: + if member.lower() == value: + return member + + return None + + +class PokemonDescription(BaseModel): + """A description of a Pokemon.""" + + ability: str = Field( + description="The primary ability of the Pokemon, which can affect its performance in battles.", + ) + + category: str = Field( + description="The category of the Pokemon, phrased as a noun.", + ) + + flavor_text: str = Field( + description="Flavor text to add characterization or lore to the Pokemon in question.", + max_length=255, + ) + + habitat: str = Field( + description="The natural habitat where the Pokemon can typically be found, phrased as a noun.", + max_length=15, + ) + + height: float = Field( + description="The height of the Pokemon in meters.", + ) + + name: str = Field( + description="The creative name for the Pokemon. Avoid using real names or actual Pokemon names.", + ) + + types: set[PokemonType] = Field( + description="The type(s) of the Pokemon.", + max_length=2, + min_length=1, + ) + + weight: float = Field( + description="The weight of the Pokemon in kilograms.", + ) + + +MODEL_NAME = "Qwen/Qwen3-1.7B" + +TOKENIZER = AutoTokenizer.from_pretrained(MODEL_NAME) +MODEL = AutoModelForCausalLM.from_pretrained( + MODEL_NAME, + torch_dtype="auto", + device_map="auto", +) + +DESCRIPTION_PROMPT = f""" +You are a helpful Pokemon professor. +The user is a Pokemon trainer seeking information. +The user will prompt you with a caption for a picture of a Pokemon. +Answer using the following schema: {json.dumps(PokemonDescription.model_json_schema())} +""" + +REPAIR_PROMPT = f""" +You are a helpful Pokemon professor. +The input is a Pokemon description and a validation error. +The description needs to be repaired based on the error. +Leave fields not mentioned in the error unchanged. +Answer using the following schema: {json.dumps(PokemonDescription.model_json_schema())} +""" + + +@lru_cache +def generate_description(user_prompt: str) -> PokemonDescription: + """Generate a Pokemon description based on the user's prompt.""" + LOGGER.debug('Generating a description based on user prompt: "%s"', user_prompt) + + messages = [ + { + "role": "system", + "content": DESCRIPTION_PROMPT, + }, + { + "role": "user", + "content": user_prompt, + }, + ] + + thinking_content, content = _prompt(messages) + + LOGGER.debug(thinking_content) + + try: + result = PokemonDescription.model_validate_json(content) + except ValidationError as e: + result = _repair(content, e) + + return result + + +def _repair(content: str, validation_error: ValidationError) -> PokemonDescription: + """Attempt to repair the given content based on the given validation error.""" + LOGGER.debug("Repairing content based on validation error: %s", validation_error) + LOGGER.debug("Original content: %s", content) + + messages = [ + { + "role": "system", + "content": REPAIR_PROMPT, + }, + { + "role": "user", + "content": f"Description: {content}\n\nError: {validation_error}", + }, + ] + + thinking_content, content = _prompt(messages) + + LOGGER.debug(thinking_content) + + return PokemonDescription.model_validate_json(content) + + +def _prompt(messages: list[dict[str, str]]) -> tuple[str, str]: + """Prompt the model with the given messages and return the generated text.""" + text = TOKENIZER.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=True, + enable_thinking=True, + ) + + model_inputs = TOKENIZER([text], return_tensors="pt").to(MODEL.device) + + # The magic numbers below taken from the model documentation, see https://huggingface.co/Qwen/Qwen3-1.7B#quickstart + generated_ids = MODEL.generate(**model_inputs, max_new_tokens=32768) + output_ids = generated_ids[0][len(model_inputs.input_ids[0]) :].tolist() + + try: + index = len(output_ids) - output_ids[::-1].index(151668) + except ValueError: + index = 0 + + thinking_content = TOKENIZER.decode( + output_ids[:index], + skip_special_tokens=True, + ).strip("\n") + content = TOKENIZER.decode(output_ids[index:], skip_special_tokens=True).strip("\n") + + return thinking_content, content diff --git a/calm-calatheas/calm_calatheas/settings.py b/calm-calatheas/calm_calatheas/settings.py new file mode 100644 index 00000000..7082121c --- /dev/null +++ b/calm-calatheas/calm_calatheas/settings.py @@ -0,0 +1,31 @@ +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Settings for the application.""" + + host: str = Field( + default="localhost", + description="Host to bind the server to", + ) + + log_level: str = Field( + default="DEBUG", + description="Logging level for the application", + ) + + port: int = Field( + default=8000, + description="Port to bind the server to", + ) + + static_files_path: str = Field( + default="app", + description="Path to the static files directory", + ) + + model_config = SettingsConfigDict(extra="ignore") + + +settings = Settings() diff --git a/calm-calatheas/devenv.lock b/calm-calatheas/devenv.lock new file mode 100644 index 00000000..2250f543 --- /dev/null +++ b/calm-calatheas/devenv.lock @@ -0,0 +1,103 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1754418859, + "owner": "cachix", + "repo": "devenv", + "rev": "e13cd53579f6a0f441ac09230178dccb3008dd36", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1747046372, + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1754416808, + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "9c52372878df6911f9afc1e2a1391f55e4dfc864", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1753719760, + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "0f871fffdc0e5852ec25af99ea5f09ca7be9b632", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "git-hooks": "git-hooks", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": [ + "git-hooks" + ] + } + } + }, + "root": "root", + "version": 7 +} diff --git a/calm-calatheas/devenv.nix b/calm-calatheas/devenv.nix new file mode 100644 index 00000000..2c50bc3e --- /dev/null +++ b/calm-calatheas/devenv.nix @@ -0,0 +1,18 @@ +{ pkgs, ... }: +{ + packages = [ + pkgs.pre-commit + ]; + + languages.python = { + enable = true; + uv = { + enable = true; + sync.enable = true; + }; + }; + + languages.javascript = { + enable = true; + }; +} diff --git a/calm-calatheas/docs/assets/code/backend.svg b/calm-calatheas/docs/assets/code/backend.svg new file mode 100644 index 00000000..8cfedad5 --- /dev/null +++ b/calm-calatheas/docs/assets/code/backend.svg @@ -0,0 +1,103 @@ + + + + + + +G + + + +calm_calatheas + +calm_calatheas + + + +calm_calatheas___main__ + +calm_calatheas. +__main__ + + + +calm_calatheas_app + +calm_calatheas. +app + + + +calm_calatheas_app->calm_calatheas + + + + + +calm_calatheas_app->calm_calatheas___main__ + + + + + +calm_calatheas_logger + +calm_calatheas. +logger + + + +calm_calatheas_model + +calm_calatheas. +model + + + +calm_calatheas_logger->calm_calatheas_model + + + + + +calm_calatheas_model->calm_calatheas_app + + + + + +calm_calatheas_settings + +calm_calatheas. +settings + + + +calm_calatheas_settings->calm_calatheas + + + + + +calm_calatheas_settings->calm_calatheas___main__ + + + + + +calm_calatheas_settings->calm_calatheas_app + + + + + + +calm_calatheas_settings->calm_calatheas_logger + + + + + diff --git a/calm-calatheas/docs/assets/code/frontend.svg b/calm-calatheas/docs/assets/code/frontend.svg new file mode 100644 index 00000000..84d1c3d0 --- /dev/null +++ b/calm-calatheas/docs/assets/code/frontend.svg @@ -0,0 +1,471 @@ + + + + + + +G + + + +frontend + +frontend + + + +frontend_app + +frontend.app + + + +frontend_app->frontend + + + + + +frontend_components + +frontend. +components + + + +frontend_components->frontend_app + + + + + +frontend_components_camera + +frontend. +components. +camera + + + +frontend_components_footer + +frontend. +components. +footer + + + +frontend_components_camera->frontend_components_footer + + + + + +frontend_components_description + +frontend. +components. +description + + + +frontend_components_description->frontend_components + + + + + +frontend_components_description_dropdown + +frontend. +components. +description_dropdown + + + +frontend_components_description_dropdown->frontend_components + + + + + + +frontend_components_description_dropdown->frontend_components_description + + + + + +frontend_components_footer->frontend_components + + + + + +frontend_components_header + +frontend. +components. +header + + + +frontend_components_header->frontend_components + + + + + +frontend_components_loading_caption_model + +frontend. +components. +loading_caption_model + + + +frontend_components_loading_caption_model->frontend_components + + + + + +frontend_components_pokemon + +frontend. +components. +pokemon + + + +frontend_components_pokemon->frontend_components + + + + +frontend_components_theme + +frontend. +components. +theme + + + +frontend_components_theme->frontend_components + + + + +frontend_components_theme->frontend_components_header + + + + + +frontend_models + +frontend. +models + + + +frontend_models->frontend_components_description + + + + + + + + + +frontend_models->frontend_components_description_dropdown + + + + + + +frontend_models->frontend_components_pokemon + + + + + + +frontend_services_database + +frontend. +services. +database + + + +frontend_models->frontend_services_database + + + + + +frontend_services_description + +frontend. +services. +description + + + +frontend_models->frontend_services_description + + + + + +frontend_services_pokemon + +frontend. +services. +pokemon + + + +frontend_models->frontend_services_pokemon + + + + + +frontend_models_pokemon_description + +frontend. +models. +pokemon_description + + + +frontend_models_pokemon_description->frontend_models + + + + + +frontend_services + +frontend. +services + + + +frontend_services->frontend_app + + + + + +frontend_services->frontend_components_camera + + + + + + +frontend_services->frontend_components_description_dropdown + + + + + +frontend_services->frontend_components_footer + + + + +frontend_services->frontend_components_loading_caption_model + + + + + + +frontend_services->frontend_components_pokemon + + + + + + +frontend_services->frontend_components_theme + + + + + +frontend_services_camera + +frontend. +services. +camera + + + +frontend_services_camera->frontend_services + + + + + +frontend_services_caption + +frontend. +services. +caption + + + +frontend_services_caption->frontend_components_footer + + + + + + +frontend_services_caption->frontend_components_loading_caption_model + + + + + + +frontend_services_caption->frontend_services + + + + + + +frontend_services_caption->frontend_services_description + + + + + +frontend_services_caption->frontend_services_pokemon + + + + + +frontend_services_database->frontend_services + + + + + + +frontend_services_database->frontend_services_pokemon + + + + + +frontend_services_description->frontend_services + + + + +frontend_services_description->frontend_services_pokemon + + + + + +frontend_services_pokemon->frontend_app + + + + + +frontend_services_pokemon->frontend_components_description_dropdown + + + + + +frontend_services_pokemon->frontend_components_footer + + + + + + +frontend_services_pokemon->frontend_components_pokemon + + + + +frontend_services_pokemon->frontend_services + + + + + +frontend_services_reader + +frontend. +services. +reader + + + +frontend_services_reader->frontend_components_camera + + + + + +frontend_services_reader->frontend_components_footer + + + + +frontend_services_reader->frontend_services + + + + + +frontend_services_reader->frontend_services_caption + + + + + +frontend_services_reader->frontend_services_pokemon + + + + + +frontend_services_theme + +frontend. +services. +theme + + + +frontend_services_theme->frontend_components_theme + + + + + +frontend_services_theme->frontend_services + + + + + diff --git a/calm-calatheas/docs/assets/design/mvp.png b/calm-calatheas/docs/assets/design/mvp.png new file mode 100644 index 00000000..9dd87a7f Binary files /dev/null and b/calm-calatheas/docs/assets/design/mvp.png differ diff --git a/calm-calatheas/docs/assets/design/overview.drawio b/calm-calatheas/docs/assets/design/overview.drawio new file mode 100644 index 00000000..fda9d9bc --- /dev/null +++ b/calm-calatheas/docs/assets/design/overview.drawio @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/calm-calatheas/docs/assets/logo-128x128.png b/calm-calatheas/docs/assets/logo-128x128.png new file mode 100644 index 00000000..51375e09 Binary files /dev/null and b/calm-calatheas/docs/assets/logo-128x128.png differ diff --git a/calm-calatheas/docs/assets/logo-16x16.png b/calm-calatheas/docs/assets/logo-16x16.png new file mode 100644 index 00000000..c156031c Binary files /dev/null and b/calm-calatheas/docs/assets/logo-16x16.png differ diff --git a/calm-calatheas/docs/assets/pokemon/aquapuff.png b/calm-calatheas/docs/assets/pokemon/aquapuff.png new file mode 100644 index 00000000..31d74331 Binary files /dev/null and b/calm-calatheas/docs/assets/pokemon/aquapuff.png differ diff --git a/calm-calatheas/docs/assets/pokemon/chronoclock.png b/calm-calatheas/docs/assets/pokemon/chronoclock.png new file mode 100644 index 00000000..5644f15b Binary files /dev/null and b/calm-calatheas/docs/assets/pokemon/chronoclock.png differ diff --git a/calm-calatheas/docs/assets/pokemon/lumina.png b/calm-calatheas/docs/assets/pokemon/lumina.png new file mode 100644 index 00000000..e17b2e7c Binary files /dev/null and b/calm-calatheas/docs/assets/pokemon/lumina.png differ diff --git a/calm-calatheas/docs/assets/pokemon/shadowclaw.png b/calm-calatheas/docs/assets/pokemon/shadowclaw.png new file mode 100644 index 00000000..07c333f6 Binary files /dev/null and b/calm-calatheas/docs/assets/pokemon/shadowclaw.png differ diff --git a/calm-calatheas/docs/assets/user-guide/camera-active.png b/calm-calatheas/docs/assets/user-guide/camera-active.png new file mode 100644 index 00000000..a4e898bd Binary files /dev/null and b/calm-calatheas/docs/assets/user-guide/camera-active.png differ diff --git a/calm-calatheas/docs/assets/user-guide/camera-loading.gif b/calm-calatheas/docs/assets/user-guide/camera-loading.gif new file mode 100644 index 00000000..8711918e Binary files /dev/null and b/calm-calatheas/docs/assets/user-guide/camera-loading.gif differ diff --git a/calm-calatheas/docs/assets/user-guide/collection.png b/calm-calatheas/docs/assets/user-guide/collection.png new file mode 100644 index 00000000..3533c707 Binary files /dev/null and b/calm-calatheas/docs/assets/user-guide/collection.png differ diff --git a/calm-calatheas/docs/assets/user-guide/dark.png b/calm-calatheas/docs/assets/user-guide/dark.png new file mode 100644 index 00000000..7bfcceb9 Binary files /dev/null and b/calm-calatheas/docs/assets/user-guide/dark.png differ diff --git a/calm-calatheas/docs/assets/user-guide/details.png b/calm-calatheas/docs/assets/user-guide/details.png new file mode 100644 index 00000000..ed2132fb Binary files /dev/null and b/calm-calatheas/docs/assets/user-guide/details.png differ diff --git a/calm-calatheas/docs/assets/user-guide/favourite.png b/calm-calatheas/docs/assets/user-guide/favourite.png new file mode 100644 index 00000000..2068fadc Binary files /dev/null and b/calm-calatheas/docs/assets/user-guide/favourite.png differ diff --git a/calm-calatheas/docs/assets/user-guide/home.png b/calm-calatheas/docs/assets/user-guide/home.png new file mode 100644 index 00000000..5a2f4fde Binary files /dev/null and b/calm-calatheas/docs/assets/user-guide/home.png differ diff --git a/calm-calatheas/docs/assets/user-guide/light.png b/calm-calatheas/docs/assets/user-guide/light.png new file mode 100644 index 00000000..c86e9a88 Binary files /dev/null and b/calm-calatheas/docs/assets/user-guide/light.png differ diff --git a/calm-calatheas/docs/assets/user-guide/processing.gif b/calm-calatheas/docs/assets/user-guide/processing.gif new file mode 100644 index 00000000..6588bcb0 Binary files /dev/null and b/calm-calatheas/docs/assets/user-guide/processing.gif differ diff --git a/calm-calatheas/docs/assets/user-guide/results.png b/calm-calatheas/docs/assets/user-guide/results.png new file mode 100644 index 00000000..465c0967 Binary files /dev/null and b/calm-calatheas/docs/assets/user-guide/results.png differ diff --git a/calm-calatheas/docs/code/backend.md b/calm-calatheas/docs/code/backend.md new file mode 100644 index 00000000..dcb29b29 --- /dev/null +++ b/calm-calatheas/docs/code/backend.md @@ -0,0 +1,16 @@ +# Backend + +Below is a high-level overview of the backend architecture: + +[![Backend Architecture Overview](../assets/code/backend.svg)](../assets/code/backend.svg) + +This diagram was generated automatically using [`pydeps`](https://pypi.org/project/pydeps/). The detailed documentation +below was generated with [`mkdocstrings`](https://mkdocstrings.github.io/). + +::: calm_calatheas.app + +::: calm_calatheas.logger + +::: calm_calatheas.model + +::: calm_calatheas.settings diff --git a/calm-calatheas/docs/code/frontend.md b/calm-calatheas/docs/code/frontend.md new file mode 100644 index 00000000..e1c10931 --- /dev/null +++ b/calm-calatheas/docs/code/frontend.md @@ -0,0 +1,23 @@ +# Frontend + +Below is a high-level overview of the frontend architecture: + +[![Frontend Architecture Overview](../assets/code/frontend.svg)](../assets/code/frontend.svg) + +This diagram was generated automatically using [`pydeps`](https://pypi.org/project/pydeps/). + +!!! NOTE "Diagram completeness" + + To keep the diagram clear, relationships between components, services, and their base classes are not shown. + +The following sections provide detailed documentation, generated automatically with [`mkdocstrings`](https://mkdocstrings.github.io/). + +::: app.frontend + +::: app.frontend.base + +::: app.frontend.components + +::: app.frontend.models + +::: app.frontend.services diff --git a/calm-calatheas/docs/code/index.md b/calm-calatheas/docs/code/index.md new file mode 100644 index 00000000..15379c91 --- /dev/null +++ b/calm-calatheas/docs/code/index.md @@ -0,0 +1,7 @@ +# Code + +This section provides automatically generated documentation for the codebase, including an overview of the project's modules, +classes, and functions. + +It is intended for developers who want to understand or contribute to the project, as well as for code jam judges reviewing +the implementation. diff --git a/calm-calatheas/docs/contributor-guide/development-environment.md b/calm-calatheas/docs/contributor-guide/development-environment.md new file mode 100644 index 00000000..abd732b3 --- /dev/null +++ b/calm-calatheas/docs/contributor-guide/development-environment.md @@ -0,0 +1,147 @@ +# Development Environment + +Follow the steps below to set up your development environment. + +## Configure your SSH Key + +Follow the steps below to configure your SSH key for accessing the repository: + +1. [Generate an SSH key](https://docs.github.com/en/github/authenticating-to-github/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent). +2. [Add the SSH key to your GitHub account](https://docs.github.com/en/github/authenticating-to-github/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account). + +## Clone the Repository + +To clone the repository, run the following command: + +```bash +git clone git@github.com:cj12-calm-calatheas/code-jam-12.git +``` + +This will clone the repository to your local machine using SSH. + +## Environment Setup + +To get started with the project, you can either install the [devcontainer](https://containers.dev) or follow the manual +setup instructions below. + +### Using the Devcontainer + +This project includes a [devcontainer](https://containers.dev) to automatically set up your development +environment, including the all tools and dependencies required for local development. + +??? NOTE "Prerequisites" + + Please ensure you have the following prerequisites installed: + + - [Docker](https://www.docker.com) must be installed on your system to use the devcontainer. + + - The [Remote Development Extension Pack](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.vscode-remote-extensionpack) for Visual Studio Code must be installed to work with devcontainers. + +??? TIP "Use WSL on Windows" + + If you are using Windows, we **strongly** recommend cloning the repository into the [WSL](https://learn.microsoft.com/en-us/windows/wsl/) + filesystem instead of the Windows filesystem. This significantly improves I/O performance when running the devcontainer. + +#### Configure your SSH Agent + +The devcontainer will attempt to pick up your SSH key from your `ssh-agent` when it starts. Follow the +guide on [sharing git credentials with the devcontainer](https://code.visualstudio.com/remote/advancedcontainers/sharing-git-credentials) +to ensure your SSH key is available inside the container. + +#### Open the Repository + +To get started, navigate to the folder where you cloned the repository and run: + +```bash +code . +``` + +This will open the current directory in Visual Studio Code. + +#### Build the Environment + +Once Visual Studio Code is open, you will see a notification at the bottom right corner of the window asking if +you want to open the project in a devcontainer. Select `Reopen in Container`. + +Your development environment will now be set up automatically. + +??? QUESTION "What if I don't see the notification?" + + You can manually open the devcontainer by pressing `F1` to open the command pallette. Type + `>Dev Containers: Reopen in Container` and press `Enter` to select the command. + +??? EXAMPLE "Detailed Setup Guides" + + For more details, refer to the setup guide for your IDE: + + - [Visual Studio Code](https://code.visualstudio.com/docs/devcontainers/tutorial) + - [PyCharm](https://www.jetbrains.com/help/pycharm/connect-to-devcontainer.html) + +### Manual Setup + +Alternatively, you can set up the development environment manually by following the steps below. + +??? NOTE "Prerequisites" + + Please ensure you have the following prerequisites installed: + + - [Python 3.13](https://www.python.org/downloads/) must be installed on your system. + - [Node.js](https://nodejs.org) must be installed on your system for linting non-Python files. + + You can check your Python version with: + + ```bash + python --version + ``` + +#### Open the Repository + +Start by opening the repository in your terminal or command prompt. + +```bash +cd path/to/your/repository +``` + +#### Set up your Python Environment + +This project uses [uv](https://docs.astral.sh/uv/) for dependency management. If you don't have `uv` installed, you can +install it using pip: + +```bash +python -m pip install uv +``` + +To install the dependencies, run: + +```bash +uv venv --allow-existing && uv sync +``` + +This sets up a virtual environment and installs all required packages. + +#### Install Node.js Dependencies + +For linting non-Python files, we also require some Node.js dependencies. To install them, run: + +```bash +npm install +``` + +#### Set up Pre-commit Hooks + +To ensure code quality, this project uses pre-commit hooks. Install them by running: + +```bash +uv run pre-commit install +``` + +This will set up the pre-commit hooks to run automatically on each commit. + +#### Install Playwright + +This project uses [Playwright](https://playwright.dev/python/) to simulate user interactions for testing. To install the +required dependencies, run the following command: + +```bash +uv run playwright install --with-deps +``` diff --git a/calm-calatheas/docs/contributor-guide/documentation.md b/calm-calatheas/docs/contributor-guide/documentation.md new file mode 100644 index 00000000..7da0a531 --- /dev/null +++ b/calm-calatheas/docs/contributor-guide/documentation.md @@ -0,0 +1,67 @@ +# Documentation + +This page provides guidelines for contributing to the documentation. + +## Tools + +The documentation is built using [MkDocs](https://www.mkdocs.org/), a static site generator that converts Markdown +files into a website. + +Markdown is a lightweight markup language with plain-text formatting syntax. Refer to the [Markdown Guide](https://www.markdownguide.org) +for more information on how to use Markdown. + +This project uses the [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) theme to generate the +documentation. Please review the theme documentation for guidance on how to use its various features. + +## Running the Documentation + +!!! NOTE "Prerequisites" + + Ensure you have [set up your development environment](./development-environment.md) before running the documentation. + +To view the documentation locally, you can use the following command: + +```bash +uv run mkdocs serve +``` + +Open your browser and navigate to [`http://localhost:8000`](http://localhost:8000) to view the documentation. +The changes you make to the documentation will be automatically reflected in the browser. + +## Adding a New Page + +To add a new page to the documentation, create a new Markdown file in the `docs` directory. + +Next, update the `nav` section in the `mkdocs.yaml` file to include the new page. The `nav` section defines the +structure of the documentation and the order in which the pages are displayed in the navigation bar. + +Please ensure that the folder structure in the `docs` directory matches the structure defined in the `nav` section. + +## Linting + +This project is configured to use [markdownlint](https://github.com/DavidAnson/markdownlint) to ensure consistent +Markdown styling and formatting across the documentation. The linter is automatically run when you commit changes +to the repository. + +You can configure the linter rules in the `.markdownlint.json` file. Refer to the [markdownlint rules](https://github.com/DavidAnson/markdownlint?tab=readme-ov-file#rules--aliases) +for more information on the available rules. + +!!! TIP "Use a Markdown Linter Extension" + + We recommend installing a Markdown linter extension in your editor to help identify and fix issues as you write. + The devcontainer is pre-configured with the [`markdownlint`](https://marketplace.visualstudio.com/items?itemName=DavidAnson.vscode-markdownlint) + extension for Visual Studio Code. + +## Formatting + +The documentation is formatted using [Prettier](https://prettier.io/), an opinionated code formatter that ensures +consistent style across the project. Prettier is automatically run when you save a Markdown file in the editor. + +You can configure the formatting rules in the `.prettierrc.json` file. Refer to the [Prettier options](https://prettier.io/docs/en/options.html) +for more information on the available options. + +## Publishing the Documentation + +The documentation is published automatically when changes are merged into the `main` branch. A GitHub Action workflow +is triggered to build the documentation and push it to the `gh-pages` branch. The published documentation is hosted +on GitHub Pages. diff --git a/calm-calatheas/docs/contributor-guide/index.md b/calm-calatheas/docs/contributor-guide/index.md new file mode 100644 index 00000000..4623788d --- /dev/null +++ b/calm-calatheas/docs/contributor-guide/index.md @@ -0,0 +1,6 @@ +# Contributor Guide + +This guide provides information on how to contribute to the project, including setting up your development environment, +using version control, and contributing to the documentation. + +It is intended for project members as well as the code jam judges. diff --git a/calm-calatheas/docs/contributor-guide/version-control.md b/calm-calatheas/docs/contributor-guide/version-control.md new file mode 100644 index 00000000..246c0e87 --- /dev/null +++ b/calm-calatheas/docs/contributor-guide/version-control.md @@ -0,0 +1,165 @@ +# Version Control + +Follow the steps below when contributing to the project. These steps ensure that all changes are properly tracked and reviewed. + +## Create a New Branch + +Always create a new branch for your changes. This makes it easier to handle multiple contributions simultaneously. + +??? QUESTION "Why should I create a new branch?" + + Creating a new branch allows you to work on your changes without affecting the `main` branch. This makes it + easier to collaborate with others and keep the codebase clean. + +First, pull the latest changes from the `main` branch: + +```bash +git pull main +``` + +Next, create a new branch with the following command: + +```bash +git checkout -b "" +``` + +Replace `` with a short, descriptive name for your branch. For example, `add-uptime-command`. + +## Commit your Changes + +On your local branch, you can make changes to the code such as adding new features, fixing bugs, or updating documentation. +Once you have made your changes, you can commit them to your branch. + +```bash +git add . +git commit -m "feat: add uptime command" +``` + +Make sure to write a clear and concise commit message that describes the changes you have made. + +??? QUESTION "How often should I commit my changes?" + + It's a good practice to commit your changes often. This allows you to track your progress and revert changes if needed. + +### Automated Checks + +The project includes pre-commit hooks to ensure your code meets the quality standards. These hooks run automatically +before each commit. + +??? QUESTION "What if the pre-commit hooks fail?" + + If the pre-commit hooks fail, you will need to address the issues before committing your changes. Follow the + instructions provided by the pre-commit hooks to identify and fix the issues. + +??? QUESTION "How do I run the pre-commit hooks manually?" + + Pre-commit hooks can also be run manually using the following command: + + ```bash + uv run pre-commit + ``` + +The pre-commit hooks are intended to help us keep the codebase maintainable. If there are rules that you believe +are too strict, please discuss them with the team. + +## Create a Pull Request + +Once you have completed your changes, it's time to create a pull request. A pull request allows your changes to +be reviewed and merged into the `main` branch. + +Before creating a pull request, ensure your branch is up to date with the latest changes from the `main` branch: + +```bash +git pull main +``` + +Next, push your changes to the repository: + +```bash +git push +``` + +Finally, [create a pull request on GitHub](https://github.com/cj12-calm-calatheas/code-jam-12/compare). Select +your branch as the source and the `main` branch as the base. + +Give your pull request a descriptive title that summarizes the changes you have made. In the pull request description, +provide a brief overview of the changes and any relevant information for reviewers. + +??? EXAMPLE "Pull Request Description" + + Here's an example of a good pull request description: + + ```plaintext + # feat: add uptime command + + This pull request adds a new uptime command to display the bot's uptime. + + ## Changes + + - Added a new command to display the bot's uptime + - Updated the help command to include information about the new command + + ## Notes + + - The new command is implemented in a separate file for better organization + - The command has been tested locally and works as expected + ``` + +### Automated Checks + +The same pre-commit hooks that run locally will also run automatically on the pull request. The workflow also +runs the tests to ensure everything is working correctly, and checks the docs for any broken links. + +??? QUESTION "What if the checks fail on the pull request?" + + If the checks fail on the pull request, you will need to address the issues in your branch and push + the changes. The checks will run again automatically. + + Please address any issues identified by the checks before requesting a review. + +## Ask for a Review + +All pull requests should be reviewed by at least one other team member before merging. The reviewer will provide +feedback and suggestions for improvement. + +Once the reviewer approves the pull request, you can merge it into the `main` branch. + +??? QUESTION "How do I request a review?" + + Request a review from a team member by [assigning them as a reviewer](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/requesting-a-pull-request-review) + to your pull request. + +### Giving Feedback + +When providing feedback on a pull request, be constructive and specific. Point out areas for improvement and suggest +possible solutions. If you have any questions or concerns, don't hesitate to ask the author for clarification. + +A code review should focus on the following aspects: + +- Correctness and functionality +- Code quality and readability +- Adherence to the project guidelines + +??? EXAMPLE "Good Code Review Feedback" + + Here are some examples of good code review feedback: + + ```plaintext + - Great work on the new command! The implementation looks good overall. + - I noticed a small typo in the docstring. Could you update it to fix the typo? + - The logic in the new command is a bit complex. Consider breaking it down into smaller functions for clarity. + - The tests cover most of the functionality, but we are missing a test case for edge case X. Could you add a test for that? + ``` + +Always be respectful and considerate when giving feedback. Remember that the goal is to improve the code and help +the author grow as a developer. + +!!! SUCCESS "Be Positive" + + Don't forget to acknowledge the positive aspects of the contribution as well! + +## Merge the Pull Request + +Once the pull request has been approved and all checks have passed, you can merge it into the `main` branch. +To merge the pull request, click the "Merge" button on the pull request page. After merging, your branch will be automatically +deleted. diff --git a/calm-calatheas/docs/design/backend.md b/calm-calatheas/docs/design/backend.md new file mode 100644 index 00000000..c0c808c1 --- /dev/null +++ b/calm-calatheas/docs/design/backend.md @@ -0,0 +1,63 @@ +# Backend + +The Pokedexter backend is primarily responsible for serving the machine learning model for generating descriptions and serving +the static files for the frontend application. + +## Web Server + +The web server is built with [Starlette](https://www.starlette.io/), a lightweight [ASGI](https://en.wikipedia.org/wiki/Asynchronous_Server_Gateway_Interface) +framework for Python web applications. Starlette offers essential features for handling HTTP requests, routing, and middleware, +making it a straightforward choice for our backend. While we considered [FastAPI](https://fastapi.tiangolo.com/), we ultimately +selected Starlette for its simplicity and minimalism. + +The server serves static frontend files and exposes two main endpoints: + +- **Description Generation:** An endpoint that uses the machine learning model to generate Pokémon descriptions. +- **Healthcheck:** An endpoint for monitoring the server’s status. Used by the Docker container to ensure the service + is running. + +This setup keeps the backend focused and efficient, aligning with our design goals. + +## Description Generation + +The backend hosts a machine learning model that generates Pokémon descriptions. This model is accessed through the Description +Generation endpoint, allowing the frontend to request descriptions based on captions created in the browser. + +We use the [`Qwen/Qwen3-1.7B`](https://huggingface.co/Qwen/Qwen3-1.7B) model, a general-purpose text generator. After +testing various prompts and settings, we found that this model produces high-quality Pokémon descriptions from image captions. +However, the model is quite large. While it can run on a laptop or desktop for limited use, it does not scale well to many +users and user experience will degrade under heavy load. + +For best results, we recommend running the model on a machine that has a [GPU with CUDA support](https://en.wikipedia.org/wiki/CUDA#GPUs_supported) +(the oldest version we tested was CUDA 6.5 on an NVIDIA GeForce GTX 1080ti) and 16GB of RAM. In our experience, generating +a description typically takes less than a minute. + +We would have preferred to use a more lightweight model that could run directly in the browser. However, the lightweight +models we tested did not generate high-quality Pokémon descriptions. One possible solution would be to fine-tune or train +a smaller model specifically for this task, but this would have required more time and a dataset of high-quality Pokémon +descriptions, which were beyond our resources for the code jam. + +## Reverse Proxy + +We recommend deploying Pokedexter behind a [reverse proxy](https://en.wikipedia.org/wiki/Reverse_proxy) acting as a +[TLS termination proxy](https://en.wikipedia.org/wiki/TLS_termination_proxy). Both the camera and PWA features require +a [secure browser context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), which is only available +when the app is served over HTTPS. + +!!! DANGER "Secure browser context required" + + The camera and PWA features will not work outside of a secure browser context! + +A reverse proxy is not included in our stack, as most users will already have their own solution or use a managed reverse +proxy provided by their cloud platform. The deployment guide includes instructions for setting up a TLS termination proxy +with [Caddy](https://caddyserver.com/). + +If you do not have a valid TLS certificate, the app can only be used on `localhost`, since browsers treat it as a secure +context. + +## Docker + +Pokedexter can be easily deployed using [Docker](https://www.docker.com/). We provide a `Dockerfile` that sets up the necessary +environment and dependencies for running the application. The Docker image includes the web server, the machine learning +model, and all static files needed for the frontend. We do not publish the Docker image to a public registry, so users +will need to build it locally. diff --git a/calm-calatheas/docs/design/design-goals.md b/calm-calatheas/docs/design/design-goals.md new file mode 100644 index 00000000..468e906e --- /dev/null +++ b/calm-calatheas/docs/design/design-goals.md @@ -0,0 +1,13 @@ +# Design Goals + +Our primary design goals for the code jam project were: + +- **Browser-first:** The application should run entirely in the browser, using technologies like PyScript and Pyodide and + leveraging the browser APIs where possible. +- **Python-centric:** The project should be developed mainly in Python, minimizing reliance on other languages. +- **Modern web architecture:** The app’s structure should follow typical web application patterns, allowing us to assess + Python’s suitability for building modern web apps. +- **Mobile-friendly:** The application should provide a seamless experience on mobile devices, similar to an authentic Pokédex. + +As the project progressed, we aimed to add **offline capability** as a key feature. However, this goal was only partially +achieved, as some components ultimately did not run in the browser. diff --git a/calm-calatheas/docs/design/index.md b/calm-calatheas/docs/design/index.md new file mode 100644 index 00000000..5642df1f --- /dev/null +++ b/calm-calatheas/docs/design/index.md @@ -0,0 +1,4 @@ +# Design + +This section describes the design of the Pokedexter application. It is intended for developers, the code jam judges, and +anyone interested in understanding the architecture, components, and design decisions behind the system. diff --git a/calm-calatheas/docs/design/system-overview.md b/calm-calatheas/docs/design/system-overview.md new file mode 100644 index 00000000..a730c9cb --- /dev/null +++ b/calm-calatheas/docs/design/system-overview.md @@ -0,0 +1,12 @@ +# System Overview + +The diagram below illustrates the architecture of the Pokedexter system: + +![System Overview](../assets/design/overview.drawio) + +Most of the system runs in the browser, using technologies such as PyScript and Pyodide, and makes extensive use of browser +APIs. Features like object recognition and the database, which are often implemented as backend services, are handled in-browser +where possible. + +The backend is intentionally minimal to align with our design goals and the code jam theme. It mainly serves static frontend +assets and processes tasks that cannot be handled in the browser due to resource constraints. diff --git a/calm-calatheas/docs/design/web-app.md b/calm-calatheas/docs/design/web-app.md new file mode 100644 index 00000000..b60f3c17 --- /dev/null +++ b/calm-calatheas/docs/design/web-app.md @@ -0,0 +1,166 @@ +# Web App + +The web app is the core of the system, responsible for the user experience, interface, and interactions. Built with [PyScript](https://pyscript.net/) +and [Pyodide](https://pyodide.org/en/stable/), it runs Python code directly in the browser. The app includes a presentation +layer and modules for camera access, image processing, and database management. + +## Presentation Layer + +The presentation layer manages the user interface and interactions. It is structured as a [Single Page Application (SPA)](https://en.wikipedia.org/wiki/Single-page_application) +and follows a [Model-View-Presenter (MVP)](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93presenter) architecture, +which separates UI components from business logic and state management. This design keeps the application architecture +similar to modern web frameworks like React or Angular, aligning with our key design goals. + +[![Model-View-Presenter Pattern](../assets/design/mvp.png)](../assets/design/mvp.png) + +### Components + +Views are organized as individual components, each responsible for a specific part of the user interface. They are all grouped +in the [`components`][app.frontend.components] module and inherit from a base [`Component`][app.frontend.base.component.Component] +class that implements rendering logic and lifecycle methods. + +The lifecycle of a component includes _initialization_, _rendering_, and _destruction_. This lifecycle is typically managed +by a parent component that coordinates these phases. + +```mermaid +graph TB + subgraph Component Lifecycle + direction LR + A[Initialization] --> B[Rendering] + B --> C[Destruction] + end +``` + +**Initialization**: The component is created but not yet displayed. This phase sets up the component’s internal state +and prepares it for rendering. + +**Rendering**: The component generates its HTML and inserts it into the DOM at its designated root element. Most components +also attach event listeners at this stage to handle user interactions and state changes. + +**Destruction**: The component is removed from the DOM and any resources it used are released. This includes detaching +event listeners and cleaning up internal state. + +Lifecycle hooks enable components to run custom logic before and after each phase of their lifecycle. These hooks provide +flexibility for setup, teardown, and responding to changes during initialization, rendering, and destruction. + +Components define their HTML structure using text-based templates, which are processed by a simple, custom-built templating +engine. This engine is intentionally minimal and does not support advanced features such as conditionals or loops, unlike +the templating systems in frameworks like React or Angular. Looking ahead, the upcoming [template strings](https://peps.python.org/pep-0750) +feature in Python 3.14 is expected to improve Python’s native HTML templating capabilities, as supporting this use case +is specifically mentioned in the PEP. + +### Services + +Services are responsible for providing business logic and state management for the application. They encapsulate the core +functionality and can be reused across different components. Most services exist globally and can be accessed by any component +that needs them, although they can also be owned by specific components if needed. + +All services are organized into the [`services`][app.frontend.services] module and inherit from the [`Service`][app.frontend.base.service.Service] +base class, which provides lifecycle management for all services. + +A service’s lifecycle consists of two main phases: _initialization_ and _destruction_. Global services are managed centrally, +while services owned by specific components are managed by those components. + +```mermaid +graph TB + subgraph Service Lifecycle + direction LR + A[Initialization] --> B[Destruction] + end +``` + +**Initialization**: The service is instantiated and its internal state is set up. This may include fetching initial data +or configuring dependencies. + +**Destruction**: The service is cleaned up when it is no longer needed. This involves releasing resources such as event +listeners, database sessions, or network connections. + +Lifecycle hooks allow services to execute custom logic before and after each phase, providing flexibility for setup, teardown, +and responding to changes during initialization and destruction. + +### State Management + +State is managed by services, which implement the observer pattern. This allows components to subscribe to state changes +and react as needed, decoupling state management from the presentation layer and ensuring a clean separation of concerns. + +Unlike frameworks such as React or Angular, which provide built-in concepts like signals and effects, PyScript does not +offer these features natively. Instead, we use the [reactivex](https://github.com/ReactiveX/RxPY) library to manage asynchronous +data streams and events, enabling reactive programming patterns within the application. + +## Camera + +The camera service manages access to the user's camera and handles image capture and permissions. + +Integrating camera functionality directly into the application ensures a seamless experience for users as they explore +and search for Pokémon, without needing to switch to a separate camera app. This approach also accommodates devices that +may not have a dedicated camera application. + +For users without a camera, or for those who wish to analyze existing images, the application also provides an image upload +feature. This allows users to select and process images from their device, ensuring accessibility and flexibility for all +users. + +## Object Recognition + +The object recognition service identifies and classifies objects within images, then generates captions describing them. +These captions are passed to the description generation model, which creates Pokémon-themed descriptions based on the recognized +objects. + +The object recognition service uses the [`Xenova/vit-gpt2-image-captioning`](https://huggingface.co/Xenova/vit-gpt2-image-captioning) +model, an image-to-text machine learning model. This lightweight model runs well even on devices with limited computing +power, such as smartphones. While it provides reasonable results for image captioning tasks, our experimentation shows +that its accuracy may vary depending on the input. + +We originally planned to use the [`transformers`](https://pypi.org/project/transformers/) library for Python to run the +model. Unfortunately, some of its dependencies are incompatible with Pyodide because they cannot be compiled to WebAssembly. + +We considered two options: moving the object recognition service to the backend to keep it in Python, or running the model +in the browser using [`transformers.js`](https://huggingface.co/docs/transformers.js/en/index) and accessing it from Python +via Pyodide. To keep with the spirit of the code jam, we chose to run the model entirely in the browser using `transformers.js`. + +While we would have preferred to leverage Python’s rich machine learning ecosystem for this feature, current limitations +prevent us from doing so. Enabling advanced machine learning capabilities directly in the browser would make Python a much +stronger choice for web development and help it move beyond experimental or hobby projects. + +There is a noticeable slowdown during model initialization and occasionally when processing images. To address this, we +tried running the model in a separate [web worker](https://docs.pyscript.net/2025.8.1/user-guide/workers/) to offload +processing from the main thread. However, we encountered errors loading the model in the worker and were unable to resolve +them within our timeframe. With more time, we believe this issue could be solved, but we chose to focus on other features. + +## Database + +The database is responsible for storing each user's Pokémon collection. + +We use [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) as our database solution because it +is a widely supported web standard that offers a robust, asynchronous API for managing large amounts of structured data +directly in the browser. + +Storing each user's collection in a client-side database means the data is kept locally in their browser. This approach +has some limitations: collections cannot be shared across devices or with other users, and all data will be lost if the +user clears their browser storage. We accept these trade-offs because using a browser-based database fits the code jam +theme and our design goals. For a future version, we would consider synchronizing the database with a remote server to +enable cross-device access and backups. + +## Progressive Web App + +Pokedexter is a [Progressive Web App (PWA)](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps), meaning +it can be installed on a user's device and run like a native app. When installed, it operates in standalone mode, providing +easier access and a familiar user experience. + +While some PWAs offer offline capabilities, Pokedexter currently relies on the backend for description generation and requires +an internet connection. A key improvement could be to explore or train a model that can run entirely on-device, enabling +a fully offline experience. This would enhance the fantasy of carrying a real Pokédex with you outdoors or in nature. + +## Browser Support + +Pokedexter is tested and works reliably on all major modern browsers, including Chromium-based browsers (such as Chrome +and Edge), WebKit-based browsers (Safari), and Firefox. To ensure consistent behavior across environments, we use [Playwright](https://playwright.dev/python/) +together with [Pytest](https://pytest.org/) and [Testcontainers](https://testcontainers.com/) for automated cross-browser +testing in a production-like environment. + +You can find the test scenarios in the `tests` folder. + +## CSS Framework + +We chose [Bulma](https://bulma.io/) as our CSS framework because it is a modern, responsive framework that makes it easy +to create visually appealing and responsive layouts. The choice for a CSS framework rather than writing custom styles from +scratch saved us some time to focus on the functionality of the app. diff --git a/calm-calatheas/docs/index.md b/calm-calatheas/docs/index.md new file mode 100644 index 00000000..14fce7cd --- /dev/null +++ b/calm-calatheas/docs/index.md @@ -0,0 +1,63 @@ +# Pokedexter + +_Who's that Pokémon?_ + +Ever wondered if your cat could be a Pokémon? Curious about what’s hiding in your cupboard? **Pokedexter** is an AI-powered +Pokédex that helps you discover Pokémon wherever you are. Just snap a photo, and Pokedexter will identify the Pokémon—maybe +even ones you never expected! + +Open Pokedexter on your phone and start discovering Pokémon all around you! + +
+ + + Shadowclaw + + + + AquaPuff + + + + ChronoClock + + + + Lumina + + +
+ +## Features + +- Identify Pokémon anywhere using AI image recognition and description generation. +- Build your personal Pokémon collection—your progress is saved locally. +- Use Pokedexter on any device that supports a modern web browser. +- Designed for mobile use with a responsive layout. +- Installable as a Progressive Web App (PWA). +- Powered by a technology stack that’s 99% Python. + +## Fit with the theme + +The theme for this year's code jam is to write a **browser-based** Python application that is **the wrong tool for the job**. + +Pokedexter is an intentionally out of place tool for Pokémon identification. It neither identifies real-world objects nor +actual Pokémon. Instead, it invites users to view their everyday surroundings through a playful, imaginative Pokémon perspective. + +Most modern web apps are built with JavaScript frameworks like React or Angular. With Pokedexter, we set out to see how +closely we could replicate a typical web app experience using Python instead of JavaScript. Our goal was to apply common +web development architecture and design patterns, while also taking advantage of Python’s strengths in data processing +and machine learning. + +## About the team + +This project has been built by the Calm Calatheas team for the [Python Discord Code Jam 2025](https://pythondiscord.com/events/code-jams/12/). +Please feel free to reach out if you have any questions, or need a hand with anything! + +| | Name | Contributions | +| --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------ | +| [TFBlunt](https://github.com/thijsfranck) | [TFBlunt](https://github.com/thijsfranck) | Team Lead, Frontend, Description Generation, Docs | +| [leoluy](https://github.com/leolhuile) | [leoluy](https://github.com/leolhuile) | Description Generation, Model Selection, Frontend Mockup, Ideation | +| [zike01](https://github.com/Zike01) | [Zike01](https://github.com/Zike01) | Ideation | +| [Flonc](https://github.com/FloncDev) | [Flonc](https://github.com/FloncDev) | Initial Frontend Prototype | +| [esmaycat](https://github.com/esmaycat) | [esmaycat](https://github.com/esmaycat) | Object Detection, Transformers.js integration, Favourites Feature | diff --git a/calm-calatheas/docs/setup-guide.md b/calm-calatheas/docs/setup-guide.md new file mode 100644 index 00000000..5fb64095 --- /dev/null +++ b/calm-calatheas/docs/setup-guide.md @@ -0,0 +1,163 @@ +# Setup Guide + +This guide provides a step-by-step approach on how to run Pokedexter. + +## Set up the development environment + +Follow the instructions in the [development environment setup guide](./contributor-guide/development-environment.md) to +set up your local environment. + +## Run the app locally + +The easiest way to run Pokedexter locally is to use the included [`taskipy`](https://pypi.org/project/taskipy/) configuration. +Run the following command: + +```bash +uv run task serve +``` + +This runs a development server that you can use to access the app from your local machine. This is great for trying out +the app yourself on the device where you are running the server. + +Keep reading if you'd like to deploy Pokedexter for production use, or if you'd like to access the app from another device +like a mobile phone or tablet. + +## Local HTTPS for Mobile Testing + +You may just want to test the application on a mobile device without setting up a full reverse proxy. Here's how to create a simple, self-signed HTTPS server for local testing. + +First, you'll need to create your own SSL/TLS certificate. This certificate will be used to encrypt the connection between your computer and the mobile device. To generate it, run the following command in the project's root directory: + +```bash +openssl req -x509 -keyout key.pem -out cert.pem -nodes +``` + +The command will prompt you for some information. When asked for the "Common Name", enter your computer's local IP address. For all other prompts, press Enter to accept the default values. This process will generate two files in your project's root directory: `key.pem` (your private key) and `cert.pem` (your self-signed certificate). + +You can now start the server with the following command: + +```bash +uv run uvicorn calm_calatheas.app:app \ + --host "0.0.0.0" \ + --port 4443 \ + --ssl-keyfile key.pem \ + --ssl-certfile cert.pem +``` + +Because the certificate is self-signed (i.e. not issued by a trusted authority), your browser will likely display a "certificate not trusted" warning. This is expected. You can safely bypass this warning to continue to your application. + +## Build the Docker image + +The easiest way to deploy Pokedexter is to use [Docker](https://www.docker.com/). To deploy Pokedexter, you must first +build the Docker image. + +!!! INFO "Prerequisite" + + Make sure you have [Docker](https://www.docker.com/) installed before proceeding. + +The project has a `taskipy` configuration that makes it easy to build the Docker image. Run the following command: + +```bash +uv run task build-docker +``` + +This first builds a `.whl` file for the project, and then uses that file to build the Docker image based on the included +`Dockerfile`. The docker image will be called `calm-calatheas:latest`. + +## Set the environment variables + +Pokedexter can be configured using environment variables. The following configuration options are available: + +| Environment Variable | Description | Default | +| -------------------- | --------------------------------------- | --------- | +| `HOST` | The address to bind the server to. | `0.0.0.0` | +| `LOG_LEVEL` | The logging level for the application. | `DEBUG` | +| `PORT` | The port to run the server on. | `8000` | +| `STATIC_FILES_PATH` | The path to the static files directory. | `app` | + +!!! NOTE "All settings are optional" + + You can run the app using the default settings without specifying any environment variables. + +See the [`Settings`][calm_calatheas.settings.Settings] documentation for more information. + +## Run the Docker container + +Once the image is built, you can deploy the app to an environment of your choice. + +!!! INFO "Minimum system specs" + + For a minimal deployment, we recommend **2 CPU cores** and **8GB of RAM**. We also recommend a GPU with at least + **4GB of VRAM** and **CUDA 6.5** support or higher. + +**If you are deploying Pokedexter to the cloud**, refer to your cloud provider's documentation on how to deploy a Docker +container. + +**If you are hosting Pokedexter yourself**, you can run the Docker container with the following command: + +```bash +docker run -p 8000:8000 calm-calatheas:latest +``` + +This runs the container and maps the default port `8000` to the host machine, allowing you to access the app at `http://localhost:8000`. + +!!! DANGER "Secure browser context required" + + Both the camera and PWA features require a [secure browser context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts), which is only available when the app is served over HTTPS or on `localhost`. + + Keep reading if your deployment will be accessed outside of `localhost`. + +## Set up a reverse proxy + +We recommend deploying Pokedexter behind a [reverse proxy](https://en.wikipedia.org/wiki/Reverse_proxy) acting as a +[TLS termination proxy](https://en.wikipedia.org/wiki/TLS_termination_proxy). + +!!! INFO "Prerequisite" + + Make sure you have a registered [domain name](https://en.wikipedia.org/wiki/Domain_name) and that you have access + to the [DNS](https://en.wikipedia.org/wiki/Domain_Name_System) settings for that domain. + +**If you are deploying Pokedexter to the cloud**, we recommend that you use your cloud provider's gateway solution to set +up HTTPS for the app. + +**If you are hosting Pokedexter yourself**, here's a sample `docker-compose.yaml` file using [Caddy](https://caddyserver.com/): + +```yaml +name: pokedexter + +services: + reverse-proxy: + image: caddy:latest + command: caddy reverse-proxy --from :8000 --to app:8000 + depends_on: + app: + condition: service_healthy + ports: + - 8000:8000 + + app: + image: calm-calatheas:latest + ports: + - 8000 +``` + +This configuration sets up Caddy as a reverse proxy for your app, allowing you to access it securely over HTTPS. Caddy +will automatically obtain and renew SSL certificates for your domain using [Let's Encrypt](https://letsencrypt.org/). + +## Set up DNS + +Finally, set up a DNS record for your domain that points to the server where the reverse proxy is running: + +```plaintext +Type: A +Host: +Value: +TTL: 3600 +``` + +Replace `` with the public IP address of the machine running your reverse proxy. This will direct +traffic for `` to your server. + +!!! SUCCESS "Deployment complete" + + You can now access the app from any device at `https://:8000`! diff --git a/calm-calatheas/docs/stylesheets/table.css b/calm-calatheas/docs/stylesheets/table.css new file mode 100644 index 00000000..b7354a34 --- /dev/null +++ b/calm-calatheas/docs/stylesheets/table.css @@ -0,0 +1,11 @@ +/* + * Make tables full width by default + */ + +.md-typeset__table { + min-width: 100%; +} + +.md-typeset table:not([class]) { + display: table; +} diff --git a/calm-calatheas/docs/user-guide.md b/calm-calatheas/docs/user-guide.md new file mode 100644 index 00000000..91f3e734 --- /dev/null +++ b/calm-calatheas/docs/user-guide.md @@ -0,0 +1,151 @@ +# User Guide + +Welcome to the user guide for Pokedexter! This guide will help you get started building your Pokémon collection. + +Open Pokedexter in your favorite web browser to get started! + +!!! TIP "Use Pokedexter on your phone" + + Open Pokedexter on your phone to identify Pokémon wherever you are! + +## Home Page + +The home page is the main screen of the app. Let's go through its step by step: + +
+ + + Home + + +
+ +At the top of the page is a welcome message and some instructions on how to get started. Below that is your Pokémon collection, +which is empty for now. + +The app is telling us to take a picture or upload an image to discover the Pokémon inside. Let's do that now! + +## Taking a Picture + +To start taking a picture, click the camera icon at the bottom of the screen. This will pop up the camera interface, allowing +you to take a picture of the object you would like to identify. + +
+ + + Camera Loading + + + + Camera Active + + +
+ +While loading, you can see the camera interface preparing to take a picture. You may at this point be prompted to allow +camera access if you haven't already done so. + +!!! INFO "Grant Camera Access" + + You will not be able to use the camera feature until you grant access. If you deny access by mistake, you can refresh the page to + be prompted again, or enable it again in your device settings. + +Once ready, the camera image will appear on the screen. You can now take a picture by clicking the **capture** button. +This will process the image and attempt to identify the Pokémon within it. + +!!! TIP "Switching Cameras" + + If your device has more than one camera (such as front and back cameras), you can switch between them by clicking the **switch camera** + button in the camera interface, located next to the **capture** button. + +## Uploading an Image + +If you prefer to upload an image instead of using the camera, you can do so by clicking the **upload** button at the bottom +of the screen. This will open a file dialog, allowing you to select an image file from your device. + +!!! TIP "Access your Camera App" + + If you prefer to use your phone's camera app, it will also be available as an option in the upload dialog. + +Once you have selected an image, the app will process it and attempt to identify the Pokémon within it. + +## Analyzing the Image + +Now, the app will analyze the image and try to detect any Pokémon present. This may take a few moments, so please be patient. +While you wait, you can try to guess which Pokémon it might come up with! + +Once the image has been processed, the app will display the results, showing the identified Pokémon along with their details. + +
+ + + Processing + + + + Results + + +
+ +## Building your Collection + +Congratulations on capturing your first Pokémon! You can now view it in your collection and continue to add more Pokémon +as you discover them. + +!!! INFO "Saving Your Collection" + + Your collection is automatically saved on your device so you don't lose your progress. + +Here's what it looks like when there's a few more Pokémon in your collection: + +
+ + + Collection + + +
+ +## Managing your Collection + +Once you've started building your collection, you may want to show your love for a specific Pokémon. You can mark it as +a favorite by clicking the **favourite** button in the Pokémon details view. A heart icon will appear next to the Pokémon's +name! + +If you ever change your mind, you can also unfavourite a Pokémon by clicking the **unfavourite** button. If you'd like +to remove a Pokémon from your collection entirely, you can do so by clicking the **delete** button at the bottom of the +details view. + +
+ + + Details + + + + Favourite + + +
+ +## Switching the Theme + +By default, the app will use your system's theme preference (light or dark). However, you can change the theme manually +by clicking the **theme** buttons in the app settings. + +You can switch back to your system default by selecting **auto**. + +
+ + + Light Theme + + + + Dark Theme + + +
+ +Happy exploring! diff --git a/calm-calatheas/mkdocs.yaml b/calm-calatheas/mkdocs.yaml new file mode 100644 index 00000000..32b5e423 --- /dev/null +++ b/calm-calatheas/mkdocs.yaml @@ -0,0 +1,116 @@ +copyright: © 2025 Calm Calatheas 🪴 +repo_name: cj12-calm-calatheas/code-jam-12 +repo_url: https://github.com/cj12-calm-calatheas/code-jam-12 +site_name: Pokedexter +site_url: https://cj12-calm-calatheas.github.io/code-jam-12/ +dev_addr: 127.0.0.1:9000 + +theme: + favicon: assets/logo-16x16.png + logo: assets/logo-128x128.png + name: material + search: true + + features: + - content.code.copy + - navigation.indexes + - navigation.instant + - navigation.instant.progress + - navigation.path + - navigation.sections + - navigation.tabs + - search.highlight + + icon: + repo: fontawesome/brands/github + + palette: + - media: "(prefers-color-scheme)" + toggle: + icon: material/brightness-auto + name: Switch to light mode + - media: "(prefers-color-scheme: light)" + primary: white + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + primary: black + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to system preference + +markdown_extensions: + - admonition + - attr_list + - codehilite + - md_in_html + - pymdownx.details + - pymdownx.snippets + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.snippets + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + clickable_checkbox: true + custom_checkbox: true + - toc: + permalink: true + +nav: + - User Guide: user-guide.md + - Setup Guide: setup-guide.md + - Contributor Guide: + - contributor-guide/index.md + - Development Environment: contributor-guide/development-environment.md + - Version Control: contributor-guide/version-control.md + - Documentation: contributor-guide/documentation.md + - Design: + - design/index.md + - Design Goals: design/design-goals.md + - System Overview: design/system-overview.md + - Web App: design/web-app.md + - Backend: design/backend.md + - Code: + - code/index.md + - Frontend: code/frontend.md + - Backend: code/backend.md + +plugins: + - autorefs + - drawio + - mkdocstrings: + handlers: + python: + options: + show_root_heading: true + show_object_full_path: false + show_symbol_type_heading: true + show_symbol_type_toc: true + show_signature: true + separate_signature: true + show_signature_annotations: true + signature_crossrefs: true + show_source: false + show_if_no_docstring: true + show_docstring_examples: true + - search + - tags + +extra: + generator: false + social: + - icon: fontawesome/brands/github + link: https://github.com/cj12-calm-calatheas/code-jam-12 + +extra_css: + - stylesheets/table.css diff --git a/calm-calatheas/package-lock.json b/calm-calatheas/package-lock.json new file mode 100644 index 00000000..dce2767f --- /dev/null +++ b/calm-calatheas/package-lock.json @@ -0,0 +1,454 @@ +{ + "name": "calm-calatheas", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "calm-calatheas", + "devDependencies": { + "nodemon": "3.1.10", + "prettier": "3.6.2", + "prettier-plugin-sort-json": "4.1.1", + "prettier-plugin-toml": "2.0.6" + } + }, + "node_modules/@taplo/core": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@taplo/core/-/core-0.2.0.tgz", + "integrity": "sha512-r8bl54Zj1In3QLkiW/ex694bVzpPJ9EhwqT9xkcUVODnVUGirdB1JTsmiIv0o1uwqZiwhi8xNnTOQBRQCpizrQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@taplo/lib": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@taplo/lib/-/lib-0.5.0.tgz", + "integrity": "sha512-+xIqpQXJco3T+VGaTTwmhxLa51qpkQxCjRwezjFZgr+l21ExlywJFcDfTrNmL6lG6tqb0h8GyJKO3UPGPtSCWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@taplo/core": "^0.2.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-sort-json": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-sort-json/-/prettier-plugin-sort-json-4.1.1.tgz", + "integrity": "sha512-uJ49wCzwJ/foKKV4tIPxqi4jFFvwUzw4oACMRG2dcmDhBKrxBv0L2wSKkAqHCmxKCvj0xcCZS4jO2kSJO/tRJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, + "node_modules/prettier-plugin-toml": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/prettier-plugin-toml/-/prettier-plugin-toml-2.0.6.tgz", + "integrity": "sha512-12N/wBuHa9jd/KVy9pRP20NMKxQfQLMseQCt66lIbLaPLItvGUcSIryE1eZZMJ7loSws6Ig3M2Elc2EreNh76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@taplo/lib": "^0.5.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + }, + "peerDependencies": { + "prettier": "^3.0.3" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/calm-calatheas/package.json b/calm-calatheas/package.json new file mode 100644 index 00000000..973ab7f8 --- /dev/null +++ b/calm-calatheas/package.json @@ -0,0 +1,10 @@ +{ + "devDependencies": { + "nodemon": "3.1.10", + "prettier": "3.6.2", + "prettier-plugin-sort-json": "4.1.1", + "prettier-plugin-toml": "2.0.6" + }, + "name": "calm-calatheas", + "private": true +} diff --git a/calm-calatheas/pyproject.toml b/calm-calatheas/pyproject.toml new file mode 100644 index 00000000..2bda1094 --- /dev/null +++ b/calm-calatheas/pyproject.toml @@ -0,0 +1,89 @@ +[project] +authors = [{ name = "esmecat" }, { name = "floncdev" }, { name = "leoluy" }, { name = "tfblunt" }, { name = "zike01" }] +description = "This is the project of the Calm Calatheas team for the Python Discord Code Jam 2025" +name = "calm-calatheas" +readme = "README.md" +requires-python = ">=3.13" +version = "0.0.0" + +dependencies = [ + "accelerate~=1.10", + "kernels~=0.9", + "pydantic~=2.11", + "pydantic-settings~=2.10", + "starlette~=0.47", + "transformers~=4.55", + "torch~=2.6", + "uvicorn~=0.35.0", +] + +[dependency-groups] +dev = [ + "bitsandbytes~=0.47", + "mkdocs~=1.6", + "mkdocs-drawio~=1.11", + "mkdocs-material~=9.6", + "mkdocstrings~=0.29", + "mkdocstrings-python~=1.16", + "playwright~=1.54", + "pre-commit~=4.2", + "pydeps~=3.0", + "pyodide-py~=0.28", + "pyright==1.1.403", + "pyscript~=0.3", + "pytest~=8.4", + "pytest-asyncio~=1.0", + "pytest-playwright~=0.7", + "pytest-sugar~=1.0", + "python-dotenv~=1.1", + "reactivex~=4.0", + "ruff~=0.12", + "taskipy~=1.14", + "testcontainers~=4.12", +] + +[tool.pyright] +exclude = [".venv"] +pythonVersion = "3.13" +reportMissingModuleSource = "none" # Pyodide "js" module is dynamic. +reportUnnecessaryTypeIgnoreComment = "error" +typeCheckingMode = "standard" +venv = ".venv" +venvPath = "." + +[tool.pytest.ini_options] +addopts = "--browser chromium --browser firefox --browser webkit --tracing retain-on-failure" +asyncio_default_fixture_loop_scope = "session" +asyncio_mode = "auto" +required_plugins = ["pytest-asyncio"] +testpaths = ["tests"] + +[tool.ruff] +line-length = 119 +target-version = "py313" + +[tool.ruff.lint] +ignore = ["D100", "D104", "D105", "D212", "D107", "FIX002", "LOG015", "PT001", "UP007", "UP045", "S311"] +select = ["ALL"] + +[tool.ruff.lint.per-file-ignores] +"**/conftest.py" = ["INP001"] +"**/test__*.py" = ["INP001", "S101", "PLR2004"] +"app/calm_calatheas/services/image_captioner.py" = ["RUF006"] +"app/frontend/components/description_dropdown.py" = ["SLF001"] +"app/main.py" = ["INP001"] +"typings/js.pyi" = ["ALL"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.setuptools] +packages = ["calm_calatheas"] + +[tool.taskipy.tasks] +build = "uv build" +build-docker = "task build && docker build . -t calm-calatheas:latest" +build-docs = "mkdocs build --strict" +docs = "mkdocs serve" +serve = "python -m calm_calatheas --log_level DEBUG" +test = "pytest" diff --git a/calm-calatheas/tests/assets/elephant.jpg b/calm-calatheas/tests/assets/elephant.jpg new file mode 100644 index 00000000..63321aeb Binary files /dev/null and b/calm-calatheas/tests/assets/elephant.jpg differ diff --git a/calm-calatheas/tests/conftest.py b/calm-calatheas/tests/conftest.py new file mode 100644 index 00000000..47a803f1 --- /dev/null +++ b/calm-calatheas/tests/conftest.py @@ -0,0 +1,49 @@ +from collections.abc import Generator +from pathlib import Path + +import pytest +from playwright.sync_api import Page, expect +from testcontainers.compose import DockerCompose + +DESCRIPTION_GENERATED_TIMEOUT_MS = 600000 +CAPTION_MODEL_LOADED_TIMEOUT_MS = 30000 +PYSCRIPT_READY_TIMEOUT_MS = 30000 + + +@pytest.fixture(scope="session") +def compose() -> Generator[DockerCompose]: + """Return a Docker Compose instance.""" + with DockerCompose(context=Path(__file__).parent.absolute(), build=True) as compose: + yield compose + + +@pytest.fixture(scope="session") +def base_url(compose: DockerCompose) -> str: + """Return the base URL for the application.""" + port = compose.get_service_port("app", 8000) + return f"http://localhost:{port}" + + +@pytest.fixture() +def app(base_url: str, page: Page) -> Page: + """Navigate to the home page, wait for PyScript load and return the page instance.""" + page.goto(base_url) + + page.wait_for_event( + event="console", + predicate=lambda event: "PyScript Ready" in event.text, + timeout=PYSCRIPT_READY_TIMEOUT_MS, + ) + + return page + + +@pytest.fixture() +def model_loaded(app: Page) -> Page: + """Wait for the caption model to be loaded.""" + notification = app.get_by_text("Loading the model for generating captions") + + expect(notification).to_be_visible() + expect(notification).not_to_be_visible(timeout=CAPTION_MODEL_LOADED_TIMEOUT_MS) + + return app diff --git a/calm-calatheas/tests/docker-compose.yaml b/calm-calatheas/tests/docker-compose.yaml new file mode 100644 index 00000000..653046d6 --- /dev/null +++ b/calm-calatheas/tests/docker-compose.yaml @@ -0,0 +1,7 @@ +name: calm-calatheas-test + +services: + app: + image: calm-calatheas:latest + ports: + - 8000 diff --git a/calm-calatheas/tests/test__calm_calatheas.py b/calm-calatheas/tests/test__calm_calatheas.py new file mode 100644 index 00000000..492fe9da --- /dev/null +++ b/calm-calatheas/tests/test__calm_calatheas.py @@ -0,0 +1,218 @@ +import re +from pathlib import Path + +import pytest +from conftest import DESCRIPTION_GENERATED_TIMEOUT_MS, PYSCRIPT_READY_TIMEOUT_MS +from playwright.sync_api import Page, expect + + +def test__main_page_has_welcome_message(app: Page) -> None: + """ + Test that the main page has a welcome message. + + Asserts: + - The welcome message is visible on the page. + """ + expect(app.get_by_text(re.compile("Welcome to your Pokedex!"))).to_be_visible() + + +def test__default_theme_is_system_preferred(app: Page) -> None: + """ + Test that the default theme is the system preferred theme. + + Asserts: + - The system preferred theme is applied by default. + """ + expect(app.locator("html")).not_to_have_attribute("data-theme", re.compile("dark|light")) + + +def test__switch_to_dark_theme(app: Page) -> None: + """ + Test that switching to the dark theme works. + + Asserts: + - The dark theme is applied when the user selects it. + """ + theme_selector = app.locator(".navbar-item", has_text="Theme") + expect(theme_selector).to_be_visible() + theme_selector.hover() + + dark_mode_selector = theme_selector.locator(".navbar-item", has_text="Dark") + expect(dark_mode_selector).to_be_visible() + dark_mode_selector.click() + + expect(app.locator("html")).to_have_attribute("data-theme", "dark") + + +def test__switch_to_light_theme(app: Page) -> None: + """ + Test that switching to the light theme works. + + Asserts: + - The light theme is applied when the user selects it. + """ + theme_selector = app.locator(".navbar-item", has_text="Theme") + expect(theme_selector).to_be_visible() + theme_selector.hover() + + light_mode_selector = theme_selector.locator(".navbar-item", has_text="Light") + expect(light_mode_selector).to_be_visible() + light_mode_selector.click() + + expect(app.locator("html")).to_have_attribute("data-theme", "light") + + +def test__switch_to_system_theme(app: Page) -> None: + """ + Test that switching to the system theme works. + + Asserts: + - The system theme is applied when the user selects it. + """ + theme_selector = app.locator(".navbar-item", has_text="Theme") + expect(theme_selector).to_be_visible() + theme_selector.hover() + + # First switch to light theme to ensure a theme is applied + dark_mode_selector = theme_selector.locator(".navbar-item", has_text="Dark") + expect(dark_mode_selector).to_be_visible() + dark_mode_selector.click() + + expect(app.locator("html")).to_have_attribute("data-theme", "dark") + + # Next switch (back) to the system theme + auto_mode_selector = theme_selector.locator(".navbar-item", has_text="Auto") + expect(auto_mode_selector).to_be_visible() + auto_mode_selector.click() + + expect(app.locator("html")).not_to_have_attribute("data-theme", re.compile("dark|light")) + + +def test__theme_is_restored_after_refresh(app: Page) -> None: + """ + Test that the selected theme is restored after a page refresh. + + Asserts: + - The theme remains consistent after refreshing the page. + """ + # Switch to dark theme + theme_selector = app.locator(".navbar-item", has_text="Theme") + expect(theme_selector).to_be_visible() + theme_selector.hover() + + dark_mode_selector = theme_selector.locator(".navbar-item", has_text="Dark") + expect(dark_mode_selector).to_be_visible() + dark_mode_selector.click() + + expect(app.locator("html")).to_have_attribute("data-theme", "dark") + + # Refresh the page + app.reload() + + app.wait_for_event( + event="console", + predicate=lambda event: "PyScript Ready" in event.text, + timeout=PYSCRIPT_READY_TIMEOUT_MS, + ) + + # Check that the dark theme is still applied + expect(app.locator("html")).to_have_attribute("data-theme", "dark") + + +@pytest.mark.parametrize( + "path", + [ + Path(__file__).parent / "assets/elephant.jpg", + ], +) +def test__description_is_generated_after_uploading_an_image(model_loaded: Page, path: Path) -> None: + """ + Test that a description is generated after uploading an image through the file input. + + Asserts: + - The description is generated and displayed after the image is uploaded. + - A placeholder is shown while the description is being generated. + """ + model_loaded.locator("input[type='file']").set_input_files(path) + + # Expect a placeholder to appear while the description is being generated + placeholder = model_loaded.locator(".pokemon-description", has=model_loaded.locator(".is-skeleton")) + expect(placeholder).to_be_visible() + + # Wait for the description to be generated + description = model_loaded.locator(".pokemon-description", has_not=model_loaded.locator(".is-skeleton")) + expect(description).to_be_visible(timeout=DESCRIPTION_GENERATED_TIMEOUT_MS) + + +@pytest.mark.parametrize( + "path", + [ + Path(__file__).parent / "assets/elephant.jpg", + ], +) +def test__description_is_still_available_after_refresh(model_loaded: Page, path: Path) -> None: + """ + Test that the generated description is still available after refreshing the page. + + Asserts: + - The description is still visible after a page refresh. + """ + model_loaded.locator("input[type='file']").set_input_files(path) + + # Expect a placeholder to appear while the description is being generated + placeholder = model_loaded.locator(".pokemon-description", has=model_loaded.locator(".is-skeleton")) + expect(placeholder).to_be_visible() + + # Wait for the description to be generated + description = model_loaded.locator(".pokemon-description", has_not=model_loaded.locator(".is-skeleton")) + expect(description).to_be_visible(timeout=DESCRIPTION_GENERATED_TIMEOUT_MS) + + # Refresh the page + model_loaded.reload() + + model_loaded.wait_for_event( + event="console", + predicate=lambda event: "PyScript Ready" in event.text, + timeout=PYSCRIPT_READY_TIMEOUT_MS, + ) + + # Check that the description is still visible + after_refresh = model_loaded.locator(".pokemon-description", has_not=model_loaded.locator(".is-skeleton")) + expect(after_refresh).to_be_visible() + + +@pytest.mark.parametrize( + "path", + [ + Path(__file__).parent / "assets/elephant.jpg", + ], +) +def test__description_can_be_deleted(model_loaded: Page, path: Path) -> None: + """ + Test that the generated description can be deleted. + + Asserts: + - The description is removed from the DOM after deletion. + """ + model_loaded.locator("input[type='file']").set_input_files(path) + + # Expect a placeholder to appear while the description is being generated + placeholder = model_loaded.locator(".pokemon-description", has=model_loaded.locator(".is-skeleton")) + expect(placeholder).to_be_visible() + + # Wait for the description to be generated + description = model_loaded.locator(".pokemon-description", has_not=model_loaded.locator(".is-skeleton")) + expect(description).to_be_visible(timeout=DESCRIPTION_GENERATED_TIMEOUT_MS) + + # Open the context menu + context_menu = description.locator(".dropdown") + expect(context_menu).to_be_visible() + context_menu.hover() + + # Delete the description + delete_button = context_menu.locator("button", has_text="Delete") + expect(delete_button).to_be_visible() + delete_button.click() + + # Check that the description is removed + expect(description).not_to_be_visible() diff --git a/calm-calatheas/typings/js.pyi b/calm-calatheas/typings/js.pyi new file mode 100644 index 00000000..e7c68ba8 --- /dev/null +++ b/calm-calatheas/typings/js.pyi @@ -0,0 +1,323 @@ +# ruff: noqa +# Pyodide already has a js.pyi but it is not complete for us, +# and is not even able to be used. + +# Use https://developer.mozilla.org/en-US/docs/Web/API as reference. + +from collections.abc import Callable, Iterable +from typing import Any, Coroutine, Generic, Literal, Sequence, TypeAlias, TypeVar, overload, Self + +from _pyodide._core_docs import _JsProxyMetaClass +from pyodide.ffi import JsArray, JsDomElement as OldJSDomElement, JsException, JsFetchResponse, JsProxy, JsTypedArray +from pyodide.webloop import PyodideFuture + +class JsDomElement(OldJSDomElement): + classList: DOMTokenList + innerText: str + + @property + def children(self) -> Sequence[JsDomElement]: ... + def getAttribute(self, name: str) -> str: ... + def setAttribute(self, name: str, value: str) -> None: ... + def closest(self, selectors: str) -> JsDomElement: ... + def hasAttribute(self, attrName: str) -> bool: ... + def removeAttribute(self, attrName: str) -> None: ... + def getBoundingClientRect(self) -> DOMRect: ... + # These are on Node, which an Element is. + # As far as this project is concerned, they are just elements. + firstChild: JsDomElement | None + nextSibling: JsDomElement | None + def removeChild(self, child: JsDomElement) -> None: ... + def insertBefore(self, newChild: JsDomElement, refChild: JsDomElement) -> None: ... + def contains(self, otherNode: JsDomElement) -> bool: ... + +class JsButtonElement(JsDomElement): + def click(self) -> None: ... + +class JsVideoElement(JsDomElement): + autoplay: bool + controls: bool + src: str + srcObject: MediaStream | None + videoHeight: int + videoWidth: int + def play(self) -> PyodideFuture[None]: ... + +class JsImgElement(JsDomElement): + src: str + width: int + height: int + naturalWidth: int + naturalHeight: int + +class JsInputElement(JsDomElement): + value: str + def click(self) -> None: ... + +class JsFileInputElement(JsInputElement): + files: FileList + +class JsCanvasElement(JsDomElement): + width: int + height: int + @overload + def getContext(self, contextId: Literal["2d"]) -> CanvasRenderingContext2D: ... + @overload + def getContext(self, contextId: str) -> JsProxy: ... + def getContext(self, contextId: str) -> JsProxy: ... + def toBlob(self, callback: Callable[[Blob], None], mimeType: str) -> None: ... + +class JsAnchorElement(JsDomElement): + href: str + target: str + download: str + def click(self) -> None: ... + +class DOMRect(JsProxy): + top: float + left: float + bottom: float + right: float + width: float + height: float + +class CanvasRenderingContext2D(JsProxy): + canvas: JsCanvasElement + @overload + def drawImage(self, image: JsImgElement | JsVideoElement, dx: int, dy: int) -> None: ... + @overload + def drawImage(self, image: JsImgElement | JsVideoElement, dx: int, dy: int, dWidth: int, dHeight: int) -> None: ... + def drawImage( + self, image: JsImgElement | JsVideoElement, dx: int, dy: int, dWidth: int = ..., dHeight: int = ... + ) -> None: ... + def translate(self, x: int | float, y: int | float) -> None: ... + def rotate(self, angle: int | float) -> None: ... + +class DOMTokenList(JsProxy): + def add(self, token: str) -> None: ... + def remove(self, *tokens: str) -> None: ... + def contains(self, token: str) -> bool: ... + +class CSSStyleDeclaration(JsProxy): + def removeProperty(self, name: str) -> None: ... + def setProperty(self, name: str, value: str) -> None: ... + def __setattr__(self, name: str, value: str) -> None: ... + +ElementT = TypeVar("ElementT", bound=JsDomElement) + +class LoadEvent(JsProxy, Generic[ElementT]): + target: ElementT + +class Event(JsProxy): + target: JsDomElement + def preventDefault(self) -> None: ... + +class UIEvent(Event): ... + +class MouseEvent(Event): + relatedTarget: JsDomElement + pageX: float + pageY: float + +class DragEvent(MouseEvent): + dataTransfer: DataTransfer + +class DataTransfer(JsProxy): + dropEffect: str + def setData(self, format: str, data: str) -> None: ... + def setDragImage(self, image: JsDomElement, x: int, y: int) -> None: ... + def getData(self, format: str) -> str: ... + +class FileList: + length: int + def item(self, index: int) -> File: ... + +class File(Blob): + name: str + +class FileReader(JsProxy): + result: str + onload: Callable[[Event], Any] + onerror: Callable[[Event], Any] + @classmethod + def new(cls) -> Self: ... + def readAsDataURL(self, file: File) -> None: ... + +def eval(code: str) -> Any: ... + +# in browser the cancellation token is an int, in node it's a special opaque +# object. +_CancellationToken: TypeAlias = int | JsProxy + +def setTimeout(cb: Callable[[], Any], timeout: int | float) -> _CancellationToken: ... +def clearTimeout(id: _CancellationToken) -> None: ... +def setInterval(cb: Callable[[], Any], interval: int | float) -> _CancellationToken: ... +def clearInterval(id: _CancellationToken) -> None: ... +def fetch( + url: str, + options: JsProxy | None = None, +) -> PyodideFuture[JsFetchResponse]: ... + +indexedDB: Any = ... +type IDBDatabase = Any +localStorage: LocalStorage = ... +sessionStorage: SessionStorage = ... +console: Console = ... +self: Any = ... +window: Any = ... + +class DOMParser(JsProxy): + def parseFromString(self, string: str, type: str) -> document: ... + +class LocalStorage: + def getItem(self, key: str) -> str | None: ... + def setItem(self, key: str, value: str) -> None: ... + def removeItem(self, key: str) -> None: ... + def clear(self) -> None: ... + +class SessionStorage: + def getItem(self, key: str) -> str | None: ... + def setItem(self, key: str, value: str) -> None: ... + def removeItem(self, key: str) -> None: ... + def clear(self) -> None: ... + +class Console: + def log(self, *values: object) -> None: ... + def error(self, *values: object) -> None: ... + +# Shenanigans to convince skeptical type system to behave correctly: +# +# These classes we are declaring are actually JavaScript objects, so the class +# objects themselves need to be instances of JsProxy. So their type needs to +# subclass JsProxy. We do this with a custom metaclass. + +class _JsMeta(_JsProxyMetaClass, JsProxy): ... +class _JsObject(metaclass=_JsMeta): ... + +class XMLHttpRequest(_JsObject): + response: str + + @staticmethod + def new() -> XMLHttpRequest: ... + def open(self, method: str, url: str, sync: bool) -> None: ... + def send(self, body: JsProxy | None = None) -> None: ... + +class Object(_JsObject): + @staticmethod + def fromEntries(it: Iterable[JsArray[Any]]) -> JsProxy: ... + +class Array(_JsObject): + @staticmethod + def new() -> JsArray[Any]: ... + +class ImageData(_JsObject): + @staticmethod + def new(width: int, height: int, settings: JsProxy | None = None) -> ImageData: ... + + width: int + height: int + +class _TypedArray(_JsObject): + @staticmethod + def new( + a: int | Iterable[int | float] | JsProxy | None, + byteOffset: int = 0, + length: int = 0, + ) -> JsTypedArray: ... + +class Uint8Array(_TypedArray): + BYTES_PER_ELEMENT = 1 + +class Float64Array(_TypedArray): + BYTES_PER_ELEMENT = 8 + +class JSON(_JsObject): + @staticmethod + def stringify(a: JsProxy) -> str: ... + @staticmethod + def parse(a: str) -> JsProxy: ... + +class document(_JsObject): + body: JsDomElement + children: list[JsDomElement] + @staticmethod + def getElementById(id: str) -> JsDomElement: ... + @overload + @staticmethod + def createElement(tagName: Literal["a"]) -> JsAnchorElement: ... + @overload + @staticmethod + def createElement(tagName: Literal["img"]) -> JsImgElement: ... + @overload + @staticmethod + def createElement(tagName: Literal["canvas"]) -> JsCanvasElement: ... + @overload + @staticmethod + def createElement(tagName: str) -> JsDomElement: ... + @staticmethod + def createElement(tagName: str) -> JsDomElement: ... + @staticmethod + def appendChild(child: JsDomElement) -> None: ... + +class navigator(_JsObject): + mediaDevices: MediaDevices + +class MediaDevices(_JsObject): + def enumerateDevices(self) -> PyodideFuture[JsArray[MediaDeviceInfo]]: ... + def getUserMedia(self, constraints: dict[str, Any]) -> PyodideFuture[MediaStream]: ... + +class MediaDeviceInfo(_JsObject): + @property + def deviceId(self) -> str: ... + @property + def kind(self) -> str: ... + @property + def label(self) -> str: ... + +class MediaStream(JsProxy): + @property + def active(self) -> bool: ... + @property + def id(self) -> str: ... + def addTrack(self, track: MediaStreamTrack) -> None: ... + def clone(self) -> MediaStream: ... + def getAudioTracks(self) -> JsArray[MediaStreamTrack]: ... + def getTrackById(self, id: str) -> MediaStreamTrack | None: ... + def getTracks(self) -> JsArray[MediaStreamTrack]: ... + def getVideoTracks(self) -> JsArray[MediaStreamTrack]: ... + def removeTrack(self, track: MediaStreamTrack) -> None: ... + +class MediaStreamTrack(_JsObject): + @property + def id(self) -> str: ... + @property + def kind(self) -> str: ... + @property + def label(self) -> str: ... + @property + def muted(self) -> bool: ... + @property + def readyState(self) -> str: ... + def stop(self) -> None: ... + +class ImageCapture(JsProxy): + def takePhoto(self) -> PyodideFuture[Blob]: ... + +class URL(_JsObject): + @staticmethod + def createObjectURL(blob: Blob) -> str: ... + @staticmethod + def revokeObjectURL(url: str) -> None: ... + +class DOMException(JsException): ... + +type Blob = Any + +# Transformers.js + +class _Pipeline(JsProxy): + def __call__( + self, action: str, model: str, options: dict + ) -> Coroutine[None, None, Callable[[Any], Coroutine[None, None, Any]]]: ... + +pipeline: _Pipeline = ... diff --git a/calm-calatheas/typings/pyscript.pyi b/calm-calatheas/typings/pyscript.pyi new file mode 100644 index 00000000..66689652 --- /dev/null +++ b/calm-calatheas/typings/pyscript.pyi @@ -0,0 +1,12 @@ +from collections.abc import Awaitable +from typing import Any + +sync: Any = ... +window: Any = ... +workers: dict[str, Awaitable[PyWorker]] = ... + +class PyWorker: + sync: Any = ... + def __init__(self, file: str) -> None: ... + @property + async def ready(self) -> bool: ... diff --git a/calm-calatheas/typings/transformers_js.pyi b/calm-calatheas/typings/transformers_js.pyi new file mode 100644 index 00000000..d9db66fb --- /dev/null +++ b/calm-calatheas/typings/transformers_js.pyi @@ -0,0 +1,5 @@ +class ModelOutputItem: + generated_text: str + +class ModelOutput: + def at(self, index: int) -> ModelOutputItem: ... diff --git a/calm-calatheas/uv.lock b/calm-calatheas/uv.lock new file mode 100644 index 00000000..36cce27b --- /dev/null +++ b/calm-calatheas/uv.lock @@ -0,0 +1,1623 @@ +requires-python = ">=3.13" +revision = 3 +version = 1 + +[[package]] +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyyaml" }, + { name = "safetensors" }, + { name = "torch" }, +] +name = "accelerate" +sdist = { url = "https://files.pythonhosted.org/packages/f7/66/be171836d86dc5b8698b3a9bf4b9eb10cb53369729939f88bf650167588b/accelerate-1.10.0.tar.gz", hash = "sha256:8270568fda9036b5cccdc09703fef47872abccd56eb5f6d53b54ea5fb7581496", size = 392261, upload-time = "2025-08-07T10:54:51.664Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.10.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/dd/0107f0aa179869ee9f47ef5a2686abd5e022fdc82af901d535e52fe91ce1/accelerate-1.10.0-py3-none-any.whl", hash = "sha256:260a72b560e100e839b517a331ec85ed495b3889d12886e79d1913071993c5a3", size = 374718, upload-time = "2025-08-07T10:54:49.988Z" }, +] + +[[package]] +name = "annotated-types" +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.7.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +dependencies = [{ name = "idna" }, { name = "sniffio" }] +name = "anyio" +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +source = { registry = "https://pypi.org/simple" } +version = "4.10.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + +[[package]] +name = "babel" +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852, upload-time = "2025-02-01T15:17:41.026Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.17.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537, upload-time = "2025-02-01T15:17:37.39Z" }, +] + +[[package]] +name = "backrefs" +sdist = { url = "https://files.pythonhosted.org/packages/eb/a7/312f673df6a79003279e1f55619abbe7daebbb87c17c976ddc0345c04c7b/backrefs-5.9.tar.gz", hash = "sha256:808548cb708d66b82ee231f962cb36faaf4f2baab032f2fbb783e9c2fdddaa59", size = 5765857, upload-time = "2025-06-22T19:34:13.97Z" } +source = { registry = "https://pypi.org/simple" } +version = "5.9" +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/4d/798dc1f30468134906575156c089c492cf79b5a5fd373f07fe26c4d046bf/backrefs-5.9-py310-none-any.whl", hash = "sha256:db8e8ba0e9de81fcd635f440deab5ae5f2591b54ac1ebe0550a2ca063488cd9f", size = 380267, upload-time = "2025-06-22T19:34:05.252Z" }, + { url = "https://files.pythonhosted.org/packages/55/07/f0b3375bf0d06014e9787797e6b7cc02b38ac9ff9726ccfe834d94e9991e/backrefs-5.9-py311-none-any.whl", hash = "sha256:6907635edebbe9b2dc3de3a2befff44d74f30a4562adbb8b36f21252ea19c5cf", size = 392072, upload-time = "2025-06-22T19:34:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/9d/12/4f345407259dd60a0997107758ba3f221cf89a9b5a0f8ed5b961aef97253/backrefs-5.9-py312-none-any.whl", hash = "sha256:7fdf9771f63e6028d7fee7e0c497c81abda597ea45d6b8f89e8ad76994f5befa", size = 397947, upload-time = "2025-06-22T19:34:08.172Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/fa31834dc27a7f05e5290eae47c82690edc3a7b37d58f7fb35a1bdbf355b/backrefs-5.9-py313-none-any.whl", hash = "sha256:cc37b19fa219e93ff825ed1fed8879e47b4d89aa7a1884860e2db64ccd7c676b", size = 399843, upload-time = "2025-06-22T19:34:09.68Z" }, + { url = "https://files.pythonhosted.org/packages/fc/24/b29af34b2c9c41645a9f4ff117bae860291780d73880f449e0b5d948c070/backrefs-5.9-py314-none-any.whl", hash = "sha256:df5e169836cc8acb5e440ebae9aad4bf9d15e226d3bad049cf3f6a5c20cc8dc9", size = 411762, upload-time = "2025-06-22T19:34:11.037Z" }, + { url = "https://files.pythonhosted.org/packages/41/ff/392bff89415399a979be4a65357a41d92729ae8580a66073d8ec8d810f98/backrefs-5.9-py39-none-any.whl", hash = "sha256:f48ee18f6252b8f5777a22a00a09a85de0ca931658f1dd96d4406a34f3748c60", size = 380265, upload-time = "2025-06-22T19:34:12.405Z" }, +] + +[[package]] +dependencies = [{ name = "soupsieve" }, { name = "typing-extensions" }] +name = "beautifulsoup4" +sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" } +source = { registry = "https://pypi.org/simple" } +version = "4.13.4" +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" }, +] + +[[package]] +dependencies = [{ name = "numpy" }, { name = "torch" }] +name = "bitsandbytes" +source = { registry = "https://pypi.org/simple" } +version = "0.47.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/eb/477d6b5602f469c7305fd43eec71d890c39909f615c1d7138f6e7d226eff/bitsandbytes-0.47.0-py3-none-manylinux_2_24_aarch64.whl", hash = "sha256:2f805b76891a596025e9e13318b675d08481b9ee650d65e5d2f9d844084c6521", size = 30004641, upload-time = "2025-08-11T18:51:20.524Z" }, + { url = "https://files.pythonhosted.org/packages/9c/40/91f1a5a694f434bc13cba160045fdc4e867032e627b001bf411048fefd9c/bitsandbytes-0.47.0-py3-none-manylinux_2_24_x86_64.whl", hash = "sha256:68f3fffd494a47ed1fd7593bfc5dd2ac69b68260599b71b4c4b3a32f90f3b184", size = 61284639, upload-time = "2025-08-11T18:51:23.581Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/e07a227f1cd6562844cea2f05ee576b0991a9a91f45965c06034178ba0f6/bitsandbytes-0.47.0-py3-none-win_amd64.whl", hash = "sha256:4880a6d42ca9628b5a571c8cc3093dc3f5f52511e5a9e47d52d569807975531a", size = 60725121, upload-time = "2025-08-11T18:51:27.543Z" }, +] + +[[package]] +dependencies = [ + { name = "accelerate" }, + { name = "kernels" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "starlette" }, + { name = "torch" }, + { name = "transformers" }, + { name = "uvicorn" }, +] +name = "calm-calatheas" +source = { virtual = "." } +version = "0.0.0" + +[package.dev-dependencies] +dev = [ + { name = "bitsandbytes" }, + { name = "mkdocs" }, + { name = "mkdocs-drawio" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings" }, + { name = "mkdocstrings-python" }, + { name = "playwright" }, + { name = "pre-commit" }, + { name = "pydeps" }, + { name = "pyodide-py" }, + { name = "pyright" }, + { name = "pyscript" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-playwright" }, + { name = "pytest-sugar" }, + { name = "python-dotenv" }, + { name = "reactivex" }, + { name = "ruff" }, + { name = "taskipy" }, + { name = "testcontainers" }, +] + +[package.metadata] +requires-dist = [ + { name = "accelerate", specifier = "~=1.10" }, + { name = "kernels", specifier = "~=0.9" }, + { name = "pydantic", specifier = "~=2.11" }, + { name = "pydantic-settings", specifier = "~=2.10" }, + { name = "starlette", specifier = "~=0.47" }, + { name = "torch", specifier = "~=2.6" }, + { name = "transformers", specifier = "~=4.55" }, + { name = "uvicorn", specifier = "~=0.35.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bitsandbytes", specifier = "~=0.47" }, + { name = "mkdocs", specifier = "~=1.6" }, + { name = "mkdocs-drawio", specifier = "~=1.11" }, + { name = "mkdocs-material", specifier = "~=9.6" }, + { name = "mkdocstrings", specifier = "~=0.29" }, + { name = "mkdocstrings-python", specifier = "~=1.16" }, + { name = "playwright", specifier = "~=1.54" }, + { name = "pre-commit", specifier = "~=4.2" }, + { name = "pydeps", specifier = "~=3.0" }, + { name = "pyodide-py", specifier = "~=0.28" }, + { name = "pyright", specifier = "==1.1.403" }, + { name = "pyscript", specifier = "~=0.3" }, + { name = "pytest", specifier = "~=8.4" }, + { name = "pytest-asyncio", specifier = "~=1.0" }, + { name = "pytest-playwright", specifier = "~=0.7" }, + { name = "pytest-sugar", specifier = "~=1.0" }, + { name = "python-dotenv", specifier = "~=1.1" }, + { name = "reactivex", specifier = "~=4.0" }, + { name = "ruff", specifier = "~=0.12" }, + { name = "taskipy", specifier = "~=1.14" }, + { name = "testcontainers", specifier = "~=4.12" }, +] + +[[package]] +name = "certifi" +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +source = { registry = "https://pypi.org/simple" } +version = "2025.8.3" +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "cfgv" +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +source = { registry = "https://pypi.org/simple" } +version = "3.4.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "charset-normalizer" +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +source = { registry = "https://pypi.org/simple" } +version = "3.4.3" +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +dependencies = [{ name = "colorama", marker = "sys_platform == 'win32'" }] +name = "click" +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +source = { registry = "https://pypi.org/simple" } +version = "8.2.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +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" } +source = { registry = "https://pypi.org/simple" } +version = "0.4.6" +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 = "distlib" +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" } +source = { registry = "https://pypi.org/simple" } +version = "0.4.0" +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]] +dependencies = [{ name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "requests" }, { name = "urllib3" }] +name = "docker" +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +source = { registry = "https://pypi.org/simple" } +version = "7.1.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + +[[package]] +name = "filelock" +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +source = { registry = "https://pypi.org/simple" } +version = "3.19.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "fsspec" +sdist = { url = "https://files.pythonhosted.org/packages/8b/02/0835e6ab9cfc03916fe3f78c0956cfcdb6ff2669ffa6651065d5ebf7fc98/fsspec-2025.7.0.tar.gz", hash = "sha256:786120687ffa54b8283d942929540d8bc5ccfa820deb555a2b5d0ed2b737bf58", size = 304432, upload-time = "2025-07-15T16:05:21.19Z" } +source = { registry = "https://pypi.org/simple" } +version = "2025.7.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/e0/014d5d9d7a4564cf1c40b5039bc882db69fd881111e03ab3657ac0b218e2/fsspec-2025.7.0-py3-none-any.whl", hash = "sha256:8b012e39f63c7d5f10474de957f3ab793b47b45ae7d39f2fb735f8bbe25c0e21", size = 199597, upload-time = "2025-07-15T16:05:19.529Z" }, +] + +[[package]] +dependencies = [{ name = "python-dateutil" }] +name = "ghp-import" +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.1.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "greenlet" +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +source = { registry = "https://pypi.org/simple" } +version = "3.2.4" +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + +[[package]] +dependencies = [{ name = "colorama" }] +name = "griffe" +sdist = { url = "https://files.pythonhosted.org/packages/81/ca/29f36e00c74844ae50d139cf5a8b1751887b2f4d5023af65d460268ad7aa/griffe-1.12.1.tar.gz", hash = "sha256:29f5a6114c0aeda7d9c86a570f736883f8a2c5b38b57323d56b3d1c000565567", size = 411863, upload-time = "2025-08-14T21:08:15.38Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.12.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/f2/4fab6c3e5bcaf38a44cc8a974d2752eaad4c129e45d6533d926a30edd133/griffe-1.12.1-py3-none-any.whl", hash = "sha256:2d7c12334de00089c31905424a00abcfd931b45b8b516967f224133903d302cc", size = 138940, upload-time = "2025-08-14T21:08:13.382Z" }, +] + +[[package]] +name = "h11" +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.16.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +sdist = { url = "https://files.pythonhosted.org/packages/b2/0a/a0f56735940fde6dd627602fec9ab3bad23f66a272397560abd65aba416e/hf_xet-1.1.7.tar.gz", hash = "sha256:20cec8db4561338824a3b5f8c19774055b04a8df7fff0cb1ff2cb1a0c1607b80", size = 477719, upload-time = "2025-08-06T00:30:55.741Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.1.7" +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/7c/8d7803995caf14e7d19a392a486a040f923e2cfeff824e9b800b92072f76/hf_xet-1.1.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:60dae4b44d520819e54e216a2505685248ec0adbdb2dd4848b17aa85a0375cde", size = 2761743, upload-time = "2025-08-06T00:30:50.634Z" }, + { url = "https://files.pythonhosted.org/packages/51/a3/fa5897099454aa287022a34a30e68dbff0e617760f774f8bd1db17f06bd4/hf_xet-1.1.7-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:b109f4c11e01c057fc82004c9e51e6cdfe2cb230637644ade40c599739067b2e", size = 2624331, upload-time = "2025-08-06T00:30:49.212Z" }, + { url = "https://files.pythonhosted.org/packages/86/50/2446a132267e60b8a48b2e5835d6e24fd988000d0f5b9b15ebd6d64ef769/hf_xet-1.1.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6efaaf1a5a9fc3a501d3e71e88a6bfebc69ee3a716d0e713a931c8b8d920038f", size = 3183844, upload-time = "2025-08-06T00:30:47.582Z" }, + { url = "https://files.pythonhosted.org/packages/20/8f/ccc670616bb9beee867c6bb7139f7eab2b1370fe426503c25f5cbb27b148/hf_xet-1.1.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:751571540f9c1fbad9afcf222a5fb96daf2384bf821317b8bfb0c59d86078513", size = 3074209, upload-time = "2025-08-06T00:30:45.509Z" }, + { url = "https://files.pythonhosted.org/packages/21/0a/4c30e1eb77205565b854f5e4a82cf1f056214e4dc87f2918ebf83d47ae14/hf_xet-1.1.7-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:18b61bbae92d56ae731b92087c44efcac216071182c603fc535f8e29ec4b09b8", size = 3239602, upload-time = "2025-08-06T00:30:52.41Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1e/fc7e9baf14152662ef0b35fa52a6e889f770a7ed14ac239de3c829ecb47e/hf_xet-1.1.7-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:713f2bff61b252f8523739969f247aa354ad8e6d869b8281e174e2ea1bb8d604", size = 3348184, upload-time = "2025-08-06T00:30:54.105Z" }, + { url = "https://files.pythonhosted.org/packages/a3/73/e354eae84ceff117ec3560141224724794828927fcc013c5b449bf0b8745/hf_xet-1.1.7-cp37-abi3-win_amd64.whl", hash = "sha256:2e356da7d284479ae0f1dea3cf5a2f74fdf925d6dca84ac4341930d892c7cb34", size = 2820008, upload-time = "2025-08-06T00:30:57.056Z" }, +] + +[[package]] +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +name = "huggingface-hub" +sdist = { url = "https://files.pythonhosted.org/packages/45/c9/bdbe19339f76d12985bc03572f330a01a93c04dffecaaea3061bdd7fb892/huggingface_hub-0.34.4.tar.gz", hash = "sha256:a4228daa6fb001be3f4f4bdaf9a0db00e1739235702848df00885c9b5742c85c", size = 459768, upload-time = "2025-08-08T09:14:52.365Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.34.4" +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/7b/bb06b061991107cd8783f300adff3e7b7f284e330fd82f507f2a1417b11d/huggingface_hub-0.34.4-py3-none-any.whl", hash = "sha256:9b365d781739c93ff90c359844221beef048403f1bc1f1c123c191257c3c890a", size = 561452, upload-time = "2025-08-08T09:14:50.159Z" }, +] + +[[package]] +name = "identify" +sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243, upload-time = "2025-08-09T19:35:00.6Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.6.13" +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153, upload-time = "2025-08-09T19:34:59.1Z" }, +] + +[[package]] +name = "idna" +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +source = { registry = "https://pypi.org/simple" } +version = "3.10" +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.1.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +dependencies = [{ name = "markupsafe" }] +name = "jinja2" +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" } +source = { registry = "https://pypi.org/simple" } +version = "3.1.6" +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]] +dependencies = [{ name = "huggingface-hub" }, { name = "packaging" }, { name = "pyyaml" }] +name = "kernels" +sdist = { url = "https://files.pythonhosted.org/packages/26/4e/626c2155efa978bdec45e57bf0cfd0a76682942964fdc166cab2306d56ed/kernels-0.9.0.tar.gz", hash = "sha256:42a77d824d71f76084f7dd52dcb6d8823e243088117dc5d66006779b10f43bfb", size = 42859, upload-time = "2025-08-01T14:46:29.647Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.9.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/60/3f9b0fba0b78bcc9ba4fa0cd3c5132402838bdb42dc69bf81fb89980d978/kernels-0.9.0-py3-none-any.whl", hash = "sha256:81705176b3488d8eb04fa2aff1a4be8dc10eeb6a4338a510e36a6b3aeeb75969", size = 37421, upload-time = "2025-08-01T14:46:28.478Z" }, +] + +[[package]] +name = "lxml" +sdist = { url = "https://files.pythonhosted.org/packages/c5/ed/60eb6fa2923602fba988d9ca7c5cdbd7cf25faa795162ed538b527a35411/lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72", size = 4096938, upload-time = "2025-06-26T16:28:19.373Z" } +source = { registry = "https://pypi.org/simple" } +version = "6.0.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/21/6e7c060822a3c954ff085e5e1b94b4a25757c06529eac91e550f3f5cd8b8/lxml-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6da7cd4f405fd7db56e51e96bff0865b9853ae70df0e6720624049da76bde2da", size = 8414372, upload-time = "2025-06-26T16:26:39.079Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/051b1607a459db670fc3a244fa4f06f101a8adf86cda263d1a56b3a4f9d5/lxml-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b34339898bb556a2351a1830f88f751679f343eabf9cf05841c95b165152c9e7", size = 4593940, upload-time = "2025-06-26T16:26:41.891Z" }, + { url = "https://files.pythonhosted.org/packages/8e/74/dd595d92a40bda3c687d70d4487b2c7eff93fd63b568acd64fedd2ba00fe/lxml-6.0.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:51a5e4c61a4541bd1cd3ba74766d0c9b6c12d6a1a4964ef60026832aac8e79b3", size = 5214329, upload-time = "2025-06-26T16:26:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/52/46/3572761efc1bd45fcafb44a63b3b0feeb5b3f0066886821e94b0254f9253/lxml-6.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d18a25b19ca7307045581b18b3ec9ead2b1db5ccd8719c291f0cd0a5cec6cb81", size = 4947559, upload-time = "2025-06-28T18:47:31.091Z" }, + { url = "https://files.pythonhosted.org/packages/94/8a/5e40de920e67c4f2eef9151097deb9b52d86c95762d8ee238134aff2125d/lxml-6.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4f0c66df4386b75d2ab1e20a489f30dc7fd9a06a896d64980541506086be1f1", size = 5102143, upload-time = "2025-06-28T18:47:33.612Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4b/20555bdd75d57945bdabfbc45fdb1a36a1a0ff9eae4653e951b2b79c9209/lxml-6.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f4b481b6cc3a897adb4279216695150bbe7a44c03daba3c894f49d2037e0a24", size = 5021931, upload-time = "2025-06-26T16:26:47.503Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/cf03b412f3763d4ca23b25e70c96a74cfece64cec3addf1c4ec639586b13/lxml-6.0.0-cp313-cp313-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a78d6c9168f5bcb20971bf3329c2b83078611fbe1f807baadc64afc70523b3a", size = 5645469, upload-time = "2025-07-03T19:19:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/39c8507c16db6031f8c1ddf70ed95dbb0a6d466a40002a3522c128aba472/lxml-6.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae06fbab4f1bb7db4f7c8ca9897dc8db4447d1a2b9bee78474ad403437bcc29", size = 5247467, upload-time = "2025-06-26T16:26:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/732d49def0631ad633844cfb2664563c830173a98d5efd9b172e89a4800d/lxml-6.0.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:1fa377b827ca2023244a06554c6e7dc6828a10aaf74ca41965c5d8a4925aebb4", size = 4720601, upload-time = "2025-06-26T16:26:52.564Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7f/6b956fab95fa73462bca25d1ea7fc8274ddf68fb8e60b78d56c03b65278e/lxml-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1676b56d48048a62ef77a250428d1f31f610763636e0784ba67a9740823988ca", size = 5060227, upload-time = "2025-06-26T16:26:55.054Z" }, + { url = "https://files.pythonhosted.org/packages/97/06/e851ac2924447e8b15a294855caf3d543424364a143c001014d22c8ca94c/lxml-6.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0e32698462aacc5c1cf6bdfebc9c781821b7e74c79f13e5ffc8bfe27c42b1abf", size = 4790637, upload-time = "2025-06-26T16:26:57.384Z" }, + { url = "https://files.pythonhosted.org/packages/06/d4/fd216f3cd6625022c25b336c7570d11f4a43adbaf0a56106d3d496f727a7/lxml-6.0.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4d6036c3a296707357efb375cfc24bb64cd955b9ec731abf11ebb1e40063949f", size = 5662049, upload-time = "2025-07-03T19:19:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/52/03/0e764ce00b95e008d76b99d432f1807f3574fb2945b496a17807a1645dbd/lxml-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7488a43033c958637b1a08cddc9188eb06d3ad36582cebc7d4815980b47e27ef", size = 5272430, upload-time = "2025-06-26T16:27:00.031Z" }, + { url = "https://files.pythonhosted.org/packages/5f/01/d48cc141bc47bc1644d20fe97bbd5e8afb30415ec94f146f2f76d0d9d098/lxml-6.0.0-cp313-cp313-win32.whl", hash = "sha256:5fcd7d3b1d8ecb91445bd71b9c88bdbeae528fefee4f379895becfc72298d181", size = 3612896, upload-time = "2025-06-26T16:27:04.251Z" }, + { url = "https://files.pythonhosted.org/packages/f4/87/6456b9541d186ee7d4cb53bf1b9a0d7f3b1068532676940fdd594ac90865/lxml-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f34687222b78fff795feeb799a7d44eca2477c3d9d3a46ce17d51a4f383e32e", size = 4013132, upload-time = "2025-06-26T16:27:06.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/42/85b3aa8f06ca0d24962f8100f001828e1f1f1a38c954c16e71154ed7d53a/lxml-6.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:21db1ec5525780fd07251636eb5f7acb84003e9382c72c18c542a87c416ade03", size = 3672642, upload-time = "2025-06-26T16:27:09.888Z" }, +] + +[[package]] +name = "markdown" +sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071, upload-time = "2025-06-19T17:12:44.483Z" } +source = { registry = "https://pypi.org/simple" } +version = "3.8.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload-time = "2025-06-19T17:12:42.994Z" }, +] + +[[package]] +dependencies = [{ name = "mdurl" }] +name = "markdown-it-py" +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +source = { registry = "https://pypi.org/simple" } +version = "4.0.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +source = { registry = "https://pypi.org/simple" } +version = "3.0.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "mdurl" +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.1.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.3.4" +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +name = "mkdocs" +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.6.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +dependencies = [{ name = "markdown" }, { name = "markupsafe" }, { name = "mkdocs" }] +name = "mkdocs-autorefs" +sdist = { url = "https://files.pythonhosted.org/packages/47/0c/c9826f35b99c67fa3a7cddfa094c1a6c43fafde558c309c6e4403e5b37dc/mkdocs_autorefs-1.4.2.tar.gz", hash = "sha256:e2ebe1abd2b67d597ed19378c0fff84d73d1dbce411fce7a7cc6f161888b6749", size = 54961, upload-time = "2025-05-20T13:09:09.886Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.4.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/dc/fc063b78f4b769d1956319351704e23ebeba1e9e1d6a41b4b602325fd7e4/mkdocs_autorefs-1.4.2-py3-none-any.whl", hash = "sha256:83d6d777b66ec3c372a1aad4ae0cf77c243ba5bcda5bf0c6b8a2c5e7a3d89f13", size = 24969, upload-time = "2025-05-20T13:09:08.237Z" }, +] + +[[package]] +dependencies = [ + { name = "beautifulsoup4" }, + { name = "jinja2" }, + { name = "lxml" }, + { name = "mkdocs" }, + { name = "requests" }, +] +name = "mkdocs-drawio" +sdist = { url = "https://files.pythonhosted.org/packages/6c/bc/0ea7ba44e4f4f065554e35b3e32a4d82d0c408bbe8f1d67ea36baa4d47cb/mkdocs_drawio-1.11.2.tar.gz", hash = "sha256:73f23f7aac3147807908afa800463ab415a714d11df1cd12424500f375c9ebfa", size = 5767, upload-time = "2025-05-23T09:33:38.154Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.11.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/0c/17a9c98ec2b71ab523f0dce728b81ee0d567857da1b4d5908ab1de188c09/mkdocs_drawio-1.11.2-py3-none-any.whl", hash = "sha256:754f224dd467a90d76d170c41d544322daa1ba1f3bd322a33f98b9884d96ec71", size = 7169, upload-time = "2025-05-23T09:33:36.476Z" }, +] + +[[package]] +dependencies = [{ name = "mergedeep" }, { name = "platformdirs" }, { name = "pyyaml" }] +name = "mkdocs-get-deps" +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.2.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "click" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +name = "mkdocs-material" +sdist = { url = "https://files.pythonhosted.org/packages/47/02/51115cdda743e1551c5c13bdfaaf8c46b959acc57ba914d8ec479dd2fe1f/mkdocs_material-9.6.17.tar.gz", hash = "sha256:48ae7aec72a3f9f501a70be3fbd329c96ff5f5a385b67a1563e5ed5ce064affe", size = 4032898, upload-time = "2025-08-15T16:09:21.412Z" } +source = { registry = "https://pypi.org/simple" } +version = "9.6.17" +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/7c/0f0d44c92c8f3068930da495b752244bd59fd87b5b0f9571fa2d2a93aee7/mkdocs_material-9.6.17-py3-none-any.whl", hash = "sha256:221dd8b37a63f52e580bcab4a7e0290e4a6f59bd66190be9c3d40767e05f9417", size = 9229230, upload-time = "2025-08-15T16:09:18.301Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.3.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +name = "mkdocstrings" +sdist = { url = "https://files.pythonhosted.org/packages/e2/0a/7e4776217d4802009c8238c75c5345e23014a4706a8414a62c0498858183/mkdocstrings-0.30.0.tar.gz", hash = "sha256:5d8019b9c31ddacd780b6784ffcdd6f21c408f34c0bd1103b5351d609d5b4444", size = 106597, upload-time = "2025-07-22T23:48:45.998Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.30.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/b4/3c5eac68f31e124a55d255d318c7445840fa1be55e013f507556d6481913/mkdocstrings-0.30.0-py3-none-any.whl", hash = "sha256:ae9e4a0d8c1789697ac776f2e034e2ddd71054ae1cf2c2bb1433ccfd07c226f2", size = 36579, upload-time = "2025-07-22T23:48:44.152Z" }, +] + +[[package]] +dependencies = [{ name = "griffe" }, { name = "mkdocs-autorefs" }, { name = "mkdocstrings" }] +name = "mkdocstrings-python" +sdist = { url = "https://files.pythonhosted.org/packages/39/7c/6dfd8ad59c0eebae167168528ed6cad00116f58ef2327686149f7b25d175/mkdocstrings_python-1.17.0.tar.gz", hash = "sha256:c6295962b60542a9c7468a3b515ce8524616ca9f8c1a38c790db4286340ba501", size = 200408, upload-time = "2025-08-14T21:18:14.568Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.17.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/ac/b1fcc937f4ecd372f3e857162dea67c45c1e2eedbac80447be516e3372bb/mkdocstrings_python-1.17.0-py3-none-any.whl", hash = "sha256:49903fa355dfecc5ad0b891e78ff5d25d30ffd00846952801bbe8331e123d4b0", size = 124778, upload-time = "2025-08-14T21:18:12.821Z" }, +] + +[[package]] +name = "mpmath" +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.3.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "mslex" +sdist = { url = "https://files.pythonhosted.org/packages/e0/97/7022667073c99a0fe028f2e34b9bf76b49a611afd21b02527fbfd92d4cd5/mslex-1.3.0.tar.gz", hash = "sha256:641c887d1d3db610eee2af37a8e5abda3f70b3006cdfd2d0d29dc0d1ae28a85d", size = 11583, upload-time = "2024-10-16T13:16:18.523Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.3.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/f2/66bd65ca0139675a0d7b18f0bada6e12b51a984e41a76dbe44761bf1b3ee/mslex-1.3.0-py3-none-any.whl", hash = "sha256:c7074b347201b3466fc077c5692fbce9b5f62a63a51f537a53fbbd02eff2eea4", size = 7820, upload-time = "2024-10-16T13:16:17.566Z" }, +] + +[[package]] +name = "networkx" +sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" } +source = { registry = "https://pypi.org/simple" } +version = "3.5" +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" }, +] + +[[package]] +name = "nodeenv" +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.9.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "numpy" +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306, upload-time = "2025-07-24T21:32:07.553Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.3.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/c0/c6bb172c916b00700ed3bf71cb56175fd1f7dbecebf8353545d0b5519f6c/numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3", size = 20949074, upload-time = "2025-07-24T20:43:07.813Z" }, + { url = "https://files.pythonhosted.org/packages/20/4e/c116466d22acaf4573e58421c956c6076dc526e24a6be0903219775d862e/numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b", size = 14177311, upload-time = "2025-07-24T20:43:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/78/45/d4698c182895af189c463fc91d70805d455a227261d950e4e0f1310c2550/numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6", size = 5106022, upload-time = "2025-07-24T20:43:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/9f/76/3e6880fef4420179309dba72a8c11f6166c431cf6dee54c577af8906f914/numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089", size = 6640135, upload-time = "2025-07-24T20:43:49.28Z" }, + { url = "https://files.pythonhosted.org/packages/34/fa/87ff7f25b3c4ce9085a62554460b7db686fef1e0207e8977795c7b7d7ba1/numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2", size = 14278147, upload-time = "2025-07-24T20:44:10.328Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0f/571b2c7a3833ae419fe69ff7b479a78d313581785203cc70a8db90121b9a/numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f", size = 16635989, upload-time = "2025-07-24T20:44:34.88Z" }, + { url = "https://files.pythonhosted.org/packages/24/5a/84ae8dca9c9a4c592fe11340b36a86ffa9fd3e40513198daf8a97839345c/numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee", size = 16053052, upload-time = "2025-07-24T20:44:58.872Z" }, + { url = "https://files.pythonhosted.org/packages/57/7c/e5725d99a9133b9813fcf148d3f858df98511686e853169dbaf63aec6097/numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6", size = 18577955, upload-time = "2025-07-24T20:45:26.714Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7c546fcf42145f29b71e4d6f429e96d8d68e5a7ba1830b2e68d7418f0bbd/numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b", size = 6311843, upload-time = "2025-07-24T20:49:24.444Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6f/a428fd1cb7ed39b4280d057720fed5121b0d7754fd2a9768640160f5517b/numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56", size = 12782876, upload-time = "2025-07-24T20:49:43.227Z" }, + { url = "https://files.pythonhosted.org/packages/65/85/4ea455c9040a12595fb6c43f2c217257c7b52dd0ba332c6a6c1d28b289fe/numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2", size = 10192786, upload-time = "2025-07-24T20:49:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/80/23/8278f40282d10c3f258ec3ff1b103d4994bcad78b0cba9208317f6bb73da/numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab", size = 21047395, upload-time = "2025-07-24T20:45:58.821Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2d/624f2ce4a5df52628b4ccd16a4f9437b37c35f4f8a50d00e962aae6efd7a/numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2", size = 14300374, upload-time = "2025-07-24T20:46:20.207Z" }, + { url = "https://files.pythonhosted.org/packages/f6/62/ff1e512cdbb829b80a6bd08318a58698867bca0ca2499d101b4af063ee97/numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a", size = 5228864, upload-time = "2025-07-24T20:46:30.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8e/74bc18078fff03192d4032cfa99d5a5ca937807136d6f5790ce07ca53515/numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286", size = 6737533, upload-time = "2025-07-24T20:46:46.111Z" }, + { url = "https://files.pythonhosted.org/packages/19/ea/0731efe2c9073ccca5698ef6a8c3667c4cf4eea53fcdcd0b50140aba03bc/numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8", size = 14352007, upload-time = "2025-07-24T20:47:07.1Z" }, + { url = "https://files.pythonhosted.org/packages/cf/90/36be0865f16dfed20f4bc7f75235b963d5939707d4b591f086777412ff7b/numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a", size = 16701914, upload-time = "2025-07-24T20:47:32.459Z" }, + { url = "https://files.pythonhosted.org/packages/94/30/06cd055e24cb6c38e5989a9e747042b4e723535758e6153f11afea88c01b/numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91", size = 16132708, upload-time = "2025-07-24T20:47:58.129Z" }, + { url = "https://files.pythonhosted.org/packages/9a/14/ecede608ea73e58267fd7cb78f42341b3b37ba576e778a1a06baffbe585c/numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5", size = 18651678, upload-time = "2025-07-24T20:48:25.402Z" }, + { url = "https://files.pythonhosted.org/packages/40/f3/2fe6066b8d07c3685509bc24d56386534c008b462a488b7f503ba82b8923/numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5", size = 6441832, upload-time = "2025-07-24T20:48:37.181Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ba/0937d66d05204d8f28630c9c60bc3eda68824abde4cf756c4d6aad03b0c6/numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450", size = 12927049, upload-time = "2025-07-24T20:48:56.24Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ed/13542dd59c104d5e654dfa2ac282c199ba64846a74c2c4bcdbc3a0f75df1/numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a", size = 10262935, upload-time = "2025-07-24T20:49:13.136Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906, upload-time = "2025-07-24T20:50:30.346Z" }, + { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607, upload-time = "2025-07-24T20:50:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110, upload-time = "2025-07-24T20:51:01.041Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050, upload-time = "2025-07-24T20:51:11.64Z" }, + { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292, upload-time = "2025-07-24T20:51:33.488Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913, upload-time = "2025-07-24T20:51:58.517Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180, upload-time = "2025-07-24T20:52:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809, upload-time = "2025-07-24T20:52:51.015Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410, upload-time = "2025-07-24T20:56:44.949Z" }, + { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821, upload-time = "2025-07-24T20:57:06.479Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303, upload-time = "2025-07-24T20:57:22.879Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524, upload-time = "2025-07-24T20:53:22.086Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519, upload-time = "2025-07-24T20:53:44.053Z" }, + { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972, upload-time = "2025-07-24T20:53:53.81Z" }, + { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439, upload-time = "2025-07-24T20:54:04.742Z" }, + { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479, upload-time = "2025-07-24T20:54:25.819Z" }, + { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805, upload-time = "2025-07-24T20:54:50.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830, upload-time = "2025-07-24T20:55:17.306Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665, upload-time = "2025-07-24T20:55:46.665Z" }, + { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777, upload-time = "2025-07-24T20:55:57.66Z" }, + { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856, upload-time = "2025-07-24T20:56:17.318Z" }, + { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226, upload-time = "2025-07-24T20:56:34.509Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +source = { registry = "https://pypi.org/simple" } +version = "12.4.5.8" +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/71/1c91302526c45ab494c23f61c7a84aa568b8c1f9d196efa5993957faf906/nvidia_cublas_cu12-12.4.5.8-py3-none-manylinux2014_x86_64.whl", hash = "sha256:2fc8da60df463fdefa81e323eef2e36489e1c94335b5358bcb38360adf75ac9b", size = 363438805, upload-time = "2024-04-03T20:57:06.025Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +source = { registry = "https://pypi.org/simple" } +version = "12.4.127" +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/42/f4f60238e8194a3106d06a058d494b18e006c10bb2b915655bd9f6ea4cb1/nvidia_cuda_cupti_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:9dec60f5ac126f7bb551c055072b69d85392b13311fcc1bcda2202d172df30fb", size = 13813957, upload-time = "2024-04-03T20:55:01.564Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +source = { registry = "https://pypi.org/simple" } +version = "12.4.127" +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/14/91ae57cd4db3f9ef7aa99f4019cfa8d54cb4caa7e00975df6467e9725a9f/nvidia_cuda_nvrtc_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a178759ebb095827bd30ef56598ec182b85547f1508941a3d560eb7ea1fbf338", size = 24640306, upload-time = "2024-04-03T20:56:01.463Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +source = { registry = "https://pypi.org/simple" } +version = "12.4.127" +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/27/1795d86fe88ef397885f2e580ac37628ed058a92ed2c39dc8eac3adf0619/nvidia_cuda_runtime_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:64403288fa2136ee8e467cdc9c9427e0434110899d07c779f25b5c068934faa5", size = 883737, upload-time = "2024-04-03T20:54:51.355Z" }, +] + +[[package]] +dependencies = [{ name = "nvidia-cublas-cu12" }] +name = "nvidia-cudnn-cu12" +source = { registry = "https://pypi.org/simple" } +version = "9.1.0.70" +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/fd/713452cd72343f682b1c7b9321e23829f00b842ceaedcda96e742ea0b0b3/nvidia_cudnn_cu12-9.1.0.70-py3-none-manylinux2014_x86_64.whl", hash = "sha256:165764f44ef8c61fcdfdfdbe769d687e06374059fbb388b6c89ecb0e28793a6f", size = 664752741, upload-time = "2024-04-22T15:24:15.253Z" }, +] + +[[package]] +dependencies = [{ name = "nvidia-nvjitlink-cu12" }] +name = "nvidia-cufft-cu12" +source = { registry = "https://pypi.org/simple" } +version = "11.2.1.3" +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/94/3266821f65b92b3138631e9c8e7fe1fb513804ac934485a8d05776e1dd43/nvidia_cufft_cu12-11.2.1.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f083fc24912aa410be21fa16d157fed2055dab1cc4b6934a0e03cba69eb242b9", size = 211459117, upload-time = "2024-04-03T20:57:40.402Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +source = { registry = "https://pypi.org/simple" } +version = "10.3.5.147" +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/6d/44ad094874c6f1b9c654f8ed939590bdc408349f137f9b98a3a23ccec411/nvidia_curand_cu12-10.3.5.147-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a88f583d4e0bb643c49743469964103aa59f7f708d862c3ddb0fc07f851e3b8b", size = 56305206, upload-time = "2024-04-03T20:58:08.722Z" }, +] + +[[package]] +dependencies = [{ name = "nvidia-cublas-cu12" }, { name = "nvidia-cusparse-cu12" }, { name = "nvidia-nvjitlink-cu12" }] +name = "nvidia-cusolver-cu12" +source = { registry = "https://pypi.org/simple" } +version = "11.6.1.9" +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/e1/5b9089a4b2a4790dfdea8b3a006052cfecff58139d5a4e34cb1a51df8d6f/nvidia_cusolver_cu12-11.6.1.9-py3-none-manylinux2014_x86_64.whl", hash = "sha256:19e33fa442bcfd085b3086c4ebf7e8debc07cfe01e11513cc6d332fd918ac260", size = 127936057, upload-time = "2024-04-03T20:58:28.735Z" }, +] + +[[package]] +dependencies = [{ name = "nvidia-nvjitlink-cu12" }] +name = "nvidia-cusparse-cu12" +source = { registry = "https://pypi.org/simple" } +version = "12.3.1.170" +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/f7/97a9ea26ed4bbbfc2d470994b8b4f338ef663be97b8f677519ac195e113d/nvidia_cusparse_cu12-12.3.1.170-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ea4f11a2904e2a8dc4b1833cc1b5181cde564edd0d5cd33e3c168eff2d1863f1", size = 207454763, upload-time = "2024-04-03T20:58:59.995Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +source = { registry = "https://pypi.org/simple" } +version = "0.6.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/a8/bcbb63b53a4b1234feeafb65544ee55495e1bb37ec31b999b963cbccfd1d/nvidia_cusparselt_cu12-0.6.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:df2c24502fd76ebafe7457dbc4716b2fec071aabaed4fb7691a201cde03704d9", size = 150057751, upload-time = "2024-07-23T02:35:53.074Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +source = { registry = "https://pypi.org/simple" } +version = "2.21.5" +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/99/12cd266d6233f47d00daf3a72739872bdc10267d0383508b0b9c84a18bb6/nvidia_nccl_cu12-2.21.5-py3-none-manylinux2014_x86_64.whl", hash = "sha256:8579076d30a8c24988834445f8d633c697d42397e92ffc3f63fa26766d25e0a0", size = 188654414, upload-time = "2024-04-03T15:32:57.427Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +source = { registry = "https://pypi.org/simple" } +version = "12.4.127" +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/ff/847841bacfbefc97a00036e0fce5a0f086b640756dc38caea5e1bb002655/nvidia_nvjitlink_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:06b3b9b25bf3f8af351d664978ca26a16d2c5127dbd53c0497e28d1fb9611d57", size = 21066810, upload-time = "2024-04-03T20:59:46.957Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +source = { registry = "https://pypi.org/simple" } +version = "12.4.127" +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/20/199b8713428322a2f22b722c62b8cc278cc53dffa9705d744484b5035ee9/nvidia_nvtx_cu12-12.4.127-py3-none-manylinux2014_x86_64.whl", hash = "sha256:781e950d9b9f60d8241ccea575b32f5105a5baf4c2351cab5256a24869f12a1a", size = 99144, upload-time = "2024-04-03T20:56:12.406Z" }, +] + +[[package]] +name = "packaging" +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +source = { registry = "https://pypi.org/simple" } +version = "25.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "paginate" +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.5.7" +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.12.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +sdist = { url = "https://files.pythonhosted.org/packages/f5/52/0763d1d976d5c262df53ddda8d8d4719eedf9594d046f117c25a27261a19/platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3", size = 20916, upload-time = "2024-05-15T03:18:23.372Z" } +source = { registry = "https://pypi.org/simple" } +version = "4.2.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/13/2aa1f0e1364feb2c9ef45302f387ac0bd81484e9c9a4c5688a322fbdfd08/platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee", size = 18146, upload-time = "2024-05-15T03:18:21.209Z" }, +] + +[[package]] +dependencies = [{ name = "greenlet" }, { name = "pyee" }] +name = "playwright" +source = { registry = "https://pypi.org/simple" } +version = "1.54.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/09/33d5bfe393a582d8dac72165a9e88b274143c9df411b65ece1cc13f42988/playwright-1.54.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bf3b845af744370f1bd2286c2a9536f474cc8a88dc995b72ea9a5be714c9a77d", size = 40439034, upload-time = "2025-07-22T13:58:04.816Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7b/51882dc584f7aa59f446f2bb34e33c0e5f015de4e31949e5b7c2c10e54f0/playwright-1.54.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:780928b3ca2077aea90414b37e54edd0c4bbb57d1aafc42f7aa0b3fd2c2fac02", size = 38702308, upload-time = "2025-07-22T13:58:08.211Z" }, + { url = "https://files.pythonhosted.org/packages/73/a1/7aa8ae175b240c0ec8849fcf000e078f3c693f9aa2ffd992da6550ea0dff/playwright-1.54.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:81d0b6f28843b27f288cfe438af0a12a4851de57998009a519ea84cee6fbbfb9", size = 40439037, upload-time = "2025-07-22T13:58:11.37Z" }, + { url = "https://files.pythonhosted.org/packages/34/a9/45084fd23b6206f954198296ce39b0acf50debfdf3ec83a593e4d73c9c8a/playwright-1.54.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:09919f45cc74c64afb5432646d7fef0d19fff50990c862cb8d9b0577093f40cc", size = 45920135, upload-time = "2025-07-22T13:58:14.494Z" }, + { url = "https://files.pythonhosted.org/packages/02/d4/6a692f4c6db223adc50a6e53af405b45308db39270957a6afebddaa80ea2/playwright-1.54.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ae206c55737e8e3eae51fb385d61c0312eeef31535643bb6232741b41b6fdc", size = 45302695, upload-time = "2025-07-22T13:58:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/4ee60a1c3714321db187bebbc40d52cea5b41a856925156325058b5fca5a/playwright-1.54.0-py3-none-win32.whl", hash = "sha256:0b108622ffb6906e28566f3f31721cd57dda637d7e41c430287804ac01911f56", size = 35469309, upload-time = "2025-07-22T13:58:21.917Z" }, + { url = "https://files.pythonhosted.org/packages/aa/77/8f8fae05a242ef639de963d7ae70a69d0da61d6d72f1207b8bbf74ffd3e7/playwright-1.54.0-py3-none-win_amd64.whl", hash = "sha256:9e5aee9ae5ab1fdd44cd64153313a2045b136fcbcfb2541cc0a3d909132671a2", size = 35469311, upload-time = "2025-07-22T13:58:24.707Z" }, + { url = "https://files.pythonhosted.org/packages/33/ff/99a6f4292a90504f2927d34032a4baf6adb498dc3f7cf0f3e0e22899e310/playwright-1.54.0-py3-none-win_arm64.whl", hash = "sha256:a975815971f7b8dca505c441a4c56de1aeb56a211290f8cc214eeef5524e8d75", size = 31239119, upload-time = "2025-07-22T13:58:27.56Z" }, +] + +[[package]] +name = "pluggy" +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.5.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, +] + +[[package]] +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +name = "pre-commit" +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +source = { registry = "https://pypi.org/simple" } +version = "4.3.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "psutil" +sdist = { url = "https://files.pythonhosted.org/packages/1f/5a/07871137bb752428aa4b659f910b399ba6f291156bdea939be3e96cae7cb/psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5", size = 508502, upload-time = "2024-12-19T18:21:20.568Z" } +source = { registry = "https://pypi.org/simple" } +version = "6.1.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/99/ca79d302be46f7bdd8321089762dd4476ee725fce16fc2b2e1dbba8cac17/psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8", size = 247511, upload-time = "2024-12-19T18:21:45.163Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6b/73dbde0dd38f3782905d4587049b9be64d76671042fdcaf60e2430c6796d/psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377", size = 248985, upload-time = "2024-12-19T18:21:49.254Z" }, + { url = "https://files.pythonhosted.org/packages/17/38/c319d31a1d3f88c5b79c68b3116c129e5133f1822157dd6da34043e32ed6/psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003", size = 284488, upload-time = "2024-12-19T18:21:51.638Z" }, + { url = "https://files.pythonhosted.org/packages/9c/39/0f88a830a1c8a3aba27fededc642da37613c57cbff143412e3536f89784f/psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160", size = 287477, upload-time = "2024-12-19T18:21:55.306Z" }, + { url = "https://files.pythonhosted.org/packages/47/da/99f4345d4ddf2845cb5b5bd0d93d554e84542d116934fde07a0c50bd4e9f/psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3", size = 289017, upload-time = "2024-12-19T18:21:57.875Z" }, + { url = "https://files.pythonhosted.org/packages/38/53/bd755c2896f4461fd4f36fa6a6dcb66a88a9e4b9fd4e5b66a77cf9d4a584/psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53", size = 250602, upload-time = "2024-12-19T18:22:08.808Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d7/7831438e6c3ebbfa6e01a927127a6cb42ad3ab844247f3c5b96bea25d73d/psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649", size = 254444, upload-time = "2024-12-19T18:22:11.335Z" }, +] + +[[package]] +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +name = "pydantic" +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.11.7" +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +dependencies = [{ name = "typing-extensions" }] +name = "pydantic-core" +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.33.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +dependencies = [{ name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }] +name = "pydantic-settings" +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.10.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, +] + +[[package]] +dependencies = [{ name = "stdlib-list" }] +name = "pydeps" +sdist = { url = "https://files.pythonhosted.org/packages/5a/03/ce4baba41362297576f84f2d1906af25e43b46cc368afda4ac8bfe4bfd81/pydeps-3.0.1.tar.gz", hash = "sha256:a57415a8fae2ff6840a199b7dfcfecb90c37e4b9b54b58a111808a3440bc03bc", size = 53070, upload-time = "2025-02-04T11:50:10.167Z" } +source = { registry = "https://pypi.org/simple" } +version = "3.0.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/ea/663366200286a95fa6ac0ea3a67510cc5799983b102bddc845d9370bf1c8/pydeps-3.0.1-py3-none-any.whl", hash = "sha256:7c86ee63c9ee6ddd088c840364981c5aa214a994d323bb7fa4724fca30829bee", size = 47596, upload-time = "2025-02-04T11:50:07.717Z" }, +] + +[[package]] +dependencies = [{ name = "typing-extensions" }] +name = "pyee" +sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250, upload-time = "2025-03-17T18:53:15.955Z" } +source = { registry = "https://pypi.org/simple" } +version = "13.0.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, +] + +[[package]] +name = "pygments" +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.19.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +dependencies = [{ name = "markdown" }, { name = "pyyaml" }] +name = "pymdown-extensions" +sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277, upload-time = "2025-07-28T16:19:34.167Z" } +source = { registry = "https://pypi.org/simple" } +version = "10.16.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, +] + +[[package]] +name = "pyodide-py" +sdist = { url = "https://files.pythonhosted.org/packages/85/3d/bdb623dc6b1165c906b958b25f81ea2b0ba2f06ded5e491d272f9a54c35d/pyodide_py-0.28.1.tar.gz", hash = "sha256:11464c6e0e3063e7c0bfc2802f53c74f75fb0834190b86bb78769f84c635a18e", size = 52569, upload-time = "2025-08-04T21:31:55.431Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.28.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/f9/a2533ac6831763c36dd1f4cb6ba9eebb8f41b1880be9e89e3b5994c6c1f8/pyodide_py-0.28.1-py3-none-any.whl", hash = "sha256:e158e58da4cee77d1dc825bdeddd689335a41b8a79b4fa4483a0f3c50e0454e7", size = 58478, upload-time = "2025-08-04T21:31:54.07Z" }, +] + +[[package]] +dependencies = [{ name = "nodeenv" }, { name = "typing-extensions" }] +name = "pyright" +sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526, upload-time = "2025-07-09T07:15:52.882Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.1.403" +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" }, +] + +[[package]] +dependencies = [ + { name = "jinja2" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "requests" }, + { name = "rich" }, + { name = "toml" }, + { name = "typer" }, +] +name = "pyscript" +sdist = { url = "https://files.pythonhosted.org/packages/9d/4d/ee8606f71049fe29666e5541f0dd1a4f861dd3a6824334fac3c5aec3e8d9/pyscript-0.3.3.tar.gz", hash = "sha256:11fc64a3f187d8645c601ae6a80e3f0142e0dd9e0c5d3244b0ec508ca0d373f9", size = 20791, upload-time = "2024-09-09T13:02:34.234Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.3.3" +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/d8/1881edf3b8653cf2f3b8005704126c738c151b6f8168a5806ea61f1efb5f/pyscript-0.3.3-py3-none-any.whl", hash = "sha256:320383f38e9eec6515dbe0c184d4ad9d9c58e2c98fb82ec09e8d8b2e93c9e62f", size = 15556, upload-time = "2024-09-09T13:02:32.756Z" }, +] + +[[package]] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +name = "pytest" +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +source = { registry = "https://pypi.org/simple" } +version = "8.4.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +dependencies = [{ name = "pytest" }] +name = "pytest-asyncio" +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.1.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + +[[package]] +dependencies = [{ name = "pytest" }, { name = "requests" }] +name = "pytest-base-url" +sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.1.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" }, +] + +[[package]] +dependencies = [ + { name = "playwright" }, + { name = "pytest" }, + { name = "pytest-base-url" }, + { name = "python-slugify" }, +] +name = "pytest-playwright" +sdist = { url = "https://files.pythonhosted.org/packages/e3/47/38e292ad92134a00ea05e6fc4fc44577baaa38b0922ab7ea56312b7a6663/pytest_playwright-0.7.0.tar.gz", hash = "sha256:b3f2ea514bbead96d26376fac182f68dcd6571e7cb41680a89ff1673c05d60b6", size = 16666, upload-time = "2025-01-31T11:06:05.453Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.7.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/96/5f8a4545d783674f3de33f0ebc4db16cc76ce77a4c404d284f43f09125e3/pytest_playwright-0.7.0-py3-none-any.whl", hash = "sha256:2516d0871fa606634bfe32afbcc0342d68da2dbff97fe3459849e9c428486da2", size = 16618, upload-time = "2025-01-31T11:06:08.075Z" }, +] + +[[package]] +dependencies = [{ name = "packaging" }, { name = "pytest" }, { name = "termcolor" }] +name = "pytest-sugar" +sdist = { url = "https://files.pythonhosted.org/packages/f5/ac/5754f5edd6d508bc6493bc37d74b928f102a5fff82d9a80347e180998f08/pytest-sugar-1.0.0.tar.gz", hash = "sha256:6422e83258f5b0c04ce7c632176c7732cab5fdb909cb39cca5c9139f81276c0a", size = 14992, upload-time = "2024-02-01T18:30:36.735Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.0.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", size = 10171, upload-time = "2024-02-01T18:30:29.395Z" }, +] + +[[package]] +dependencies = [{ name = "six" }] +name = "python-dateutil" +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" } +source = { registry = "https://pypi.org/simple" } +version = "2.9.0.post0" +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 = "python-dotenv" +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.1.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +dependencies = [{ name = "text-unidecode" }] +name = "python-slugify" +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +source = { registry = "https://pypi.org/simple" } +version = "8.0.4" +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + +[[package]] +name = "pywin32" +source = { registry = "https://pypi.org/simple" } +version = "311" +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +source = { registry = "https://pypi.org/simple" } +version = "6.0.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +dependencies = [{ name = "pyyaml" }] +name = "pyyaml-env-tag" +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +dependencies = [{ name = "typing-extensions" }] +name = "reactivex" +sdist = { url = "https://files.pythonhosted.org/packages/ef/63/f776322df4d7b456446eff78c4e64f14c3c26d57d46b4e06c18807d5d99c/reactivex-4.0.4.tar.gz", hash = "sha256:e912e6591022ab9176df8348a653fe8c8fa7a301f26f9931c9d8c78a650e04e8", size = 119177, upload-time = "2022-07-16T07:11:53.689Z" } +source = { registry = "https://pypi.org/simple" } +version = "4.0.4" +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/3f/2ed8c1b8fe3fc2ed816ba40554ef703aad8c51700e2606c139fcf9b7f791/reactivex-4.0.4-py3-none-any.whl", hash = "sha256:0004796c420bd9e68aad8e65627d85a8e13f293de76656165dffbcb3a0e3fb6a", size = 217791, upload-time = "2022-07-16T07:11:52.061Z" }, +] + +[[package]] +name = "regex" +sdist = { url = "https://files.pythonhosted.org/packages/0b/de/e13fa6dc61d78b30ba47481f99933a3b49a57779d625c392d8036770a60d/regex-2025.7.34.tar.gz", hash = "sha256:9ead9765217afd04a86822dfcd4ed2747dfe426e887da413b15ff0ac2457e21a", size = 400714, upload-time = "2025-07-31T00:21:16.262Z" } +source = { registry = "https://pypi.org/simple" } +version = "2025.7.34" +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/16/b709b2119975035169a25aa8e4940ca177b1a2e25e14f8d996d09130368e/regex-2025.7.34-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c3c9740a77aeef3f5e3aaab92403946a8d34437db930a0280e7e81ddcada61f5", size = 485334, upload-time = "2025-07-31T00:19:56.58Z" }, + { url = "https://files.pythonhosted.org/packages/94/a6/c09136046be0595f0331bc58a0e5f89c2d324cf734e0b0ec53cf4b12a636/regex-2025.7.34-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:69ed3bc611540f2ea70a4080f853741ec698be556b1df404599f8724690edbcd", size = 289942, upload-time = "2025-07-31T00:19:57.943Z" }, + { url = "https://files.pythonhosted.org/packages/36/91/08fc0fd0f40bdfb0e0df4134ee37cfb16e66a1044ac56d36911fd01c69d2/regex-2025.7.34-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d03c6f9dcd562c56527c42b8530aad93193e0b3254a588be1f2ed378cdfdea1b", size = 285991, upload-time = "2025-07-31T00:19:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/99dc8f6f756606f0c214d14c7b6c17270b6bbe26d5c1f05cde9dbb1c551f/regex-2025.7.34-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6164b1d99dee1dfad33f301f174d8139d4368a9fb50bf0a3603b2eaf579963ad", size = 797415, upload-time = "2025-07-31T00:20:01.668Z" }, + { url = "https://files.pythonhosted.org/packages/62/cf/2fcdca1110495458ba4e95c52ce73b361cf1cafd8a53b5c31542cde9a15b/regex-2025.7.34-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1e4f4f62599b8142362f164ce776f19d79bdd21273e86920a7b604a4275b4f59", size = 862487, upload-time = "2025-07-31T00:20:03.142Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/899105dd27fed394e3fae45607c1983e138273ec167e47882fc401f112b9/regex-2025.7.34-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72a26dcc6a59c057b292f39d41465d8233a10fd69121fa24f8f43ec6294e5415", size = 910717, upload-time = "2025-07-31T00:20:04.727Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f6/4716198dbd0bcc9c45625ac4c81a435d1c4d8ad662e8576dac06bab35b17/regex-2025.7.34-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5273fddf7a3e602695c92716c420c377599ed3c853ea669c1fe26218867002f", size = 801943, upload-time = "2025-07-31T00:20:07.1Z" }, + { url = "https://files.pythonhosted.org/packages/40/5d/cff8896d27e4e3dd11dd72ac78797c7987eb50fe4debc2c0f2f1682eb06d/regex-2025.7.34-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c1844be23cd40135b3a5a4dd298e1e0c0cb36757364dd6cdc6025770363e06c1", size = 786664, upload-time = "2025-07-31T00:20:08.818Z" }, + { url = "https://files.pythonhosted.org/packages/10/29/758bf83cf7b4c34f07ac3423ea03cee3eb3176941641e4ccc05620f6c0b8/regex-2025.7.34-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dde35e2afbbe2272f8abee3b9fe6772d9b5a07d82607b5788e8508974059925c", size = 856457, upload-time = "2025-07-31T00:20:10.328Z" }, + { url = "https://files.pythonhosted.org/packages/d7/30/c19d212b619963c5b460bfed0ea69a092c6a43cba52a973d46c27b3e2975/regex-2025.7.34-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f6e8e7af516a7549412ce57613e859c3be27d55341a894aacaa11703a4c31a", size = 849008, upload-time = "2025-07-31T00:20:11.823Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b8/3c35da3b12c87e3cc00010ef6c3a4ae787cff0bc381aa3d251def219969a/regex-2025.7.34-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:469142fb94a869beb25b5f18ea87646d21def10fbacb0bcb749224f3509476f0", size = 788101, upload-time = "2025-07-31T00:20:13.729Z" }, + { url = "https://files.pythonhosted.org/packages/47/80/2f46677c0b3c2b723b2c358d19f9346e714113865da0f5f736ca1a883bde/regex-2025.7.34-cp313-cp313-win32.whl", hash = "sha256:da7507d083ee33ccea1310447410c27ca11fb9ef18c95899ca57ff60a7e4d8f1", size = 264401, upload-time = "2025-07-31T00:20:15.233Z" }, + { url = "https://files.pythonhosted.org/packages/be/fa/917d64dd074682606a003cba33585c28138c77d848ef72fc77cbb1183849/regex-2025.7.34-cp313-cp313-win_amd64.whl", hash = "sha256:9d644de5520441e5f7e2db63aec2748948cc39ed4d7a87fd5db578ea4043d997", size = 275368, upload-time = "2025-07-31T00:20:16.711Z" }, + { url = "https://files.pythonhosted.org/packages/65/cd/f94383666704170a2154a5df7b16be28f0c27a266bffcd843e58bc84120f/regex-2025.7.34-cp313-cp313-win_arm64.whl", hash = "sha256:7bf1c5503a9f2cbd2f52d7e260acb3131b07b6273c470abb78568174fe6bde3f", size = 268482, upload-time = "2025-07-31T00:20:18.189Z" }, + { url = "https://files.pythonhosted.org/packages/ac/23/6376f3a23cf2f3c00514b1cdd8c990afb4dfbac3cb4a68b633c6b7e2e307/regex-2025.7.34-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:8283afe7042d8270cecf27cca558873168e771183d4d593e3c5fe5f12402212a", size = 485385, upload-time = "2025-07-31T00:20:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/73/5b/6d4d3a0b4d312adbfd6d5694c8dddcf1396708976dd87e4d00af439d962b/regex-2025.7.34-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6c053f9647e3421dd2f5dff8172eb7b4eec129df9d1d2f7133a4386319b47435", size = 289788, upload-time = "2025-07-31T00:20:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/92/71/5862ac9913746e5054d01cb9fb8125b3d0802c0706ef547cae1e7f4428fa/regex-2025.7.34-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a16dd56bbcb7d10e62861c3cd000290ddff28ea142ffb5eb3470f183628011ac", size = 286136, upload-time = "2025-07-31T00:20:26.146Z" }, + { url = "https://files.pythonhosted.org/packages/27/df/5b505dc447eb71278eba10d5ec940769ca89c1af70f0468bfbcb98035dc2/regex-2025.7.34-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69c593ff5a24c0d5c1112b0df9b09eae42b33c014bdca7022d6523b210b69f72", size = 797753, upload-time = "2025-07-31T00:20:27.919Z" }, + { url = "https://files.pythonhosted.org/packages/86/38/3e3dc953d13998fa047e9a2414b556201dbd7147034fbac129392363253b/regex-2025.7.34-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98d0ce170fcde1a03b5df19c5650db22ab58af375aaa6ff07978a85c9f250f0e", size = 863263, upload-time = "2025-07-31T00:20:29.803Z" }, + { url = "https://files.pythonhosted.org/packages/68/e5/3ff66b29dde12f5b874dda2d9dec7245c2051f2528d8c2a797901497f140/regex-2025.7.34-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d72765a4bff8c43711d5b0f5b452991a9947853dfa471972169b3cc0ba1d0751", size = 910103, upload-time = "2025-07-31T00:20:31.313Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fe/14176f2182125977fba3711adea73f472a11f3f9288c1317c59cd16ad5e6/regex-2025.7.34-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4494f8fd95a77eb434039ad8460e64d57baa0434f1395b7da44015bef650d0e4", size = 801709, upload-time = "2025-07-31T00:20:33.323Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0d/80d4e66ed24f1ba876a9e8e31b709f9fd22d5c266bf5f3ab3c1afe683d7d/regex-2025.7.34-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4f42b522259c66e918a0121a12429b2abcf696c6f967fa37bdc7b72e61469f98", size = 786726, upload-time = "2025-07-31T00:20:35.252Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/c3ebb30e04a56c046f5c85179dc173818551037daae2c0c940c7b19152cb/regex-2025.7.34-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:aaef1f056d96a0a5d53ad47d019d5b4c66fe4be2da87016e0d43b7242599ffc7", size = 857306, upload-time = "2025-07-31T00:20:37.12Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b2/a4dc5d8b14f90924f27f0ac4c4c4f5e195b723be98adecc884f6716614b6/regex-2025.7.34-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:656433e5b7dccc9bc0da6312da8eb897b81f5e560321ec413500e5367fcd5d47", size = 848494, upload-time = "2025-07-31T00:20:38.818Z" }, + { url = "https://files.pythonhosted.org/packages/0d/21/9ac6e07a4c5e8646a90b56b61f7e9dac11ae0747c857f91d3d2bc7c241d9/regex-2025.7.34-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e91eb2c62c39705e17b4d42d4b86c4e86c884c0d15d9c5a47d0835f8387add8e", size = 787850, upload-time = "2025-07-31T00:20:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/d51204e28e7bc54f9a03bb799b04730d7e54ff2718862b8d4e09e7110a6a/regex-2025.7.34-cp314-cp314-win32.whl", hash = "sha256:f978ddfb6216028c8f1d6b0f7ef779949498b64117fc35a939022f67f810bdcb", size = 269730, upload-time = "2025-07-31T00:20:42.253Z" }, + { url = "https://files.pythonhosted.org/packages/74/52/a7e92d02fa1fdef59d113098cb9f02c5d03289a0e9f9e5d4d6acccd10677/regex-2025.7.34-cp314-cp314-win_amd64.whl", hash = "sha256:4b7dc33b9b48fb37ead12ffc7bdb846ac72f99a80373c4da48f64b373a7abeae", size = 278640, upload-time = "2025-07-31T00:20:44.42Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/a815529b559b1771080faa90c3ab401730661f99d495ab0071649f139ebd/regex-2025.7.34-cp314-cp314-win_arm64.whl", hash = "sha256:4b8c4d39f451e64809912c82392933d80fe2e4a87eeef8859fcc5380d0173c64", size = 271757, upload-time = "2025-07-31T00:20:46.355Z" }, +] + +[[package]] +dependencies = [{ name = "certifi" }, { name = "charset-normalizer" }, { name = "idna" }, { name = "urllib3" }] +name = "requests" +sdist = { url = "https://files.pythonhosted.org/packages/9d/be/10918a2eac4ae9f02f6cfe6414b7a155ccd8f7f9d4380d62fd5b955065c3/requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1", size = 110794, upload-time = "2023-05-22T15:12:44.175Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.31.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", size = 62574, upload-time = "2023-05-22T15:12:42.313Z" }, +] + +[[package]] +dependencies = [{ name = "markdown-it-py" }, { name = "pygments" }] +name = "rich" +sdist = { url = "https://files.pythonhosted.org/packages/b3/01/c954e134dc440ab5f96952fe52b4fdc64225530320a910473c1fe270d9aa/rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432", size = 221248, upload-time = "2024-02-28T14:51:19.472Z" } +source = { registry = "https://pypi.org/simple" } +version = "13.7.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/67/a37f6214d0e9fe57f6ae54b2956d550ca8365857f42a1ce0392bb21d9410/rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222", size = 240681, upload-time = "2024-02-28T14:51:14.353Z" }, +] + +[[package]] +name = "ruff" +sdist = { url = "https://files.pythonhosted.org/packages/4a/45/2e403fa7007816b5fbb324cb4f8ed3c7402a927a0a0cb2b6279879a8bfdc/ruff-0.12.9.tar.gz", hash = "sha256:fbd94b2e3c623f659962934e52c2bea6fc6da11f667a427a368adaf3af2c866a", size = 5254702, upload-time = "2025-08-14T16:08:55.2Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.12.9" +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/20/53bf098537adb7b6a97d98fcdebf6e916fcd11b2e21d15f8c171507909cc/ruff-0.12.9-py3-none-linux_armv6l.whl", hash = "sha256:fcebc6c79fcae3f220d05585229463621f5dbf24d79fdc4936d9302e177cfa3e", size = 11759705, upload-time = "2025-08-14T16:08:12.968Z" }, + { url = "https://files.pythonhosted.org/packages/20/4d/c764ee423002aac1ec66b9d541285dd29d2c0640a8086c87de59ebbe80d5/ruff-0.12.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aed9d15f8c5755c0e74467731a007fcad41f19bcce41cd75f768bbd687f8535f", size = 12527042, upload-time = "2025-08-14T16:08:16.54Z" }, + { url = "https://files.pythonhosted.org/packages/8b/45/cfcdf6d3eb5fc78a5b419e7e616d6ccba0013dc5b180522920af2897e1be/ruff-0.12.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5b15ea354c6ff0d7423814ba6d44be2807644d0c05e9ed60caca87e963e93f70", size = 11724457, upload-time = "2025-08-14T16:08:18.686Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/44615c754b55662200c48bebb02196dbb14111b6e266ab071b7e7297b4ec/ruff-0.12.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d596c2d0393c2502eaabfef723bd74ca35348a8dac4267d18a94910087807c53", size = 11949446, upload-time = "2025-08-14T16:08:21.059Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d1/9b7d46625d617c7df520d40d5ac6cdcdf20cbccb88fad4b5ecd476a6bb8d/ruff-0.12.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b15599931a1a7a03c388b9c5df1bfa62be7ede6eb7ef753b272381f39c3d0ff", size = 11566350, upload-time = "2025-08-14T16:08:23.433Z" }, + { url = "https://files.pythonhosted.org/packages/59/20/b73132f66f2856bc29d2d263c6ca457f8476b0bbbe064dac3ac3337a270f/ruff-0.12.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3d02faa2977fb6f3f32ddb7828e212b7dd499c59eb896ae6c03ea5c303575756", size = 13270430, upload-time = "2025-08-14T16:08:25.837Z" }, + { url = "https://files.pythonhosted.org/packages/a2/21/eaf3806f0a3d4c6be0a69d435646fba775b65f3f2097d54898b0fd4bb12e/ruff-0.12.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:17d5b6b0b3a25259b69ebcba87908496e6830e03acfb929ef9fd4c58675fa2ea", size = 14264717, upload-time = "2025-08-14T16:08:27.907Z" }, + { url = "https://files.pythonhosted.org/packages/d2/82/1d0c53bd37dcb582b2c521d352fbf4876b1e28bc0d8894344198f6c9950d/ruff-0.12.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72db7521860e246adbb43f6ef464dd2a532ef2ef1f5dd0d470455b8d9f1773e0", size = 13684331, upload-time = "2025-08-14T16:08:30.352Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2f/1c5cf6d8f656306d42a686f1e207f71d7cebdcbe7b2aa18e4e8a0cb74da3/ruff-0.12.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a03242c1522b4e0885af63320ad754d53983c9599157ee33e77d748363c561ce", size = 12739151, upload-time = "2025-08-14T16:08:32.55Z" }, + { url = "https://files.pythonhosted.org/packages/47/09/25033198bff89b24d734e6479e39b1968e4c992e82262d61cdccaf11afb9/ruff-0.12.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fc83e4e9751e6c13b5046d7162f205d0a7bac5840183c5beebf824b08a27340", size = 12954992, upload-time = "2025-08-14T16:08:34.816Z" }, + { url = "https://files.pythonhosted.org/packages/52/8e/d0dbf2f9dca66c2d7131feefc386523404014968cd6d22f057763935ab32/ruff-0.12.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:881465ed56ba4dd26a691954650de6ad389a2d1fdb130fe51ff18a25639fe4bb", size = 12899569, upload-time = "2025-08-14T16:08:36.852Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b614d7c08515b1428ed4d3f1d4e3d687deffb2479703b90237682586fa66/ruff-0.12.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:43f07a3ccfc62cdb4d3a3348bf0588358a66da756aa113e071b8ca8c3b9826af", size = 11751983, upload-time = "2025-08-14T16:08:39.314Z" }, + { url = "https://files.pythonhosted.org/packages/58/d6/383e9f818a2441b1a0ed898d7875f11273f10882f997388b2b51cb2ae8b5/ruff-0.12.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:07adb221c54b6bba24387911e5734357f042e5669fa5718920ee728aba3cbadc", size = 11538635, upload-time = "2025-08-14T16:08:41.297Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/56f869d314edaa9fc1f491706d1d8a47747b9d714130368fbd69ce9024e9/ruff-0.12.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f5cd34fabfdea3933ab85d72359f118035882a01bff15bd1d2b15261d85d5f66", size = 12534346, upload-time = "2025-08-14T16:08:43.39Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4b/d8b95c6795a6c93b439bc913ee7a94fda42bb30a79285d47b80074003ee7/ruff-0.12.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f6be1d2ca0686c54564da8e7ee9e25f93bdd6868263805f8c0b8fc6a449db6d7", size = 13017021, upload-time = "2025-08-14T16:08:45.889Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c1/5f9a839a697ce1acd7af44836f7c2181cdae5accd17a5cb85fcbd694075e/ruff-0.12.9-py3-none-win32.whl", hash = "sha256:cc7a37bd2509974379d0115cc5608a1a4a6c4bff1b452ea69db83c8855d53f93", size = 11734785, upload-time = "2025-08-14T16:08:48.062Z" }, + { url = "https://files.pythonhosted.org/packages/fa/66/cdddc2d1d9a9f677520b7cfc490d234336f523d4b429c1298de359a3be08/ruff-0.12.9-py3-none-win_amd64.whl", hash = "sha256:6fb15b1977309741d7d098c8a3cb7a30bc112760a00fb6efb7abc85f00ba5908", size = 12840654, upload-time = "2025-08-14T16:08:50.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fd/669816bc6b5b93b9586f3c1d87cd6bc05028470b3ecfebb5938252c47a35/ruff-0.12.9-py3-none-win_arm64.whl", hash = "sha256:63c8c819739d86b96d500cce885956a1a48ab056bbcbc61b747ad494b2485089", size = 11949623, upload-time = "2025-08-14T16:08:52.233Z" }, +] + +[[package]] +name = "safetensors" +sdist = { url = "https://files.pythonhosted.org/packages/ac/cc/738f3011628920e027a11754d9cae9abec1aed00f7ae860abbf843755233/safetensors-0.6.2.tar.gz", hash = "sha256:43ff2aa0e6fa2dc3ea5524ac7ad93a9839256b8703761e76e2d0b2a3fa4f15d9", size = 197968, upload-time = "2025-08-08T13:13:58.654Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.6.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/b1/3f5fd73c039fc87dba3ff8b5d528bfc5a32b597fea8e7a6a4800343a17c7/safetensors-0.6.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9c85ede8ec58f120bad982ec47746981e210492a6db876882aa021446af8ffba", size = 454797, upload-time = "2025-08-08T13:13:52.066Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c9/bb114c158540ee17907ec470d01980957fdaf87b4aa07914c24eba87b9c6/safetensors-0.6.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d6675cf4b39c98dbd7d940598028f3742e0375a6b4d4277e76beb0c35f4b843b", size = 432206, upload-time = "2025-08-08T13:13:50.931Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/f70c34e47df3110e8e0bb268d90db8d4be8958a54ab0336c9be4fe86dac8/safetensors-0.6.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d2d2b3ce1e2509c68932ca03ab8f20570920cd9754b05063d4368ee52833ecd", size = 473261, upload-time = "2025-08-08T13:13:41.259Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/be9c6a7c7ef773e1996dc214e73485286df1836dbd063e8085ee1976f9cb/safetensors-0.6.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:93de35a18f46b0f5a6a1f9e26d91b442094f2df02e9fd7acf224cfec4238821a", size = 485117, upload-time = "2025-08-08T13:13:43.506Z" }, + { url = "https://files.pythonhosted.org/packages/c9/55/23f2d0a2c96ed8665bf17a30ab4ce5270413f4d74b6d87dd663258b9af31/safetensors-0.6.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89a89b505f335640f9120fac65ddeb83e40f1fd081cb8ed88b505bdccec8d0a1", size = 616154, upload-time = "2025-08-08T13:13:45.096Z" }, + { url = "https://files.pythonhosted.org/packages/98/c6/affb0bd9ce02aa46e7acddbe087912a04d953d7a4d74b708c91b5806ef3f/safetensors-0.6.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4d0d0b937e04bdf2ae6f70cd3ad51328635fe0e6214aa1fc811f3b576b3bda", size = 520713, upload-time = "2025-08-08T13:13:46.25Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5d/5a514d7b88e310c8b146e2404e0dc161282e78634d9358975fd56dfd14be/safetensors-0.6.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8045db2c872db8f4cbe3faa0495932d89c38c899c603f21e9b6486951a5ecb8f", size = 485835, upload-time = "2025-08-08T13:13:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7b/4fc3b2ba62c352b2071bea9cfbad330fadda70579f617506ae1a2f129cab/safetensors-0.6.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:81e67e8bab9878bb568cffbc5f5e655adb38d2418351dc0859ccac158f753e19", size = 521503, upload-time = "2025-08-08T13:13:47.651Z" }, + { url = "https://files.pythonhosted.org/packages/5a/50/0057e11fe1f3cead9254315a6c106a16dd4b1a19cd247f7cc6414f6b7866/safetensors-0.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0e4d029ab0a0e0e4fdf142b194514695b1d7d3735503ba700cf36d0fc7136ce", size = 652256, upload-time = "2025-08-08T13:13:53.167Z" }, + { url = "https://files.pythonhosted.org/packages/e9/29/473f789e4ac242593ac1656fbece6e1ecd860bb289e635e963667807afe3/safetensors-0.6.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fa48268185c52bfe8771e46325a1e21d317207bcabcb72e65c6e28e9ffeb29c7", size = 747281, upload-time = "2025-08-08T13:13:54.656Z" }, + { url = "https://files.pythonhosted.org/packages/68/52/f7324aad7f2df99e05525c84d352dc217e0fa637a4f603e9f2eedfbe2c67/safetensors-0.6.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:d83c20c12c2d2f465997c51b7ecb00e407e5f94d7dec3ea0cc11d86f60d3fde5", size = 692286, upload-time = "2025-08-08T13:13:55.884Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/cad1d9762868c7c5dc70c8620074df28ebb1a8e4c17d4c0cb031889c457e/safetensors-0.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d944cea65fad0ead848b6ec2c37cc0b197194bec228f8020054742190e9312ac", size = 655957, upload-time = "2025-08-08T13:13:57.029Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/e2158e17bbe57d104f0abbd95dff60dda916cf277c9f9663b4bf9bad8b6e/safetensors-0.6.2-cp38-abi3-win32.whl", hash = "sha256:cab75ca7c064d3911411461151cb69380c9225798a20e712b102edda2542ddb1", size = 308926, upload-time = "2025-08-08T13:14:01.095Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c3/c0be1135726618dc1e28d181b8c442403d8dbb9e273fd791de2d4384bcdd/safetensors-0.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:c7b214870df923cbc1593c3faee16bec59ea462758699bd3fee399d00aac072c", size = 320192, upload-time = "2025-08-08T13:13:59.467Z" }, +] + +[[package]] +name = "setuptools" +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +source = { registry = "https://pypi.org/simple" } +version = "80.9.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "six" +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" } +source = { registry = "https://pypi.org/simple" } +version = "1.17.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.3.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "soupsieve" +sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.7" +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" }, +] + +[[package]] +dependencies = [{ name = "anyio" }] +name = "starlette" +sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.47.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, +] + +[[package]] +name = "stdlib-list" +sdist = { url = "https://files.pythonhosted.org/packages/5d/09/8d5c564931ae23bef17420a6c72618463a59222ca4291a7dd88de8a0d490/stdlib_list-0.11.1.tar.gz", hash = "sha256:95ebd1d73da9333bba03ccc097f5bac05e3aa03e6822a0c0290f87e1047f1857", size = 60442, upload-time = "2025-02-18T15:39:38.769Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.11.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c7/4102536de33c19d090ed2b04e90e7452e2e3dc653cf3323208034eaaca27/stdlib_list-0.11.1-py3-none-any.whl", hash = "sha256:9029ea5e3dfde8cd4294cfd4d1797be56a67fc4693c606181730148c3fd1da29", size = 83620, upload-time = "2025-02-18T15:39:37.02Z" }, +] + +[[package]] +dependencies = [{ name = "mpmath" }] +name = "sympy" +sdist = { url = "https://files.pythonhosted.org/packages/ca/99/5a5b6f19ff9f083671ddf7b9632028436167cd3d33e11015754e41b249a4/sympy-1.13.1.tar.gz", hash = "sha256:9cebf7e04ff162015ce31c9c6c9144daa34a93bd082f54fd8f12deca4f47515f", size = 7533040, upload-time = "2024-07-19T09:26:51.238Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.13.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/fe/81695a1aa331a842b582453b605175f419fe8540355886031328089d840a/sympy-1.13.1-py3-none-any.whl", hash = "sha256:db36cdc64bf61b9b24578b6f7bab1ecdd2452cf008f34faa33776680c26d66f8", size = 6189177, upload-time = "2024-07-19T09:26:48.863Z" }, +] + +[[package]] +dependencies = [ + { name = "colorama" }, + { name = "mslex", marker = "sys_platform == 'win32'" }, + { name = "psutil" }, + { name = "tomli", marker = "python_full_version < '4'" }, +] +name = "taskipy" +sdist = { url = "https://files.pythonhosted.org/packages/c7/44/572261df3db9c6c3332f8618fafeb07a578fd18b06673c73f000f3586749/taskipy-1.14.1.tar.gz", hash = "sha256:410fbcf89692dfd4b9f39c2b49e1750b0a7b81affd0e2d7ea8c35f9d6a4774ed", size = 14475, upload-time = "2024-11-26T16:37:46.155Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.14.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/97/4e4cfb1391c81e926bebe3d68d5231b5dbc3bb41c6ba48349e68a881462d/taskipy-1.14.1-py3-none-any.whl", hash = "sha256:6e361520f29a0fd2159848e953599f9c75b1d0b047461e4965069caeb94908f1", size = 13052, upload-time = "2024-11-26T16:37:44.546Z" }, +] + +[[package]] +name = "termcolor" +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } +source = { registry = "https://pypi.org/simple" } +version = "3.1.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, +] + +[[package]] +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +name = "testcontainers" +sdist = { url = "https://files.pythonhosted.org/packages/d3/62/01d9f648e9b943175e0dcddf749cf31c769665d8ba08df1e989427163f33/testcontainers-4.12.0.tar.gz", hash = "sha256:13ee89cae995e643f225665aad8b200b25c4f219944a6f9c0b03249ec3f31b8d", size = 66631, upload-time = "2025-07-21T20:32:26.37Z" } +source = { registry = "https://pypi.org/simple" } +version = "4.12.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/e8/9e2c392e5d671afda47b917597cac8fde6a452f5776c4c9ceb93fbd2889f/testcontainers-4.12.0-py3-none-any.whl", hash = "sha256:26caef57e642d5e8c5fcc593881cf7df3ab0f0dc9170fad22765b184e226ab15", size = 111791, upload-time = "2025-07-21T20:32:25.038Z" }, +] + +[[package]] +name = "text-unidecode" +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.3" +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + +[[package]] +dependencies = [{ name = "huggingface-hub" }] +name = "tokenizers" +sdist = { url = "https://files.pythonhosted.org/packages/c2/2f/402986d0823f8d7ca139d969af2917fefaa9b947d1fb32f6168c509f2492/tokenizers-0.21.4.tar.gz", hash = "sha256:fa23f85fbc9a02ec5c6978da172cdcbac23498c3ca9f3645c5c68740ac007880", size = 351253, upload-time = "2025-07-28T15:48:54.325Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.21.4" +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/c6/fdb6f72bf6454f52eb4a2510be7fb0f614e541a2554d6210e370d85efff4/tokenizers-0.21.4-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ccc10a7c3bcefe0f242867dc914fc1226ee44321eb618cfe3019b5df3400133", size = 2863987, upload-time = "2025-07-28T15:48:44.877Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a6/28975479e35ddc751dc1ddc97b9b69bf7fcf074db31548aab37f8116674c/tokenizers-0.21.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:5e2f601a8e0cd5be5cc7506b20a79112370b9b3e9cb5f13f68ab11acd6ca7d60", size = 2732457, upload-time = "2025-07-28T15:48:43.265Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8f/24f39d7b5c726b7b0be95dca04f344df278a3fe3a4deb15a975d194cbb32/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39b376f5a1aee67b4d29032ee85511bbd1b99007ec735f7f35c8a2eb104eade5", size = 3012624, upload-time = "2025-07-28T13:22:43.895Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/26358925717687a58cb74d7a508de96649544fad5778f0cd9827398dc499/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2107ad649e2cda4488d41dfd031469e9da3fcbfd6183e74e4958fa729ffbf9c6", size = 2939681, upload-time = "2025-07-28T13:22:47.499Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/cc300fea5db2ab5ddc2c8aea5757a27b89c84469899710c3aeddc1d39801/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c73012da95afafdf235ba80047699df4384fdc481527448a078ffd00e45a7d9", size = 3247445, upload-time = "2025-07-28T15:48:39.711Z" }, + { url = "https://files.pythonhosted.org/packages/be/bf/98cb4b9c3c4afd8be89cfa6423704337dc20b73eb4180397a6e0d456c334/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f23186c40395fc390d27f519679a58023f368a0aad234af145e0f39ad1212732", size = 3428014, upload-time = "2025-07-28T13:22:49.569Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/96c1cc780e6ca7f01a57c13235dd05b7bc1c0f3588512ebe9d1331b5f5ae/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc88bb34e23a54cc42713d6d98af5f1bf79c07653d24fe984d2d695ba2c922a2", size = 3193197, upload-time = "2025-07-28T13:22:51.471Z" }, + { url = "https://files.pythonhosted.org/packages/f2/90/273b6c7ec78af547694eddeea9e05de771278bd20476525ab930cecaf7d8/tokenizers-0.21.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51b7eabb104f46c1c50b486520555715457ae833d5aee9ff6ae853d1130506ff", size = 3115426, upload-time = "2025-07-28T15:48:41.439Z" }, + { url = "https://files.pythonhosted.org/packages/91/43/c640d5a07e95f1cf9d2c92501f20a25f179ac53a4f71e1489a3dcfcc67ee/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:714b05b2e1af1288bd1bc56ce496c4cebb64a20d158ee802887757791191e6e2", size = 9089127, upload-time = "2025-07-28T15:48:46.472Z" }, + { url = "https://files.pythonhosted.org/packages/44/a1/dd23edd6271d4dca788e5200a807b49ec3e6987815cd9d0a07ad9c96c7c2/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1340ff877ceedfa937544b7d79f5b7becf33a4cfb58f89b3b49927004ef66f78", size = 9055243, upload-time = "2025-07-28T15:48:48.539Z" }, + { url = "https://files.pythonhosted.org/packages/21/2b/b410d6e9021c4b7ddb57248304dc817c4d4970b73b6ee343674914701197/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3c1f4317576e465ac9ef0d165b247825a2a4078bcd01cba6b54b867bdf9fdd8b", size = 9298237, upload-time = "2025-07-28T15:48:50.443Z" }, + { url = "https://files.pythonhosted.org/packages/b7/0a/42348c995c67e2e6e5c89ffb9cfd68507cbaeb84ff39c49ee6e0a6dd0fd2/tokenizers-0.21.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c212aa4e45ec0bb5274b16b6f31dd3f1c41944025c2358faaa5782c754e84c24", size = 9461980, upload-time = "2025-07-28T15:48:52.325Z" }, + { url = "https://files.pythonhosted.org/packages/3d/d3/dacccd834404cd71b5c334882f3ba40331ad2120e69ded32cf5fda9a7436/tokenizers-0.21.4-cp39-abi3-win32.whl", hash = "sha256:6c42a930bc5f4c47f4ea775c91de47d27910881902b0f20e4990ebe045a415d0", size = 2329871, upload-time = "2025-07-28T15:48:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/41/f2/fd673d979185f5dcbac4be7d09461cbb99751554ffb6718d0013af8604cb/tokenizers-0.21.4-cp39-abi3-win_amd64.whl", hash = "sha256:475d807a5c3eb72c59ad9b5fcdb254f6e17f53dfcbb9903233b0dfa9c943b597", size = 2507568, upload-time = "2025-07-28T15:48:55.456Z" }, +] + +[[package]] +name = "toml" +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.10.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + +[[package]] +name = "tomli" +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.2.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "setuptools" }, + { name = "sympy" }, + { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +name = "torch" +source = { registry = "https://pypi.org/simple" } +version = "2.6.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/85/ead1349fc30fe5a32cadd947c91bda4a62fbfd7f8c34ee61f6398d38fb48/torch-2.6.0-cp313-cp313-manylinux1_x86_64.whl", hash = "sha256:4874a73507a300a5d089ceaff616a569e7bb7c613c56f37f63ec3ffac65259cf", size = 766626191, upload-time = "2025-01-29T16:17:26.26Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b0/26f06f9428b250d856f6d512413e9e800b78625f63801cbba13957432036/torch-2.6.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:a0d5e1b9874c1a6c25556840ab8920569a7a4137afa8a63a32cee0bc7d89bd4b", size = 95611439, upload-time = "2025-01-29T16:21:21.061Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9c/fc5224e9770c83faed3a087112d73147cd7c7bfb7557dcf9ad87e1dda163/torch-2.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:510c73251bee9ba02ae1cb6c9d4ee0907b3ce6020e62784e2d7598e0cfa4d6cc", size = 204126475, upload-time = "2025-01-29T16:21:55.394Z" }, + { url = "https://files.pythonhosted.org/packages/88/8b/d60c0491ab63634763be1537ad488694d316ddc4a20eaadd639cedc53971/torch-2.6.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:ff96f4038f8af9f7ec4231710ed4549da1bdebad95923953a25045dcf6fd87e2", size = 66536783, upload-time = "2025-01-29T16:22:08.559Z" }, +] + +[[package]] +dependencies = [{ name = "colorama", marker = "sys_platform == 'win32'" }] +name = "tqdm" +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +source = { registry = "https://pypi.org/simple" } +version = "4.67.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +dependencies = [ + { name = "filelock" }, + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "regex" }, + { name = "requests" }, + { name = "safetensors" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +name = "transformers" +sdist = { url = "https://files.pythonhosted.org/packages/70/a5/d8b8a1f3a051daeb5f11253bb69fc241f193d1c0566e299210ed9220ff4e/transformers-4.55.2.tar.gz", hash = "sha256:a45ec60c03474fd67adbce5c434685051b7608b3f4f167c25aa6aeb1cad16d4f", size = 9571466, upload-time = "2025-08-13T18:25:43.767Z" } +source = { registry = "https://pypi.org/simple" } +version = "4.55.2" +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/5a/022ac010bedfb5119734cf9d743cf1d830cb4c604f53bb1552216f4344dc/transformers-4.55.2-py3-none-any.whl", hash = "sha256:097e3c2e2c0c9681db3da9d748d8f9d6a724c644514673d0030e8c5a1109f1f1", size = 11269748, upload-time = "2025-08-13T18:25:40.394Z" }, +] + +[[package]] +name = "triton" +source = { registry = "https://pypi.org/simple" } +version = "3.2.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/30/37a3384d1e2e9320331baca41e835e90a3767303642c7a80d4510152cbcf/triton-3.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5dfa23ba84541d7c0a531dfce76d8bcd19159d50a4a8b14ad01e91734a5c1b0", size = 253154278, upload-time = "2025-01-22T19:13:54.221Z" }, +] + +[[package]] +dependencies = [{ name = "click" }, { name = "typing-extensions" }] +name = "typer" +sdist = { url = "https://files.pythonhosted.org/packages/5b/49/39f10d0f75886439ab3dac889f14f8ad511982a754e382c9b6ca895b29e9/typer-0.9.0.tar.gz", hash = "sha256:50922fd79aea2f4751a8e0408ff10d2662bd0c8bbfa84755a699f3bada2978b2", size = 273985, upload-time = "2023-05-02T05:20:57.63Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.9.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/0e/c68adf10adda05f28a6ed7b9f4cd7b8e07f641b44af88ba72d9c89e4de7a/typer-0.9.0-py3-none-any.whl", hash = "sha256:5d96d986a21493606a358cae4461bd8cdf83cbf33a5aa950ae629ca3b51467ee", size = 45861, upload-time = "2023-05-02T05:20:55.675Z" }, +] + +[[package]] +name = "typing-extensions" +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } +source = { registry = "https://pypi.org/simple" } +version = "4.14.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, +] + +[[package]] +dependencies = [{ name = "typing-extensions" }] +name = "typing-inspection" +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.4.1" +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "urllib3" +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +source = { registry = "https://pypi.org/simple" } +version = "2.5.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +dependencies = [{ name = "click" }, { name = "h11" }] +name = "uvicorn" +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +source = { registry = "https://pypi.org/simple" } +version = "0.35.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +] + +[[package]] +dependencies = [{ name = "distlib" }, { name = "filelock" }, { name = "platformdirs" }] +name = "virtualenv" +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +source = { registry = "https://pypi.org/simple" } +version = "20.34.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, +] + +[[package]] +name = "watchdog" +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +source = { registry = "https://pypi.org/simple" } +version = "6.0.0" +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wrapt" +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +source = { registry = "https://pypi.org/simple" } +version = "1.17.3" +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] diff --git a/cool-cacti/.github/workflows/lint.yaml b/cool-cacti/.github/workflows/lint.yaml new file mode 100644 index 00000000..7f67e803 --- /dev/null +++ b/cool-cacti/.github/workflows/lint.yaml @@ -0,0 +1,35 @@ +# GitHub Action workflow enforcing our code style. + +name: Lint + +# Trigger the workflow on both push (to the main repository, on the main branch) +# and pull requests (against the main repository, but from any repo, from any branch). +on: + push: + branches: + - main + pull_request: + +# Brand new concurrency setting! This ensures that not more than one run can be triggered for the same commit. +# It is useful for pull requests coming from the main repository since both triggers will match. +concurrency: lint-${{ github.sha }} + +jobs: + lint: + runs-on: ubuntu-latest + + env: + # The Python version your project uses. Feel free to change this if required. + PYTHON_VERSION: "3.12" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ env.PYTHON_VERSION }} + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Run pre-commit hooks + uses: pre-commit/action@v3.0.1 diff --git a/cool-cacti/.gitignore b/cool-cacti/.gitignore new file mode 100644 index 00000000..12ea8c3c --- /dev/null +++ b/cool-cacti/.gitignore @@ -0,0 +1,39 @@ +horizons_data/* +!horizons_data/template.json +!horizons_data/planets.json + +# Files generated by the interpreter +__pycache__/ +*.py[cod] + +# Environment specific +.venv +venv +.env +env + +# Unittest reports +.coverage* + +# Logs +*.log + +# PyEnv version selector +.python-version + +# Built objects +*.so +dist/ +build/ + +# IDEs +# PyCharm +.idea/ +# VSCode +.vscode/ +.VSCodeCounter/ +# MacOS +.DS_Store + +# Ruff check +.ruff_cache/ \ No newline at end of file diff --git a/cool-cacti/.pre-commit-config.yaml b/cool-cacti/.pre-commit-config.yaml new file mode 100644 index 00000000..80547ab2 --- /dev/null +++ b/cool-cacti/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +# Pre-commit configuration. +# See https://github.com/python-discord/code-jam-template/tree/main#pre-commit-run-linting-before-committing + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.2 + hooks: + - id: ruff-check + args: [ --fix ] + - id: ruff-format diff --git a/cool-cacti/ATTRIBUTIONS.txt b/cool-cacti/ATTRIBUTIONS.txt new file mode 100644 index 00000000..8373639a --- /dev/null +++ b/cool-cacti/ATTRIBUTIONS.txt @@ -0,0 +1,30 @@ +Music: "25 oh 20 and to the stars", "something about space", "death screen clip" +Composed and created by Elemeno Peter https://kimeklover.newgrounds.com/ +Licensed under CC BY-NC-SA + +Planet sprites generated with Pixel Planet Generator by Deep Fold +https://deep-fold.itch.io/pixel-planet-generator +Software licensed under the MIT License + +Explosion sprites by LinkNinja +https://linkninja.itch.io/simple-explosion-animation +Licensed under CC-0 + +Recycle items art by Clint Bellanger +https://opengameart.org/content/recycle-items-set +Licensed under CC-BY 3.0 + +QR Code Scanner +https://www.hiclipart.com/free-transparent-background-png-clipart-pjnbg +HiClipart is an open community for users to share PNG images, all PNG cliparts in HiClipart are for Non-Commercial Use + +3 Explosion Bangs Copyright 2012 Iwan 'qubodup' Gabovitch +https://opengameart.org/content/3-background-crash-explosion-bang-sounds +Licensed under CC-BY 3.0 + +Rock breaking sound +https://opengameart.org/content/rockbreaking +Licensed under CC-BY 3.0 + +Scan sound from https://www.zapsplat.com +Use with attribution \ No newline at end of file diff --git a/cool-cacti/LICENSE.txt b/cool-cacti/LICENSE.txt new file mode 100644 index 00000000..5a04926b --- /dev/null +++ b/cool-cacti/LICENSE.txt @@ -0,0 +1,7 @@ +Copyright 2021 Python Discord + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/cool-cacti/README.md b/cool-cacti/README.md new file mode 100644 index 00000000..ef910d55 --- /dev/null +++ b/cool-cacti/README.md @@ -0,0 +1,132 @@ +# Planet Scanners: Python Discord Code Jam 2025 + +We created a simple sprite-based, space-themed game using PyScript backed by Pyodide and its JavaScript wrapper +capabilities to leverage JavaScript's convenient API for rendering graphics on HTML canvas elements, playing +audio, and accessing user input. We hope this can demo as an alternative to the use of a pygame wrapper such as +pygbag, without the need for its extra layer of abstraction, as the browser-side execution of our game doesn't use anything +but the Python standard library and the PyScript and Pyodide modules for accessing JavaScript functionality. + +### The Game + +The introduction depicts aliens flying around space past the speed of light, about to turn on their super +advanced intergalactic planet scanner. Today, it fails! Luckily, one of the aliens has an old Earth-based +barcode scanner - the "wrong tool for the +job" - that will have to do today. In the solar system overview screen, seen below, the player can select each of the solar system's planets in turn and must complete +a scan for that planet to complete a mission. + +![Planet selection screen](/readme_images/game1.png) + +The gameplay (shown in the image below) is a variation of the classic asteroids game, where +the player must dodge incoming asteroids to avoid ship damage and stay immobile while holding down the Spacebar +to progress scanning. +An animated rotating planet combined with the movement of stars off-screen in the opposite direction creates a +simple but effective perspective of the player ship orbiting a planet. + +![Planet selection screen](/readme_images/game2.png) + +![Planet selection screen](/readme_images/game3.png) + +## Intended Event Frameworks + +We used PyScript backed by the Pyodide interpreter. We were pleasantly surprised by how few PyScript-specific +oddities we ran into. Using the provided JavaScript wrapper capabilities felt almost as natural as writing +JavaScript directly into the Python files, except writing in Python! To serve our single-page game app, we +included an ultra-minimalistic Flask backend for convenience in designing, though with a little refactoring our page could +also be served statically. There is some very minimalistic HTML and CSS to more or less create an HTML canvas to +draw on with the appropriate styling and create the initial setup for importing our in-browser Python scripts. +It was necessary to provide the contents of a [PyScript.json](/static/PyScript.json) file to the config attribute of the tag +of our [index.html](/templates/index.html) to let the PyScript environment allow the proper imports of modules +into one another. + +One of the few things that adds a bit of awkwardness is needing to wrap function references passed to to JS callbacks by +using `create_proxy`, instead of passing a reference to the `game_loop` function directly: +```py +from Pyodide.ffi import create_proxy + +def game_loop(timestamp: float) -> None: + ... + +game_loop_proxy = create_proxy(game_loop) +window.requestAnimationFrame(game_loop_proxy) +``` + +On the other hand, "writing JavaScript" in Python can feel very elegant sometimes. A CanvasRenderingContext2D's +drawing methods for +example often take a lot of arguments to define the coordinates of objects being draw. There's heavy use of +rectangular bounds given as four parameters: left, top, width, height. Defining a Python Rect class implementing +the iterator protocol... + +```py +@dataclass +class Rect: + left: float + top: float + width: float + height: float + + def __iter__(self) -> Iterator[float]: + yield self.left + yield self.top + yield self.width + yield self.height +``` + +...allows for some drawing calls to be very succinct with unpacking: + +```py +# sprite_coords = Rect(0, 0, sprite_width, sprite_height) +# dest_coords = Rect(dest_left, dest_top, dest_width, dest_height) +ctx.fillRect(*dest_coords) +ctx.drawImage(sprite_image, *sprite_coords, *dest_coords) +``` + +## Installation and Usage + +### Prerequisites to Run +- Python 3.12 +- [uv](https://github.com/astral-sh/uv) is recommended for the package manager +- An active internet connection (to fetch the Pyodide interpreter and PyScript modules from the PyScript CDN) +### Installation +1. Clone the repository: + ```bash + git clone https://github.com/fluffy-marmot/codejam2025 + ``` + +2. Install dependencies using uv: + ```bash + uv sync + ``` +### Without uv +The dependencies are listed in [`pyproject.toml`](pyproject.toml). Since the only server-side dependency for running the +project is flask (PyScript is obtained automatically in browser as needed via CDN), the +project can be run after cloning it by simply using +```bash +pip install flask +python app.py +``` +### Running the Game +Running the [app.py](/app.py) file starts the simple flask server to serve the single html page, which should be at +[http://127.0.0.1:5000](http://127.0.0.1:5000) if testing it locally. We also have a version of our game hosted +at [https://caius.pythonanywhere.com/codejam/](https://caius.pythonanywhere.com/codejam/) although this has been +slightly modified from the current repository to run as a single app within an already existing Django project. +None of the files in the `/static/` directory of the hosted version have been modified, therefore in-browser functionality +should be the same. + +## Individual Contributions + +RealisticTurtle: storyboarding, intro scene and story, game scene, star system, scanning mechanics + +Soosh: library research, core functionality (audio and input modules, gameloop, scene system), code integration, debris +system + +Dark Zero: planet selection scene, sprites, spritesheet helper scripts, player mechanics, asteroid +implementation, collision logic, end scene + +Doomy: dynamic textboxes, end scene and credits, Horizons API functionality, +refactoring and maintenance, scanner refinement, experimented with Marimo + +## Game Demonstration + +This video is a quick demonstration of our game and its mechanics by our teammate RealisticTurtle + +View the demo on [Youtube](https://www.youtube.com/watch?v=J8LKGUsTeAo) \ No newline at end of file diff --git a/cool-cacti/app.py b/cool-cacti/app.py new file mode 100644 index 00000000..69774e8e --- /dev/null +++ b/cool-cacti/app.py @@ -0,0 +1,46 @@ +import json +from pathlib import Path + +from flask import Flask, render_template + +""" +using a flask backend to serve a very simple html file containing a canvas that we draw on using +very various pyscript scripts. We can send the planets_info variable along with the render_template +request so that it will be accessible in the index.html template and afterwards the pyscript scripts +""" +app = Flask(__name__) + +base_dir = Path(__file__).resolve().parent +static_dir = base_dir / "static" +sprite_dir = static_dir / "sprites" +audio_dir = static_dir / "audio" + +# contains various information and game data about planets +with Path.open(base_dir / "horizons_data" / "planets.json", encoding='utf-8') as f: + planets_info = json.load(f) + +# create a list of available sprite files +sprite_list = [sprite_file.stem for sprite_file in sprite_dir.iterdir() if sprite_file.is_file()] + +# create a list of available audio files +audio_list = [audio_file.name for audio_file in audio_dir.iterdir()] + +with Path.open(static_dir / "lore.txt") as f: + lore = f.read() + +with Path.open(static_dir / "credits.txt") as f: + credits = f.read() + +@app.route("/") +def index(): + return render_template( + "index.html", + planets_info=planets_info, + sprite_list=sprite_list, + audio_list=audio_list, + lore=lore, + credits=credits + ) + +if __name__ == "__main__": + app.run(debug=True) diff --git a/cool-cacti/horizons_data/planets.json b/cool-cacti/horizons_data/planets.json new file mode 100644 index 00000000..d0100903 --- /dev/null +++ b/cool-cacti/horizons_data/planets.json @@ -0,0 +1,267 @@ +[ + { + "id": 10, + "name": "Sun", + "sprite": "sun.png" + }, + { + "id": 199, + "name": "Mercury", + "sprite": "mercury.png", + "x": 50464198.3250268, + "y": 9059796.87177754, + "info": "PLANETARY SCAN COMPLETE: Mercury\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Vol. Mean Radius (km) = 2439.4+-0.1 Density (g cm^-3) = 5.427\n Mass x10^23 (kg) = 3.302 Volume (x10^10 km^3) = 6.085 \n Sidereal rot. period = 58.6463 d Sid. rot. rate (rad/s)= 0.00000124001\n Mean solar day = 175.9421 d Core radius (km) = ~1600 \n Geometric Albedo = 0.106 Surface emissivity = 0.77+-0.06\n GM (km^3/s^2) = 22031.86855 Equatorial radius, Re = 2440.53 km\n GM 1-sigma (km^3/s^2) = Mass ratio (Sun/plnt) = 6023682\n Mom. of Inertia = 0.33 Equ. gravity m/s^2 = 3.701 \n Atmos. pressure (bar) = < 5x10^-15 Max. angular diam. = 11.0\" \n Mean Temperature (K) = 440 Visual mag. V(1,0) = -0.42 \n Obliquity to orbit[1] = 2.11' +/- 0.1' Hill's sphere rad. Rp = 94.4 \n Sidereal orb. per. = 0.2408467 y Mean Orbit vel. km/s = 47.362 \n Sidereal orb. per. = 87.969257 d Escape vel. km/s = 4.435\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 14462 6278 9126\n Maximum Planetary IR (W/m^2) 12700 5500 8000\n Minimum Planetary IR (W/m^2) 6 6 6\n*******************************************************************************\n", + "level": [ + "Mercury - Scan Required", + "\n", + "Asteroid counts : *******", + "Asteroid speed : **********", + "Asteroid damage : *******", + "Asteroid durability: ****", + "Scan difficulty : ********************", + "\n", + "Mercury's sparse asteroid field is more forgiving than most, but the", + "Sun's proximity, and the constant bombardment of high-energy radiation", + "particles will gnaw at the ship over time and slow damage it.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 3.6, + "asteroid": { + "count": 7, + "speed": 10, + "damage": 7, + "durability": 4 + } + }, + { + "id": 299, + "name": "Venus", + "sprite": "venus.png", + "x": 62265012.79592998, + "y": 88255225.26065554, + "info": "PLANETARY SCAN COMPLETE: Venus\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Vol. Mean Radius (km) = 6051.84+-0.01 Density (g/cm^3) = 5.204\n Mass x10^23 (kg) = 48.685 Volume (x10^10 km^3) = 92.843\n Sidereal rot. period = 243.018484 d Sid. Rot. Rate (rad/s)= -0.00000029924\n Mean solar day = 116.7490 d Equ. gravity m/s^2 = 8.870\n Mom. of Inertia = 0.33 Core radius (km) = ~3200\n Geometric Albedo = 0.65 Potential Love # k2 = ~0.25\n GM (km^3/s^2) = 324858.592 Equatorial Radius, Re = 6051.893 km\n GM 1-sigma (km^3/s^2) = +-0.006 Mass ratio (Sun/Venus)= 408523.72\n Atmos. pressure (bar) = 90 Max. angular diam. = 60.2\"\n Mean Temperature (K) = 735 Visual mag. V(1,0) = -4.40\n Obliquity to orbit = 177.3 deg Hill's sphere rad.,Rp = 167.1\n Sidereal orb. per., y = 0.61519726 Orbit speed, km/s = 35.021\n Sidereal orb. per., d = 224.70079922 Escape speed, km/s = 10.361\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 2759 2614 2650\n Maximum Planetary IR (W/m^2) 153 153 153\n Minimum Planetary IR (W/m^2) 153 153 153\n*******************************************************************************\n", + "level": [ + "Venus - Scan Required", + "\n", + "Asteroid counts : ************", + "Asteroid speed : ***************", + "Asteroid damage : ************", + "Asteroid durability: ********", + "Scan difficulty : ************", + "\n", + "Piloting this dense asteroid field will keep you too busy to", + "ponder the mysteries of what lies beneath Venus's veil of toxic clouds.", + "Even the difficulties of navigating its orbit are better than the searing", + "heat and crushing atmosphere of its surface.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 1.6, + "asteroid": { + "count": 12, + "speed": 15, + "damage": 12, + "durability": 8 + } + }, + { + "id": 399, + "name": "Mars", + "sprite": "mars.png", + "x": 121056565.2223383, + "y": -91071517.42399806, + "info": "PLANETARY SCAN COMPLETE: Mars\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Vol. mean radius (km) = 3389.92+-0.04 Density (g/cm^3) = 3.933(5+-4)\n Mass x10^23 (kg) = 6.4171 Flattening, f = 1/169.779\n Volume (x10^10 km^3) = 16.318 Equatorial radius (km)= 3396.19\n Sidereal rot. period = 24.622962 hr Sid. rot. rate, rad/s = 0.0000708822 \n Mean solar day (sol) = 88775.24415 s Polar gravity m/s^2 = 3.758\n Core radius (km) = ~1700 Equ. gravity m/s^2 = 3.71\n Geometric Albedo = 0.150 \n\n GM (km^3/s^2) = 42828.375662 Mass ratio (Sun/Mars) = 3098703.59\n GM 1-sigma (km^3/s^2) = +- 0.00028 Mass of atmosphere, kg= ~ 2.5 x 10^16\n Mean temperature (K) = 210 Atmos. pressure (bar) = 0.0056 \n Obliquity to orbit = 25.19 deg Max. angular diam. = 17.9\"\n Mean sidereal orb per = 1.88081578 y Visual mag. V(1,0) = -1.52\n Mean sidereal orb per = 686.98 d Orbital speed, km/s = 24.13\n Hill's sphere rad. Rp = 319.8 Escape speed, km/s = 5.027\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 717 493 589\n Maximum Planetary IR (W/m^2) 470 315 390\n Minimum Planetary IR (W/m^2) 30 30 30\n*******************************************************************************\n", + "level": [ + "Mars - Scan Required", + "\n", + "Asteroid counts : *****", + "Asteroid speed : ******************", + "Asteroid damage : ********************", + "Asteroid durability: *******************", + "Scan difficulty : ****************", + "\n", + "Though Mars's asteroid belt is deceptively thin, its dense icy rocks", + "strike with brutal force. Here, survival depends not on dodging many,", + "but on avoiding the few that could end you instantly.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 2.6, + "asteroid": { + "count": 5, + "speed": 20, + "damage": 40, + "durability": 19 + } + }, + { + "id": 499, + "name": "Earth", + "sprite": "earth.png", + "x": -205766295.2832569, + "y": -121439888.7499874, + "info": "PLANETARY SCAN COMPLETE: Earth\n\n*******************************************************************************\n\n GEOPHYSICAL PROPERTIES:\n Vol. Mean Radius (km) = 6371.01+-0.02 Mass x10^24 (kg)= 5.97219+-0.0006\n Equ. radius, km = 6378.137 Mass layers:\n Polar axis, km = 6356.752 Atmos = 5.1 x 10^18 kg\n Flattening = 1/298.257223563 oceans = 1.4 x 10^21 kg\n Density, g/cm^3 = 5.51 crust = 2.6 x 10^22 kg\n J2 (IERS 2010) = 0.00108262545 mantle = 4.043 x 10^24 kg\n g_p, m/s^2 (polar) = 9.8321863685 outer core = 1.835 x 10^24 kg\n g_e, m/s^2 (equatorial) = 9.7803267715 inner core = 9.675 x 10^22 kg\n g_o, m/s^2 = 9.82022 Fluid core rad = 3480 km\n GM, km^3/s^2 = 398600.435436 Inner core rad = 1215 km\n GM 1-sigma, km^3/s^2 = 0.0014 Escape velocity = 11.186 km/s\n Rot. Rate (rad/s) = 0.00007292115 Surface area:\n Mean sidereal day, hr = 23.9344695944 land = 1.48 x 10^8 km\n Mean solar day 2000.0, s = 86400.002 sea = 3.62 x 10^8 km\n Mean solar day 1820.0, s = 86400.0 Love no., k2 = 0.299\n Moment of inertia = 0.3308 Atm. pressure = 1.0 bar\n Mean surface temp (Ts), K= 287.6 Volume, km^3 = 1.08321 x 10^12\n Mean effect. temp (Te), K= 255 Magnetic moment = 0.61 gauss Rp^3\n Geometric albedo = 0.367 Vis. mag. V(1,0)= -3.86\n Solar Constant (W/m^2) = 1367.6 (mean), 1414 (perihelion), 1322 (aphelion)\n HELIOCENTRIC ORBIT CHARACTERISTICS:\n Obliquity to orbit, deg = 23.4392911 Sidereal orb period = 1.0000174 y\n Orbital speed, km/s = 29.79 Sidereal orb period = 365.25636 d\n Mean daily motion, deg/d = 0.9856474 Hill's sphere radius = 234.9 \n*******************************************************************************\n", + "level": [ + "Earth - Scan Required", + "\n", + "Asteroid counts : ************", + "Asteroid speed : *********", + "Asteroid damage : *****", + "Asteroid durability: *****************", + "Scan difficulty : *********", + "\n", + "In high orbit over Earth, safely above the new asteroid fields, lies", + "Chiaki Spacestation, which will afford you a unique opportunity for a", + "ship repair upon completing this mission.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 1.3, + "asteroid": { + "count": 12, + "speed": 9, + "damage": 17, + "durability": 3 + } + + }, + { + "id": 599, + "name": "Jupiter", + "sprite": "jupiter.png", + "x": -100070012.8507128, + "y": 765661098.764396, + "info": "PLANETARY SCAN COMPLETE: Jupiter\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Mass x 10^26 (kg) = 18.9819 Density (g/cm^3) = 1.3262 +- .0003\n Equat. radius (1 bar) = 71492+-4 km Polar radius (km) = 66854+-10\n Vol. Mean Radius (km) = 69911+-6 Flattening = 0.06487\n Geometric Albedo = 0.52 Rocky core mass (Mc/M)= 0.0261\n Sid. rot. period (III)= 9h 55m 29.711 s Sid. rot. rate (rad/s)= 0.00017585\n Mean solar day, hrs = ~9.9259 \n GM (km^3/s^2) = 126686531.900 GM 1-sigma (km^3/s^2) = +- 1.2732\n Equ. grav, ge (m/s^2) = 24.79 Pol. grav, gp (m/s^2) = 28.34\n Vis. magnitude V(1,0) = -9.40\n Vis. mag. (opposition)= -2.70 Obliquity to orbit = 3.13 deg\n Sidereal orbit period = 11.861982204 y Sidereal orbit period = 4332.589 d\n Mean daily motion = 0.0831294 deg/d Mean orbit speed, km/s= 13.0697\n Atmos. temp. (1 bar) = 165+-5 K Escape speed, km/s = 59.5 \n A_roche(ice)/Rp = 2.76 Hill's sphere rad. Rp = 740\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 56 46 51\n Maximum Planetary IR (W/m^2) 13.7 13.4 13.6\n Minimum Planetary IR (W/m^2) 13.7 13.4 13.6\n*******************************************************************************\n", + "level": [ + "Jupiter - Scan Required", + "\n", + "Asteroid counts : ************", + "Asteroid speed : ***************", + "Asteroid damage : **********", + "Asteroid durability: **********", + "Scan difficulty : ************", + "\n", + "Besides Jupiter's dangerous asteroid fields, the solar system's", + "largest planet has a strong gravitational field that you will", + "constantly need to fight against lest it claims you for its endless", + "storms.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 1.8, + "asteroid": { + "count": 12, + "speed": 15, + "damage": 10, + "durability": 10 + } + }, + { + "id": 699, + "name": "Saturn", + "sprite": "saturn.png", + "x": 1427205111.636179, + "y": -76337597.30005142, + "info": "PLANETARY SCAN COMPLETE: Saturn\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Mass x10^26 (kg) = 5.6834 Density (g/cm^3) = 0.687+-.001\n Equat. radius (1 bar) = 60268+-4 km Polar radius (km) = 54364+-10\n Vol. Mean Radius (km) = 58232+-6 Flattening = 0.09796\n Geometric Albedo = 0.47 Rocky core mass (Mc/M) = 0.1027\n Sid. rot. period (III)= 10h 39m 22.4s Sid. rot. rate (rad/s) = 0.000163785 \n Mean solar day, hrs =~10.656 \n GM (km^3/s^2) = 37931206.234 GM 1-sigma (km^3/s^2) = +- 98\n Equ. grav, ge (m/s^2) = 10.44 Pol. grav, gp (m/s^2) = 12.14+-0.01\n Vis. magnitude V(1,0) = -8.88 \n Vis. mag. (opposition)= +0.67 Obliquity to orbit = 26.73 deg\n Sidereal orbit period = 29.447498 yr Sidereal orbit period = 10755.698 d\n Mean daily motion = 0.0334979 deg/d Mean orbit velocity = 9.68 km/s\n Atmos. temp. (1 bar) = 134+-4 K Escape speed, km/s = 35.5 \n Aroche(ice)/Rp = 2.71 Hill's sphere rad. Rp = 1100\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 16.8 13.6 15.1\n Maximum Planetary IR (W/m^2) 4.7 4.5 4.6\n Minimum Planetary IR (W/m^2) 4.7 4.5 4.6\n*******************************************************************************\n", + "level": [ + "Saturn - Scan Required", + "\n", + "Asteroid counts : ************", + "Asteroid speed : ****************", + "Asteroid damage : ******************", + "Asteroid durability: *****************", + "Scan difficulty : ****************", + "\n", + "When Sol's system passed through a vast galactic asteroid field,", + "sometime in the early 23rd century, each planet captured its own", + "share of asteroids into its orbit. The collisions between Saturn's", + "newly captured asteroids and its already existing rings have created", + "the perfect rock and ice maelstrom to test even the most daring pilots.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 2.6, + "asteroid": { + "count": 12, + "speed": 16, + "damage": 18, + "durability": 17 + } + }, + { + "id": 799, + "name": "Uranus", + "sprite": "uranus.png", + "x": 1548241220.947309, + "y": 2474904952.161897, + "info": "PLANETARY SCAN COMPLETE: Uranus\n\n*******************************************************************************\n\n PHYSICAL DATA:\n Mass x10^24 (kg) = 86.813 Density (g/cm^3) = 1.271\n Equat. radius (1 bar) = 25559+-4 km Polar radius (km) = 24973+-20\n Vol. Mean Radius (km) = 25362+-12 Flattening = 0.02293\n Geometric Albedo = 0.51\n Sid. rot. period (III)= 17.24+-0.01 h Sid. rot. rate (rad/s) = -0.000101237\n Mean solar day, h =~17.24 Rocky core mass (Mc/M) = 0.0012 \n GM (km^3/s^2) = 5793950.6103 GM 1-sigma (km^3/s^2) = +-4.3 \n Equ. grav, ge (m/s^2) = 8.87 Pol. grav, gp (m/s^2) = 9.19+-0.02\n Visual magnitude V(1,0)= -7.11\n Vis. mag. (opposition)= +5.52 Obliquity to orbit = 97.77 deg\n Sidereal orbit period = 84.0120465 y Sidereal orbit period = 30685.4 d\n Mean daily motion = 0.01176904 dg/d Mean orbit velocity = 6.8 km/s\n Atmos. temp. (1 bar) = 76+-2 K Escape speed, km/s = 21.3 \n Aroche(ice)/Rp = 2.20 Hill's sphere rad., Rp = 2700\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 4.09 3.39 3.71\n Maximum Planetary IR (W/m^2) 0.72 0.55 0.63\n Minimum Planetary IR (W/m^2) 0.72 0.55 0.63\n*******************************************************************************\n", + "level": [ + "Uranus - Scan Required", + "\n", + "Asteroid counts : ************", + "Asteroid speed : ****************", + "Asteroid damage : ******************", + "Asteroid durability: *****************", + "Scan difficulty : ****************", + "\n", + "Viewed from far above, the tranquil appearance of Uranus masks a", + "world of ever-present swirling storms and harsh winds laden with", + "icy particles. ", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 2.6, + "asteroid": { + "count": 12, + "speed": 16, + "damage": 18, + "durability": 17 + } + }, + { + "id": 899, + "name": "Neptune", + "sprite": "neptune.png", + "x": 4470024777.676497, + "y": 12434785.46605173, + "info": "PLANETARY SCAN COMPLETE: Neptune\n\n*******************************************************************************\n\n PHYSICAL DATA (update 2021-May-03):\n Mass x10^24 (kg) = 102.409 Density (g/cm^3) = 1.638\n Equat. radius (1 bar) = 24766+-15 km Volume, 10^10 km^3 = 6254 \n Vol. mean radius (km) = 24624+-21 Polar radius (km) = 24342+-30\n Geometric Albedo = 0.41 Flattening = 0.0171\n Sid. rot. period (III)= 16.11+-0.01 hr Sid. rot. rate (rad/s) = 0.000108338 \n Mean solar day, h =~16.11 h \n GM (km^3/s^2) = 6835099.97 GM 1-sigma (km^3/s^2) = +-10 \n Equ. grav, ge (m/s^2) = 11.15 Pol. grav, gp (m/s^2) = 11.41+-0.03\n Visual magnitude V(1,0)= -6.87\n Vis. mag. (opposition)= +7.84 Obliquity to orbit = 28.32 deg\n Sidereal orbit period = 164.788501027 y Sidereal orbit period = 60189 d\n Mean daily motion = 0.006020076dg/d Mean orbit velocity = 5.43 km/s \n Atmos. temp. (1 bar) = 72+-2 K Escape speed (1 bar) = 23.5 km/s \n Aroche(ice)/Rp = 2.98 Hill's sphere rad., Rp = 4700\n Perihelion Aphelion Mean\n Solar Constant (W/m^2) 1.54 1.49 1.51\n Maximum Planetary IR (W/m^2) 0.52 0.52 0.52\n Minimum Planetary IR (W/m^2) 0.52 0.52 0.52\n*******************************************************************************\n", + "level": [ + "Neptune - Scan Required", + "\n", + "Asteroid counts : **************", + "Asteroid speed : ************", + "Asteroid damage : ******************", + "Asteroid durability: *****************", + "Scan difficulty : ************", + "\n", + "Neptune reigns at the edge of humanity's realm of reasonable exploration;", + "You may be too busy dodging asteroids to fully its deep, arresting blue.", + "\n", + "Ship controls:", + "\tMovement: Arrow Keys or WASD", + "\tUse scanner: Hold Spacebar" + ], + "scan_multiplier": 2.2, + "asteroid": { + "count": 14, + "speed": 12, + "damage": 18, + "durability": 17 + } + } +] diff --git a/cool-cacti/pyproject.toml b/cool-cacti/pyproject.toml new file mode 100644 index 00000000..b220588f --- /dev/null +++ b/cool-cacti/pyproject.toml @@ -0,0 +1,87 @@ +[project] +# This section contains metadata about your project. +# Don't forget to change the name, description, and authors to match your project! +name = "code-jam-soon-to-be-awesome-project" +description = "no idea yet :)" +authors = [ + { name ="https://github.com/fluffy-marmot", email="10621013+fluffy-marmot@users.noreply.github.com"}, + { name ="https://github.com/Prorammer-4090", email="email@mail.com"}, + { name ="https://github.com/TheRatLord", email="email@mail.com"}, + { name ="https://github.com/spirledaxis", email="email@mail.com"}, +] +version = "0.1.0" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "flask>=3.1.1", +] + +[dependency-groups] +# This `dev` group contains all the development requirements for our linting toolchain. +# Don't forget to pin your dependencies! +# This list will have to be migrated if you wish to use another dependency manager. +dev = [ + "numpy>=2.3.2", + "pillow>=11.3.0", + "pre-commit~=4.2.0", + "ruff~=0.12.2", +] + +[tool.ruff] +# Increase the line length. This breaks PEP8 but it is way easier to work with. +# The original reason for this limit was a standard vim terminal is only 79 characters, +# but this doesn't really apply anymore. +line-length = 119 +# Target Python 3.12. If you decide to use a different version of Python +# you will need to update this value. +target-version = "py312" +# Automatically fix auto-fixable issues. +fix = true +# Show applied fixes. +show-fixes = true +# The directory containing the source code. If you choose a different project layout +# you will need to update this value. +src = ["src"] + +[tool.ruff.lint] +# Enable all linting rules. +select = ["ALL"] +fixable = ["I", "F401"] # I = Sorts imports, F401 = Deletes unused imports +# Ignore some of the most obnoxious linting errors. +ignore = [ + # Trailing comma rule, not compatible with formatter + "COM812", + # Missing docstrings. + "D100", + "D104", + "D105", + "D106", + "D107", + # Docstring whitespace. + "D203", + "D213", + # Docstring punctuation. + "D415", + # Docstring quotes. + "D301", + # Builtins. + "A", + # Print statements. + "T20", + # TODOs. + "TD002", + "TD003", + "FIX", + # Security + "S311" +] + +[tool.ruff.lint.isort] +combine-as-imports = true +section-order = [ + "future", + "standard-library", + "third-party", + "first-party", + "local-folder", +] diff --git a/cool-cacti/readme_images/game1.png b/cool-cacti/readme_images/game1.png new file mode 100644 index 00000000..121864c3 Binary files /dev/null and b/cool-cacti/readme_images/game1.png differ diff --git a/cool-cacti/readme_images/game2.png b/cool-cacti/readme_images/game2.png new file mode 100755 index 00000000..706a7833 Binary files /dev/null and b/cool-cacti/readme_images/game2.png differ diff --git a/cool-cacti/readme_images/game3.png b/cool-cacti/readme_images/game3.png new file mode 100755 index 00000000..41a85216 Binary files /dev/null and b/cool-cacti/readme_images/game3.png differ diff --git a/cool-cacti/static/audio/bang1.ogg b/cool-cacti/static/audio/bang1.ogg new file mode 100644 index 00000000..3dfa1339 Binary files /dev/null and b/cool-cacti/static/audio/bang1.ogg differ diff --git a/cool-cacti/static/audio/bang2.ogg b/cool-cacti/static/audio/bang2.ogg new file mode 100644 index 00000000..bfeb9617 Binary files /dev/null and b/cool-cacti/static/audio/bang2.ogg differ diff --git a/cool-cacti/static/audio/bang3.ogg b/cool-cacti/static/audio/bang3.ogg new file mode 100644 index 00000000..5bd826b6 Binary files /dev/null and b/cool-cacti/static/audio/bang3.ogg differ diff --git a/cool-cacti/static/audio/death.ogg b/cool-cacti/static/audio/death.ogg new file mode 100644 index 00000000..3510fc12 Binary files /dev/null and b/cool-cacti/static/audio/death.ogg differ diff --git a/cool-cacti/static/audio/explosion.ogg b/cool-cacti/static/audio/explosion.ogg new file mode 100644 index 00000000..f7f9552a Binary files /dev/null and b/cool-cacti/static/audio/explosion.ogg differ diff --git a/cool-cacti/static/audio/music_main.ogg b/cool-cacti/static/audio/music_main.ogg new file mode 100644 index 00000000..d62013a1 Binary files /dev/null and b/cool-cacti/static/audio/music_main.ogg differ diff --git a/cool-cacti/static/audio/music_thematic.ogg b/cool-cacti/static/audio/music_thematic.ogg new file mode 100644 index 00000000..84b8f34a Binary files /dev/null and b/cool-cacti/static/audio/music_thematic.ogg differ diff --git a/cool-cacti/static/audio/scan.ogg b/cool-cacti/static/audio/scan.ogg new file mode 100644 index 00000000..9e3c53dd Binary files /dev/null and b/cool-cacti/static/audio/scan.ogg differ diff --git a/cool-cacti/static/audio/text.ogg b/cool-cacti/static/audio/text.ogg new file mode 100644 index 00000000..4390feb8 Binary files /dev/null and b/cool-cacti/static/audio/text.ogg differ diff --git a/cool-cacti/static/credits.txt b/cool-cacti/static/credits.txt new file mode 100644 index 00000000..ea729290 --- /dev/null +++ b/cool-cacti/static/credits.txt @@ -0,0 +1,49 @@ +MISSION COMPLETE! + +A Space Exploration Adventure +Made for Python Discord's 2025 CodeJam +"Wrong Tool for the Job" + +Developer Team (Cool Cacti) + +Dark_Zero +Doomy +RealisticTurtle +Soosh + +Special Thanks (Music by) +Elemeno Peter + + +Thank you for playing! + + + + +Great job on scanning a grand total 8e0 planets! + + + + +Are you still here? + + + + + + + + +The credits are already over. + + + +...can we move on? + + + + + + + +42 \ No newline at end of file diff --git a/cool-cacti/static/favicon.ico b/cool-cacti/static/favicon.ico new file mode 100644 index 00000000..c8aa787c Binary files /dev/null and b/cool-cacti/static/favicon.ico differ diff --git a/cool-cacti/static/lore.txt b/cool-cacti/static/lore.txt new file mode 100644 index 00000000..dc42c987 --- /dev/null +++ b/cool-cacti/static/lore.txt @@ -0,0 +1,24 @@ +Why does boss want so much info on random planets...? +Yeah man...this mission seems pointless. We already scanned 2.3e+15 planets yesterday, including rogue ones...what could he possibly do with all that data? +Well, at least we can scan efficiently. Remember yesterday? We were scanning 8e+10 planets per second! +That's true. I heard some civilizations actually need to be in orbit to get a planet's data! +Seriously?? I remember a few years ago we only had a range of a light-year, even that was terrible... +I can't imagine such primitive technology! +Yeah, if you had to orbit each planet, you couldn't travel 200x light speed! +I bet their technology is so bad that they have to stay in orbit for a minute or so just to scan one planet. +Really now? Then they could only get hundreds of planets a day then. +Boss won't even consider a number without scientific notation nowadays... +Yeah, at least we're advanced...anyway, let's get the day started. Turn on the planet scanner! +*Flips switch* Hey! The terminal is reading SyntaxError: invalid syntax on line 42: print(hello world). +??? What do you mean, shouldn't the console print 'hello world'? +No, dingus. There aren't quotes. No wonder you failed programming 101... +Since you're soooo smart, why don't you fix the error? +We don't have access to the source code of this ship... +What does that mean...? +I can't fix the error, we can't scan planets. +ARE YOU SERIOUS!!! We are 12 TRILLION universes away from home, and NOW you tell me that? +Not my fault, man. We'll be fine though. I just remembered that I have this wireless barcode scanner from work that I accidentally took from my shift. It only has a range of several hundred km though, so we'll have to go orbit planets for a minute or so to scan them. +Uhm, excuse me? That sounds just like that primitive technology! I am NOT having it! +Boss will kill us if we return home with no planet data... +But..a barcode scanner? To scan a planet? That's INSANE! +We'll have to make do with......THE WRONG TOOL FOR THE JOB. \ No newline at end of file diff --git a/cool-cacti/static/pyscript.json b/cool-cacti/static/pyscript.json new file mode 100644 index 00000000..611bdf05 --- /dev/null +++ b/cool-cacti/static/pyscript.json @@ -0,0 +1,20 @@ +{ + "files": { + "/static/scripts/asteroid.py": "", + "/static/scripts/audio.py": "", + "/static/scripts/common.py": "", + "/static/scripts/consolelogger.py": "", + "/static/scripts/controls.py": "", + "/static/scripts/debris.py": "", + "/static/scripts/game.py": "", + "/static/scripts/overlay.py": "", + "/static/scripts/player.py": "", + "/static/scripts/scene_classes.py": "", + "/static/scripts/scene_descriptions.py": "", + "/static/scripts/solar_system.py": "", + "/static/scripts/spacemass.py": "", + "/static/scripts/sprites.py": "", + "/static/scripts/stars.py": "", + "/static/scripts/window.py": "" + } +} \ No newline at end of file diff --git a/cool-cacti/static/scripts/asteroid.py b/cool-cacti/static/scripts/asteroid.py new file mode 100644 index 00000000..ec688e97 --- /dev/null +++ b/cool-cacti/static/scripts/asteroid.py @@ -0,0 +1,274 @@ +import math +import random + +from js import document # type: ignore[attr-defined] +from common import Position, PlanetData +from scene_classes import SceneObject +from window import window, SpriteSheet +from consolelogger import getLogger + +log = getLogger(__name__) + +# Canvas dimensions +canvas = document.getElementById("gameCanvas") +container = document.getElementById("canvasContainer") +SCREEN_W, SCREEN_H = container.clientWidth, container.clientHeight + +ASTEROID_SHEET = window.sprites["asteroids"] + +# "magic numbers" obtained via a script in assets/make_spritesheets.py, end of the printout +# Updated to include recycle sprite collision radii (positions 104-119) +ASTEROID_RADII = [22, 26, 18, 19, 21, 25, 18, 23, 26, 20, 24, 13, 22, 18, 21, 23, 30, 19, 18, 18, 18, 21, 26, + 20, 21, 16, 24, 22, 18, 25, 18, 20, 19, 21, 22, 18, 24, 20, 23, 20, 22, 20, 24, 17, 16, 16, + 18, 21, 17, 22, 24, 25, 14, 24, 25, 14, 22, 23, 21, 18, 20, 18, 18, 19, 24, 23, 23, 27, 19, + 24, 25, 20, 23, 21, 25, 22, 19, 25, 21, 16, 30, 26, 24, 30, 23, 21, 20, 18, 25, 16, 24, 21, + 23, 18, 21, 24, 20, 23, 29, 20, 24, 22, 22, 19, 21, 37, 31, 43, 31, 32, 23, 24, 22, 20, 24, 21, 25, 33, 23, 21] # noqa + + +class Asteroid(SceneObject): + def __init__( + self, sheet: SpriteSheet, + x: float, y: float, + vx: float, vy: float, + target_size_px: float, + sprite_index: int, + grid_cols: int = 11, + cell_size: float = 0, + grow_rate=6.0, + health: int = 450, + damage_mul: float= 1.0 + ): + super().__init__() + super().set_position(x, y) + self.sheet = sheet + self.velocity_x = vx + self.velocity_y = vy + self.rotation = 0.0 + self.rotation_speed = random.uniform(-0.5, 0.5) + self.target_size = target_size_px + self.size = 5.0 + self.grow_rate = target_size_px / random.uniform(grow_rate - 1.8, grow_rate + 2.5) + self.sprite_index = sprite_index + self.grid_cols = grid_cols + self.cell_size = cell_size + self.hitbox_scale = 0.45 + self.hitbox_radius = ASTEROID_RADII[sprite_index] + self._last_timestamp = None + self.linger_time = 0.5 + self.full_size_reached_at = None + self.health = random.uniform(health * 0.8, health * 1.2) + self.damage_mul = random.uniform(damage_mul * 0.9, damage_mul * 1.1) + + def _ensure_cell_size(self): + if not self.cell_size: + if self.sheet.width: + self.cell_size = max(1, int(self.sheet.width // self.grid_cols)) + + def _src_rect(self): + self._ensure_cell_size() + col = self.sprite_index % self.grid_cols + row = self.sprite_index // self.grid_cols + x = col * self.cell_size + y = row * self.cell_size + return x, y, self.cell_size, self.cell_size + + def update(self, timestamp: float): + if self._last_timestamp is None: + self._last_timestamp = timestamp + return + dt = (timestamp - self._last_timestamp) / 1000.0 # seconds + self._last_timestamp = timestamp + + # Movement + self.x += self.velocity_x * dt + self.y += self.velocity_y * dt + self.rotation += self.rotation_speed * dt + + # Growth towards target size + if self.size < self.target_size: + self.size = self.size + self.grow_rate * dt + if self.size >= self.target_size: + self.full_size_reached_at = timestamp + + def render(self, ctx, timestamp_ms: float): + self.update(timestamp_ms) + self._ensure_cell_size() + if not self.cell_size: + return + + x, y, w, h = self._src_rect() + size = self.size + + ctx.save() + ctx.translate(self.x, self.y) + ctx.rotate(self.rotation) + + # Draw centered + ctx.drawImage(self.sheet.image, x, y, w, h, -size / 2, -size / 2, size, size) + + # Debug hit circle + if getattr(window, "DEBUG_DRAW_HITBOXES", False): + ctx.beginPath() + ctx.strokeStyle = "#FF5555" + ctx.lineWidth = 2 + ctx.arc(0, 0, size * self.hitbox_radius / 100 * 1, 0, 2 * math.pi) + ctx.stroke() + ctx.restore() + + def is_off_screen(self, w=SCREEN_W, h=SCREEN_H, margin=50) -> bool: + return self.x < -margin or self.x > w + margin or self.y < -margin or self.y > h + margin + + def get_hit_circle(self): + return (self.x, self.y, self.size * self.hitbox_radius / 100 * 1) + + def should_be_removed(self): + """Check if asteroid should be removed (off screen or lingered too long)""" + if self.is_off_screen(): + return True + if self.full_size_reached_at and (self._last_timestamp - self.full_size_reached_at) > ( + self.linger_time * 1000 + ): + return True + if self.health <= 0: + window.debris.generate_debris(window.player.get_position(), self.get_position(), 4) + window.debris.generate_debris(window.player.get_position(), self.get_position(), 3.75) + return True + return False + +# updated spawn_on_player, it looked goofy near planets with high chance +class AsteroidAttack: + def __init__(self, spritesheet, width: int, height: int, max_size_px: float, spawnrate: int = 500, spawn_at_player_chance: int = 50): + self.sheet = spritesheet + self.w = width + self.h = height + self.max_size = max_size_px or 256 + self.spawnrate = spawnrate + self.asteroids: list[Asteroid] = [] + self._last_spawn = 0.0 + self._max_asteroids = 50 # default max asteroids that can appear on the screen + self.cell_size = 0 + self._use_grow_rate = 6.0 # default growth rate (how fast they appear to approach the player) + self._use_health = 450 # default durability (affects asteroids being destroyed by impacts w/ player) + self._use_damage_mul = 1.0 + self.spawn_at_player_chance = spawn_at_player_chance + def _spawn_one(self): + # Don't spawn if at the limit + if len(self.asteroids) >= self._max_asteroids: + return + + # Planet area (left side) + planet_width = self.w * 0.3 + space_start_x = planet_width + 50 + if random.randint(1, self.spawn_at_player_chance) == 1: + x = window.player.x + y = window.player.y + else: + x = random.uniform(space_start_x, self.w) + y = random.uniform(0, self.h) + + if x < (SCREEN_W / 2): + velocity_x = random.uniform(-15, -5) + if y < (SCREEN_H / 2): + velocity_y = random.uniform(-15, -5) + else: + velocity_y = random.uniform(5, 15) + else: + velocity_x = random.uniform(5, 15) + if y < (SCREEN_H / 2): + velocity_y = random.uniform(-15, -5) + else: + velocity_y = random.uniform(5, 15) + + # Use recycle sprites (104-119) for Earth, regular asteroids (0-103) for other planets + if hasattr(self, '_current_planet_name') and self._current_planet_name.lower() == 'earth': + idx = random.randint(104, 119) # Recycle sprites + # Scale recycle items smaller since they're items, not large asteroids + target = random.uniform(self.max_size * 0.25, self.max_size * 0.45) + # log.debug("Spawning recycle sprite %d for Earth with smaller target size %f", idx, target) + else: + idx = random.randint(0, 103) # Regular asteroid sprites + target = random.uniform(self.max_size * 0.7, self.max_size * 1.3) + # if hasattr(self, '_current_planet_name'): + # log.debug("Spawning asteroid sprite %d for %s", idx, self._current_planet_name) + + a = Asteroid( + self.sheet, x, y, velocity_x, velocity_y, target, idx, + grow_rate=self._use_grow_rate, + health=self._use_health, + damage_mul=self._use_damage_mul + ) + self.asteroids.append(a) + + # Spawn at interval and only if under limit + def spawn_and_update(self, timestamp: float): + # adjust spawnrate by a random factor so asteroids don't spawn at fixed intervals + spawnrate = self.spawnrate * random.uniform(0.2, 1.0) + + # Increase spawn rate for smaller recycle items on Earth + if hasattr(self, '_current_planet_name') and self._current_planet_name.lower() == 'earth': + spawnrate *= 0.1 # 10x faster spawn rate for Earth recycle items (1/10 = 0.1) + + # slow down spawnrate for this attempt a bit if there already many asteroids active + spawnrate = spawnrate * max(1, 1 + (len(self.asteroids) - 35) * 0.1) + if self._last_spawn == 0.0 or (timestamp - self._last_spawn) >= spawnrate: + if len(self.asteroids) < self._max_asteroids: + self._last_spawn = timestamp + self._spawn_one() + + # Remove asteroids + before_count = len(self.asteroids) + self.asteroids = [a for a in self.asteroids if not a.should_be_removed()] + after_count = len(self.asteroids) + + # If we removed asteroids, we can spawn new ones + if after_count < before_count: + self._last_spawn = timestamp - (self.spawnrate * 0.7) + + def update_and_render(self, ctx, timestamp: float): + self.spawn_and_update(timestamp) + for a in self.asteroids: + a.render(ctx, timestamp) + + def reset(self, planet_data: PlanetData): + """ reset the asteroid management system with the given difficulty parameters """ + + # Store the planet name for sprite selection + self._current_planet_name = planet_data.name + + # the asteroid difficulty settings are on a 1-20 scale of ints + asteroid_settings = planet_data.asteroid + + spawnrate = 500 + # clamp max between 10-80, default is 50 at difficulty 10 + max_asteroids = min(max(10, 5 * asteroid_settings.count), 80) + + # this determines how quickly asteroids seem to be approaching player (sprite growing in size) + # NOTE: the relationship is inverse, smaller growth rate = faster approaching asteroids + # a value of 6.0 feels like a pretty good rough default, not too slow + use_grow_rate = max(1.2, 10.5 - (asteroid_settings.speed - 5) * 0.5) + # how easily asteroids fall apart from collisions, default 450 health at level 10 + use_health = 50 + 40 * asteroid_settings.durability + # range of 0.3 to 2.2 multiplier + use_damage_mul = 0.2 + 0.1 * asteroid_settings.damage + + log.debug("Resetting asteroids with difficulty parameters for planet %s:", planet_data.name) + log.debug("Max asteroids: %s (%s), default 50", max_asteroids, asteroid_settings.count) + log.debug("Grow rate(approach speed): %s (%s), default 6.0", use_grow_rate, asteroid_settings.speed) + log.debug("Asteroid durability: %s (%s), default 450", use_health, asteroid_settings.durability) + log.debug("Damage multiplier: %s (%s), default 1.0", use_damage_mul, asteroid_settings.damage) + + # Special difficulty adjustments for Earth recycle items + if planet_data.name.lower() == 'earth': + max_asteroids = min(max_asteroids * 2, 120) # Allow up to 2x more recycle items + use_grow_rate *= 0.6 # Make them approach 40% faster + use_health *= 1.5 # Make them 50% more durable + use_damage_mul *= 0.7 # Decrease damage by 30% + + self._max_asteroids = max_asteroids + self._use_grow_rate = use_grow_rate + self._use_health = use_health + self._use_damage_mul = use_damage_mul + + self.asteroids.clear() + self._last_spawn = 0.0 + self.cell_size = 0 diff --git a/cool-cacti/static/scripts/audio.py b/cool-cacti/static/scripts/audio.py new file mode 100644 index 00000000..59f42b53 --- /dev/null +++ b/cool-cacti/static/scripts/audio.py @@ -0,0 +1,84 @@ +import random +from typing import Union +from functools import partial + +from js import Audio # type: ignore[attr-defined] + + +class AudioHandler: + def __init__(self, static_url: str) -> None: + self.static_url = static_url + self.volume: int = 1.0 + + self.text_sound = self.load_audio("text.ogg") + self.scan_sound = self.load_audio("scan.ogg") + self.explosion_sound = self.load_audio("explosion.ogg") + + self.music_main = self.load_audio("music_main.ogg") + self.music_thematic = self.load_audio("music_thematic.ogg") + self.music_death = self.load_audio("death.ogg") + + self.active_music = None + + def set_volume(self, volume: float) -> None: + """ set volume to somewhere between 0.0 and 1.0 if a valid value is given """ + if 0.0 <= volume <= 1.0: + self.volume = volume + + def load_audio(self, audio_name: str) -> Audio: + return Audio.new(f"{self.static_url}audio/{audio_name}") + + def play_sound(self, audio_name: Union[str, "Audio"], volume=1.0) -> None: + """ + play a sound file + audio_name: name of sound file, without any path included, as it appears in static/audio/ + volume: adjust this for loud sound files, 0.0 to 1.0, where 1.0 is full volume + """ + # sometimes we want to load new instances of Audio objcts, other times we want a persistent one + if isinstance(audio_name, str): + sound = self.load_audio(audio_name) + else: + sound = audio_name + sound.volume = volume * self.volume + sound.play() + + def play_bang(self) -> None: + # these bangs are kind of loud, playing it at reduced volume + self.play_sound(random.choice(["bang1.ogg", "bang2.ogg", "bang3.ogg"]), volume=0.4) + + def play_unique_sound(self, audio: Audio, pause_it=False, volume=1.0) -> None: + if not pause_it and audio.paused: + self.play_sound(audio, volume=volume) + elif pause_it: + audio.pause() + audio.currentTime = 0 + + def play_text(self, pause_it=False, volume=0.8) -> None: + self.play_unique_sound(self.text_sound, pause_it, volume=volume) + + def play_scan(self, pause_it=False, volume=0.4) -> None: + self.play_unique_sound(self.scan_sound, pause_it, volume=volume) + + def play_explosion(self, pause_it=False, volume=0.6) -> None: + self.play_unique_sound(self.explosion_sound, pause_it, volume=volume) + + def _play_music(self, music_audio, pause_it=False, volume=1.0) -> None: + if pause_it: + music_audio.pause() + music_audio.currentTime = 0 + self.active_music = None + return + # if another music file is playing, don't play this one + if self.active_music and not self.active_music.paused: + return + self.active_music = music_audio + self.play_unique_sound(music_audio, volume=volume) + + def play_music_main(self, pause_it=False, volume=0.65) -> None: + self._play_music(self.music_main, pause_it=pause_it, volume=volume) + + def play_music_death(self, pause_it=False, volume=1.0) -> None: + self._play_music(self.music_death, pause_it=pause_it, volume=volume) + + def play_music_thematic(self, pause_it=False, volume=1.0) -> None: + self._play_music(self.music_thematic, pause_it=pause_it, volume=volume) \ No newline at end of file diff --git a/cool-cacti/static/scripts/common.py b/cool-cacti/static/scripts/common.py new file mode 100644 index 00000000..e96be15f --- /dev/null +++ b/cool-cacti/static/scripts/common.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Iterator + +HTMLImageElement = Any +CanvasRenderingContext2D = Any + +@dataclass +class AsteroidData: + """Dataclass for asteroid data from asteroids.json. Difficulty stuff is on a 1-20 scale""" + + count: int + speed: int + damage: int + durability: int + +@dataclass +class PlanetData: + """Dataclass for planet data from planets.json""" + + id: int + name: str + sprite: str + x: float = 0.0 + y: float = 0.0 + info: str = "" + level: list[str] = field(default_factory=list) + scan_multiplier: float = 1.0 + asteroid: AsteroidData | None = None + spritesheet: SpriteSheet | None = None # JS Image object added in HTML + + @classmethod + def from_dict(cls, data: dict) -> 'PlanetData': + """Create PlanetData from dictionary, handling nested asteroid data.""" + data = data.copy() + # Handle nested asteroid data + asteroid_data = None + if 'asteroid' in data: + asteroid_dict = data.pop('asteroid') # Remove from data to avoid duplicate in **data + asteroid_data = AsteroidData(**asteroid_dict) + + # Create instance with unpacked dictionary + return cls(asteroid=asteroid_data, **data) + +@dataclass +class Rect: + left: float + top: float + width: float + height: float + + def __iter__(self) -> Iterator[float]: + yield self.left + yield self.top + yield self.width + yield self.height + + def contains(self, point: Position) -> bool: + return self.left <= point.x <= self.right and self.top <= point.y <= self.bottom + + @property + def right(self) -> float: + return self.left + self.width + + @right.setter + def right(self, value: float) -> None: + self.left = value - self.width + + @property + def bottom(self) -> float: + return self.top + self.height + + @bottom.setter + def bottom(self, value: float) -> None: + self.top = value - self.height + + +@dataclass +class Position: + x: float + y: float + + def __iter__(self) -> Iterator[float]: + yield self.x + yield self.y + + def __add__(self, other_pos: Position) -> Position: + return Position(self.x + other_pos.x, self.y + other_pos.y) + + def midpoint(self, other_pos: Position) -> Position: + return Position((self.x + other_pos.x) / 2, (self.y + other_pos.y) / 2) + + def distance(self, other_pos: Position) -> float: + return ((self.x - other_pos.x) ** 2 + (self.y - other_pos.y) ** 2) ** 0.5 + + +@dataclass +class PlanetState: + """State for planet""" + + mass: float + radius: float + initial_velocity: float = 0.0 + x: float = 0 + y: float = 0 + angle: float = 0.0 + velocity_x: float = 0.0 + velocity_y: float = 0.0 + + +class SpriteSheet: + """Wrapper for individual sprites with enhanced functionality.""" + + def __init__(self, key: str, image: "HTMLImageElement"): + self.key = key.lower() + self.image = image + + @property + def height(self): + """Height of the sprite image.""" + return self.image.height + + @property + def width(self): + """Width of the sprite image.""" + return self.image.width + + @property + def frame_size(self): + """Size of each frame (assuming square frames).""" + return self.height + + @property + def is_loaded(self): + return self.height > 0 and self.width > 0 + + @property + def num_frames(self): + """Number of frames in the spritesheet.""" + if not self.is_loaded: + return 1 + return self.width // self.frame_size + + def get_frame_position(self, frame: int) -> Position: + """Get the position of a specific frame in the spritesheet with overflow handling.""" + if self.num_frames == 0: + return Position(0, 0) + frame_index = frame % self.num_frames + x = frame_index * self.frame_size + return Position(x, 0) + + # Delegate other attributes to the underlying image + def __getattr__(self, name): + return getattr(self.image, name) diff --git a/cool-cacti/static/scripts/consolelogger.py b/cool-cacti/static/scripts/consolelogger.py new file mode 100644 index 00000000..ab1b183b --- /dev/null +++ b/cool-cacti/static/scripts/consolelogger.py @@ -0,0 +1,47 @@ +import logging + +from js import console # type: ignore[attr-defined] + + +class ConsoleHandler(logging.Handler): + def emit(self, record): + try: + msg = self.format(record) + + if record.levelno >= logging.ERROR: + console.error(msg) + elif record.levelno >= logging.WARNING: + console.warn(msg) + elif record.levelno >= logging.INFO: + console.info(msg) + else: + console.debug(msg) + except Exception: + self.handleError(record) + + +# why the heck does python's standard lib use camelCase? :( I'm just mimicking logging.getLogger ... +def getLogger(name, show_time: bool = False) -> logging.Logger: + """ + to get a logger in another file that outputs only to the browser javascript console and doesn't insert + its output into the webpage, simply use: + + from consolelogger import getLogger + log = getLogger(__name__) + log.debug. ("This is a log message") # etc. + """ + logger = logging.getLogger(name) + logger.setLevel(logging.DEBUG) + + # set up the logger so it only outputs to the browser's javascript console. Spiffy + handler = ConsoleHandler() + if not show_time: + handler.setFormatter(logging.Formatter("[%(levelname)s] %(name)s: %(message)s")) + else: + formatter = logging.Formatter("[%(levelname)s %(asctime)s] %(name)s: %(message)s", datefmt="%H:%M:%S") + handler.setFormatter(formatter) + + logger.handlers.clear() + logger.addHandler(handler) + + return logger diff --git a/cool-cacti/static/scripts/controls.py b/cool-cacti/static/scripts/controls.py new file mode 100644 index 00000000..5bbde962 --- /dev/null +++ b/cool-cacti/static/scripts/controls.py @@ -0,0 +1,134 @@ +from dataclasses import dataclass +from time import time + +from common import Position +from consolelogger import getLogger +from pyodide.ffi import create_proxy # type: ignore[attr-defined] + +log = getLogger(__name__) + + +@dataclass +class MousePositions: + mousedown: Position + mouseup: Position + click: Position + move: Position + + +class GameControls: + """ + in game.py using + controls = GameControls(canvas) + controls object gives access to what keys are being currently pressed, accessible properties: + - controls.pressed is a set of strings representing keys and mouse buttons currently held down + the strings for mouse buttons are given by GameControls.MOUSE_LEFT, etc. + - controls.mouse gives access to all the coordinates of the last registered mouse event of each kind as the + tuples controls.mouse.mousedown, controls.mouse.mouseup, controls.mouse.click, controls.mouse.move + - use controls.mouse.move for best current coordinates of the mouse + - additionally, controls.click is a boolean representing if a click just occurred. It is set to False at the + end of each game loop if nothing makes use of the click event + - use enable_logging=False if spam of mouse/key events in browser console gets annoying + """ + + MOUSE_LEFT = "mouse_left" + MOUSE_RIGHT = "mouse_right" + MOUSE_MIDDLE = "mouse_middle" + + # just to use internally in the class to translate the 0, 1, 2 javascript convention + mouse_button_map = {0: MOUSE_LEFT, 1: MOUSE_MIDDLE, 2: MOUSE_RIGHT} + + def __init__(self, canvas, enable_logging=False): + # keep track of what keys \ mouse buttons are currently pressed in this variable + self.pressed = set() + # keep track of the last coordinates used by all mouse events + self.mouse = MousePositions(Position(0, 0), Position(0, 0), Position(0, 0), Position(0, 0)) + # keep track of whether a click has occurred + self.click = False + + # enable logging of mouse and key events in the console for debug purposes + self._logging = enable_logging + self._last_mousemove_log = 0 + + on_canvas_mousedown_proxy = create_proxy(self.on_canvas_mousedown) + on_canvas_mouseup_proxy = create_proxy(self.on_canvas_mouseup) + on_canvas_click_proxy = create_proxy(self.on_canvas_click) + on_canvas_mousemove_proxy = create_proxy(self.on_canvas_mousemove) + on_keydown_proxy = create_proxy(self.on_keydown) + on_keyup_proxy = create_proxy(self.on_keyup) + + canvas.addEventListener("mousedown", on_canvas_mousedown_proxy) + canvas.addEventListener("mouseup", on_canvas_mouseup_proxy) + canvas.addEventListener("click", on_canvas_click_proxy) + canvas.addEventListener("mousemove", on_canvas_mousemove_proxy) + canvas.addEventListener("keydown", on_keydown_proxy) + canvas.addEventListener("keyup", on_keyup_proxy) + + # helper method so we don't need to copy and paste this to every mouse event + def get_mouse_event_coords(self, event) -> Position: + canvas_rect = event.target.getBoundingClientRect() + return Position(event.clientX - canvas_rect.left, event.clientY - canvas_rect.top) + + def on_canvas_mousedown(self, event): + pos = self.get_mouse_event_coords(event) + self.mouse.move = pos + self.mouse.mousedown = pos + + if event.button in self.mouse_button_map: + button = self.mouse_button_map[event.button] + self.pressed.add(button) + + if self._logging: + log.debug("mousedown %s %s, %s", button, pos.x, pos.y) + + def on_canvas_mouseup(self, event): + pos = self.get_mouse_event_coords(event) + self.mouse.move = pos + self.mouse.mouseup = pos + + if event.button in self.mouse_button_map: + button = self.mouse_button_map[event.button] + if button in self.pressed: + self.pressed.remove(button) + + if self._logging: + log.debug("mouseup %s %s, %s", button, pos.x, pos.y) + + def on_canvas_click(self, event): + pos = self.get_mouse_event_coords(event) + self.mouse.move = pos + self.mouse.click = pos + + self.click = True + if self._logging: + log.debug("click %s, %s", pos.x, pos.y) + + def on_canvas_mousemove(self, event): + pos = self.get_mouse_event_coords(event) + self.mouse.move = pos + + # throttle number of mousemove logs to prevent spamming the debug log + if self._logging and (now := time()) - self._last_mousemove_log > 2.5: + log.debug("mousemove %s, %s", pos.x, pos.y) + self._last_mousemove_log = now + + # TODO: check event.buttons here (tells which buttons are pressed during mouse move) if mouse is pressed + # down on canvas, then moved off, and button is unpressed while off the canvas, mouse buttons may be + # flagged as down when they aren't anymore, checking event.buttons would be a good way to 'unstuck' them + + def on_keydown(self, event): + if event.key in ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]: + event.preventDefault() + self.pressed.add(event.key) + if self._logging: + log.debug("keydown %s", event.key) + + def on_keyup(self, event): + if event.key in self.pressed: + self.pressed.remove(event.key) + if self._logging: + log.debug("keyup %s", event.key) + + # TODO: probably also need a way to handle canvas losing focus and missing key up events, for example if alt + # tabbing away, it registers a key down event, but the not a key up event since it has already lost focus by + # that point diff --git a/cool-cacti/static/scripts/debris.py b/cool-cacti/static/scripts/debris.py new file mode 100644 index 00000000..2474df35 --- /dev/null +++ b/cool-cacti/static/scripts/debris.py @@ -0,0 +1,128 @@ +import math +from random import randint + +from common import Position +from scene_classes import SceneObject +from window import window + + +class Debris(SceneObject): + def __init__(self, position: Position, color: str, radius: float, duration: int, rotation: float) -> None: + super().__init__() + super().set_position(position) + + self.color = color + self.radius = radius + # number of frames until this debris should decay + self.initial_duration = self.duration = duration + self.rotation = rotation + self.momentum = Position(0, 0) + + def update(self) -> None: + # decay duration by 1 frame + self.duration -= 1 + + # adjust position based on momentum and break Newton's laws a little + self.x += self.momentum.x * 0.4 + self.y += self.momentum.y * 0.4 + self.momentum.x *= 0.97 + self.momentum.y *= 0.97 + + def render(self, ctx, timestamp) -> None: + ctx.save() + + ctx.translate(*self.get_position()) + ctx.rotate(self.rotation) + + ctx.beginPath() + # Outer arc + ctx.arc(0, 0, self.radius, 0, 2 * math.pi, False) + # Inner cut arc (opposite winding, offset) + ctx.arc( + self.radius * 0.3 * self.duration / self.initial_duration, + 0, + self.radius * (1.2 - 0.8 * self.duration / self.initial_duration), + 0, + 2 * math.pi, + True, + ) + + ctx.closePath() + ctx.fillStyle = self.color + ctx.globalAlpha = min(self.duration / 255, 1.0) # normalize to 0..1 + ctx.fill() + + ctx.restore() + # DEBUGGING THE CIRCLES DEFINING CRESCENT ABOVE ^ + if window.DEBUG_DRAW_HITBOXES: + ctx.save() + ctx.translate(*self.get_position()) + ctx.rotate(self.rotation) + ctx.strokeStyle = "#FF0000" + ctx.beginPath() + ctx.arc(0, 0, self.radius, 0, 2 * math.pi, False) + ctx.stroke() + ctx.closePath() + + ctx.strokeStyle = "#00FF00" + ctx.beginPath() + ctx.arc( + self.radius * 0.3 * self.duration / self.initial_duration, + 0, + self.radius * (1.2 - 0.8 * self.duration / self.initial_duration), + 0, + 2 * math.pi, + True, + ) + ctx.stroke() + ctx.closePath() + ctx.restore() + + super().render(ctx, timestamp) + + +class DebrisSystem(SceneObject): + def __init__(self) -> None: + super().__init__() + + self.debris_list: list[Debris] = [] # will be filled with debris object instances + + def update(self) -> None: + # tick each debris' timer and discard any debris whose timer has run out + for debris in self.debris_list: + debris.update() + self.debris_list = list(filter(lambda deb: deb.duration > 0, self.debris_list)) + + def generate_debris(self, player_pos: Position, asteroid_pos: Position, max_size=3) -> None: + distance = player_pos.distance(asteroid_pos) + new_debris = [] + for _ in range(randint(3, 5)): + position = player_pos.midpoint(asteroid_pos) + Position(randint(-20, 20), randint(-20, 20)) + shade = randint(128, 255) + color = f"#{shade:x}{shade:x}{shade:x}" + radius = randint(15, 25) * min(50 / distance, max_size) + duration = randint(100, 200) + rotation = 0 + + new_debris.append(Debris(position, color, radius, duration, rotation)) + + new_debris_center = Position( + sum(debris.x for debris in new_debris) / len(new_debris), + sum(debris.y for debris in new_debris) / len(new_debris), + ) + + for debris in new_debris: + debris.momentum = Position((debris.x - new_debris_center.x) / 5.0, (debris.y - new_debris_center.y) / 5.0) + debris.rotation = math.atan2(-debris.y + new_debris_center.y, -debris.x + new_debris_center.x) + + self.debris_list.extend(new_debris) + + def render(self, ctx, timestamp) -> None: + """Render every debris""" + for debris in self.debris_list: + debris.render(ctx, timestamp) + + super().render(ctx, timestamp) + + def reset(self): + self.debris_list = [] diff --git a/cool-cacti/static/scripts/game.py b/cool-cacti/static/scripts/game.py new file mode 100644 index 00000000..554dce15 --- /dev/null +++ b/cool-cacti/static/scripts/game.py @@ -0,0 +1,76 @@ +from asteroid import AsteroidAttack +from consolelogger import getLogger +from controls import GameControls +from debris import DebrisSystem +from js import document # type: ignore[attr-defined] +from player import Player, Scanner +from pyodide.ffi import create_proxy # type: ignore[attr-defined] +from scene_classes import Scene +from scene_descriptions import create_scene_manager +from window import window + +log = getLogger(__name__) + +# References to the useful html elements +loadingLabel = document.getElementById("loadingLabel") +container = document.getElementById("canvasContainer") +width, height = container.clientWidth, container.clientHeight +canvas = window.canvas +ctx = window.ctx = window.canvas.getContext("2d") + +window.DEBUG_DRAW_HITBOXES = False + +# TODO: the resizing and margins needs work, I suck with CSS / html layout +def resize_canvas(event=None) -> None: + width, height = container.clientWidth, container.clientHeight + canvas.width = width + canvas.height = height + canvas.style.width = f"{width}px" + canvas.style.height = f"{height}px" + + +resize_proxy = create_proxy(resize_canvas) +window.addEventListener("resize", resize_proxy) +resize_canvas() + +""" +I'm not entirely clear on what this create_proxy is doing, but when passing python functions as callbacks to +"javascript" (well pyscript wrappers for javascript functionality) we need to wrap them in these proxy objects +instead of passing them as straight up python function references. +""" + +# setup of important systems, expose them globally via window object +controls = window.controls = GameControls(canvas) +scene_manager = window.scene_manager = create_scene_manager() +player = window.player = Player( + window.get_sprite("player"), window.get_sprite("health"), canvas.width / 2, canvas.height / 2, scale=0.1 +) +window.asteroids = AsteroidAttack(window.get_sprite("asteroids"), width, height, 256) +window.debris = DebrisSystem() + +scanner = window.scanner = Scanner(window.get_sprite("scanner"), player, min_x=width * 0.45, scan_mult=1) +log.info("Created player at position (%s, %s)", player.x, player.y) + +loadingLabel.style.display = "none" + + +def game_loop(timestamp: float) -> None: + """Timestamp argument will be time since the html document began to load, in miliseconds.""" + + # these should disable bilinear filtering smoothing, which isn't friendly to pixelated graphics + ctx.imageSmoothingEnabled = False + ctx.webkitImageSmoothingEnabled = False + ctx.mozImageSmoothingEnabled = False + ctx.msImageSmoothingEnabled = False + + active_scene: Scene = scene_manager.get_active_scene() + active_scene.render(ctx, timestamp) + + # if a click event occurred and nothing made use of it during this loop, clear the click flag + controls.click = False + # Schedule next frame + window.requestAnimationFrame(game_loop_proxy) + +# Start loop +game_loop_proxy = create_proxy(game_loop) +window.requestAnimationFrame(game_loop_proxy) \ No newline at end of file diff --git a/cool-cacti/static/scripts/overlay.py b/cool-cacti/static/scripts/overlay.py new file mode 100644 index 00000000..8e9840de --- /dev/null +++ b/cool-cacti/static/scripts/overlay.py @@ -0,0 +1,321 @@ +import re + +from window import window +from common import Position, CanvasRenderingContext2D, Rect +from consolelogger import getLogger +from scene_classes import Scene, SceneManager +from spacemass import SpaceMass + +log = getLogger(__name__) + +def rgba_to_hex(rgba_str): + """ + Convert "rgba(r, g, b, a)" to hex string "#RRGGBB". + Alpha is ignored. + """ + # Extract the numbers + match = re.match(r"rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\s*\)", rgba_str) + if not match: + raise ValueError(f"Invalid RGBA string: {rgba_str}") + + r, g, b = map(int, match.groups()) + return f"#{r:02X}{g:02X}{b:02X}" + +class TextOverlay(Scene): + DEFAULT = "No information found :(" + + def __init__(self, name: str, scene_manager: SceneManager, text: str, color="rgba(0, 255, 0, 0.8)", rect=None, hint=None): + super().__init__(name, scene_manager) + self.bold = False + self.color = color + self.calculate_and_set_font() + self.set_text(text) + self.char_delay = 10 # milliseconds between characters + self.margins = Position(200, 50) + self.button_label = None + self.button_click_callable = None + self.other_click_callable = None + self.deactivate() + self.rect = rect # tuple: (x, y, width, height) + self.muted = True + self.center = False + self.hint = hint + + def deactivate(self): + self.active = False + # pause text sound in case it was playing + window.audio_handler.play_text(pause_it=True) + + def set_text(self, text: str): + """ + Set a new text message for this object to display and resets relevant properties like the + current character position to be ready to start over. Text width is calculated for centered text + rendering. + """ + self.displayed_text = "" + self.text = text + self.char_index = 0 + self.last_char_time = 0 + + # calculate text width in case we want centered text, we won't have to calculate it every frame + self._prepare_font(window.ctx) + self._text_width = max(window.ctx.measureText(line).width for line in self.text.split("\n")) + + def set_button(self, button_label: str | None): + self.button_label = button_label + + def calculate_and_set_font(self) -> str: + # Set text style based on window size + base_size = min(window.canvas.width, window.canvas.height) / 50 + font_size = max(12, min(20, base_size)) # Scale between 12px and 20px + self.font = {"size": font_size, "font": "'Courier New', monospace"} + return self.font + + def update_textstream(self, timestamp): + """Update streaming text""" + + if timestamp - self.last_char_time > self.char_delay and self.char_index < len(self.text): + if not self.muted: + window.audio_handler.play_text() + + chars_to_add = min(3, len(self.text) - self.char_index) + self.displayed_text += self.text[self.char_index : self.char_index + chars_to_add] + self.char_index += chars_to_add + self.last_char_time = timestamp + if self.char_index == len(self.text): + window.audio_handler.play_text(pause_it=True) + + def _prepare_font(self, ctx): + font = self.font or self.calculate_and_set_font() + ctx.font = f"{'bold ' if self.bold else ''}{font['size']}px {font['font']}" + ctx.fillStyle = rgba_to_hex(self.color) + return font + + def render_and_handle_button(self, ctx: CanvasRenderingContext2D, overlay_bounds: Rect) -> Rect: + """ + this function returns the button's bounding Rect as a byproduct, so it can be + conveniently used to check for click events in the calling function + """ + if not self.button_label: + return None + + ctx.save() + ctx.font = "14px Courier New" + text_width = ctx.measureText(self.button_label).width + + button_bounds = Rect(overlay_bounds.right - (text_width + 30), overlay_bounds.bottom - 44, text_width + 20, 34) + + ctx.fillStyle = "rgba(0, 0, 0, 0.95)" + ctx.fillRect(*button_bounds) + + # check whether mouse is currently moving over the button + if button_bounds.contains(window.controls.mouse.move): + ctx.fillStyle = "#ffff00" + else: + ctx.fillStyle = "#00ff00" + + ctx.fillText(self.button_label, button_bounds.left + 10, button_bounds.bottom - 10) + ctx.strokeStyle = "rgba(0, 255, 0, 0.95)" + ctx.lineWidth = 2 + ctx.strokeRect(*button_bounds) + + ctx.restore() + return button_bounds + + def render(self, ctx: CanvasRenderingContext2D, timestamp): + if not self.active or not self.text: + return + + self.update_textstream(timestamp) + + if self.rect: + x, y, width, height = self.rect + overlay_bounds = Rect(x, y, width, height) + else: + overlay_width = window.canvas.width - 2 * self.margins.x + overlay_height = window.canvas.height - 2 * self.margins.y + overlay_bounds = Rect(self.margins.x, self.margins.y, overlay_width, overlay_height) + + # Draw transparent console background + ctx.fillStyle = "rgba(0, 0, 0, 0.8)" + + ctx.fillRect(*overlay_bounds) + + # Draw console border + ctx.strokeStyle = self.color + ctx.lineWidth = 2 + ctx.strokeRect(*overlay_bounds) + ctx.strokeRect( + overlay_bounds.left + 3, overlay_bounds.top + 3, overlay_bounds.width - 6, overlay_bounds.height - 6 + ) + + font = self._prepare_font(ctx) + + # Draw streaming text + lines = self.displayed_text.split("\n") + line_height = font["size"] + 4 + + if self.center: + # Center both horizontally and vertically + total_text_height = len(lines) * line_height + start_y = overlay_bounds.top + (overlay_bounds.height - total_text_height) / 2 + font["size"] + start_x = (window.canvas.width - self._text_width) / 2 + else: + start_y = overlay_bounds.top + font["size"] + 10 # use overlay_bounds.top + start_x = overlay_bounds.left + 10 + + for i, line in enumerate(lines): + y_pos = start_y + i * line_height + if y_pos < overlay_bounds.bottom - 10: # don't draw outside overlay + ctx.fillText(line, start_x, y_pos) + + # Draw hint if any at bottom left + if self.hint: + ctx.fillText(self.hint, overlay_bounds.left + 10, overlay_bounds.bottom - 10) + + button_bounds = self.render_and_handle_button(ctx, overlay_bounds) + if window.controls.click: + # log.debug(self.button_click_callable) + # log.debug(self.other_click_callable) + # if a click occurred and we don't have a button or we clicked outside the button + if button_bounds is None or not button_bounds.contains(window.controls.mouse.click): + if self.other_click_callable is not None: + self.other_click_callable() + # otherwise, button was clicked + elif self.button_click_callable is not None: + self.button_click_callable() + + +class ResultsScreen(TextOverlay): + def __init__(self, name: str, scene_manager: SceneManager, planet: SpaceMass): + self.planet_data = window.get_planet(planet.name) + text = self.planet_data.info if self.planet_data else "" + super().__init__(name, scene_manager, text) + # default sizing for scan results screen + self.margins = Position(200, 50) + +class DeathScreen(TextOverlay): + def __init__(self, name: str, scene_manager: SceneManager): + super().__init__(name, scene_manager, "GAME OVER", color="rgba(0, 255, 0, 0.9)") + # Center the death screen + self.margins = Position(150, 150) + self.center = True + self.muted = True # This only refers to text terminal sound, not audio in general + self.bold = True + + def calculate_and_set_font(self) -> str: + base_size = min(window.canvas.width, window.canvas.height) / 15 + font_size = max(32, min(72, base_size)) # Scale between 32px and 72px + self.font = {"size": font_size, "font": "'Courier New', monospace"} + return self.font + + def render(self, ctx: CanvasRenderingContext2D, timestamp): + window.audio_handler.play_music_death() + super().render(ctx, timestamp) + + +class Dialogue(TextOverlay): + def __init__(self, name: str, scene_manager: SceneManager, text: str): + # Initialize the first line using the TextOverlay constructor + lines = text.split("\n") + first_line = lines[0] if lines else "" + super().__init__(name, scene_manager, first_line) + + # Store all lines and keep track of current index + self.lines = lines + self.current_index = 0 + self.swap_color = False + self.is_col1 = False + self.switch_color() + self.done = False + + def next(self): + """Advance to the next line of dialogue.""" + self.current_index += 1 + if self.current_index < len(self.lines): + self.switch_color() + # Use the TextOverlay method to set the next line + self.set_text(self.lines[self.current_index].strip()) + self.active = True + else: + # No more lines + self.done = True + self.deactivate() + + def render(self, ctx: CanvasRenderingContext2D, timestamp): + """Render the currently active line.""" + + message_parts = self.lines[self.current_index].strip().split(' ') + split_message = [] + len_text_line = 0 + partial_message = '' + + for part in message_parts: + word_width = ctx.measureText(part + ' ').width # include space + if len_text_line + word_width <= self.rect[2]: + partial_message += part + ' ' + len_text_line += word_width + else: + # save current line before adding the new word + split_message.append(partial_message.rstrip()) + # start new line with current word + partial_message = part + ' ' + len_text_line = word_width + + if partial_message: + split_message.append(partial_message.rstrip()) + + formatted_message = '' + for part in split_message: + formatted_message += part + '\n' + self.text = formatted_message + + super().render(ctx, timestamp) + + def switch_color(self): + self.is_col1 = not self.is_col1 + if self.is_col1: + self.color = "rgba(0, 255, 0, 0.8)" + else: + self.color = "rgba(170, 255, 0, 0.8)" + +class Credits: + """Simple scrolling credits""" + def __init__(self, credits_text: str, fill_color: str): + self.credits_lines = credits_text.split("\n") if credits_text else ["No credits available"] + self.scroll_speed = 0.4 # pixels per frame + self.y_offset = window.canvas.height * 0.7 # Start near bottom of screen + self.line_height = 30 + self.fill_color = fill_color + self.finished = False + + def update(self, timestamp): + """Update the scroll position.""" + self.y_offset -= self.scroll_speed + + # Check if credits have finished scrolling + if not self.finished: + last_line_y = self.y_offset + (len(self.credits_lines) * self.line_height) + # log.debug("Credits Last Line Y Offset: %s", last_line_y) + if last_line_y < 0: + self.finished = True + + def render(self, ctx, timestamp): + """Render the scrolling credits.""" + if self.finished: + return + + ctx.save() + ctx.font = f"18px Courier New" + ctx.fillStyle = self.fill_color + ctx.textAlign = "center" + + # Draw each line of credits + for i, line in enumerate(self.credits_lines): + y_pos = self.y_offset + (i * self.line_height) + # Only render if the line is visible on screen + if -self.line_height <= y_pos <= window.canvas.height + self.line_height: + ctx.fillText(line, window.canvas.width / 2, y_pos) + + ctx.restore() + diff --git a/cool-cacti/static/scripts/player.py b/cool-cacti/static/scripts/player.py new file mode 100644 index 00000000..702f80f8 --- /dev/null +++ b/cool-cacti/static/scripts/player.py @@ -0,0 +1,575 @@ +import math +import time +from collections import deque +from dataclasses import dataclass +import random + +from asteroid import Asteroid +from common import Position +from consolelogger import getLogger +from scene_classes import SceneObject +from window import SpriteSheet, window + +log = getLogger(__name__) + +class Player(SceneObject): + """Controllable player sprite. + + Exposed globally as window.player so other modules can use it. + Movement keys: WASD or Arrow keys. + """ + + FULL_HEALTH = 1000 + + def __init__( + self, + sprite: SpriteSheet, + bar_icon: SpriteSheet, + x: float, + y: float, + speed: float = 100.0, + scale: float = 0.1, + hitbox_scale: float = 0.5, + ): + super().__init__() + + self.health = Player.FULL_HEALTH + self.health_history = deque([Player.FULL_HEALTH] * 200) + self.sprite = sprite + self.set_position(x, y) + self.default_pos = (x, y) + self.speed = speed + self.momentum = [0, 0] + self.scale = scale + self._half_w = 0 + self._half_h = 0 + self.hitbox_scale = hitbox_scale + self.rotation = 0.0 # rotation in radians + self.target_rotation = 0.0 + self.max_tilt = math.pi / 8 # Maximum tilt angle (22.5 degrees) + self.rotation_speed = 8.0 + self.is_moving = False + self.is_disabled = False + self.bar_icon = bar_icon + self.active = False + self.invincible = False + self.key_cooldown = {} + + def _update_sprite_dims(self): + w = self.sprite.width + h = self.sprite.height + if w and h: + self._half_w = (w * self.scale) / 2 + self._half_h = (h * self.scale) / 2 + + def update(self, timestamp: float): + """Update player position based on pressed keys. + + dt: time delta (seconds) + controls: GameControls instance for key state + """ + if not self.sprite: + return + + # update sprite dimensions if needed + if not self._half_w or not self._half_h: + self._update_sprite_dims() + + keys = window.controls.pressed + dx = dy = 0.0 + if not self.is_disabled: + if "w" in keys or "ArrowUp" in keys: + dy -= 1.75 + if "s" in keys or "ArrowDown" in keys: + dy += 1.75 + if "a" in keys or "ArrowLeft" in keys: + dx -= 1.75 + if "d" in keys or "ArrowRight" in keys: + dx += 1.75 + + # TODO: remove this, for testing momentum + if "m" in keys: + if timestamp - self.key_cooldown.setdefault("m", 0) < 1000: return + angle = random.uniform(0, 6.28) + self.momentum[0] = math.cos(angle) * 5 + self.momentum[1] = math.sin(angle) * 5 + self.key_cooldown["m"] = timestamp + # DEBUG: switch hitbox visibility + if "c" in keys: + if timestamp - self.key_cooldown.setdefault("c", 0) < 100: return + window.DEBUG_DRAW_HITBOXES = not window.DEBUG_DRAW_HITBOXES + self.key_cooldown["c"] = timestamp + # DEBUG: instant death for testing + if "k" in keys: + self.health = 0 + + # miliseconds to seconds since that's what was being used + dt = (timestamp - self.last_timestamp) / 1000 + + # Update target rotation based on horizontal movement + if dx < 0: # Moving left + self.target_rotation = -self.max_tilt # Tilt left + elif dx > 0: # Moving right + self.target_rotation = self.max_tilt # Tilt right + else: + self.target_rotation = 0.0 + + # Smoothly interpolate current rotation toward target + rotation_diff = self.target_rotation - self.rotation + self.rotation += rotation_diff * self.rotation_speed * dt + + if dx or dy: + # normalize diagonal movement + mag = (dx * dx + dy * dy) ** 0.5 + dx /= mag + dy /= mag + self.x += dx * self.speed * dt + self.y += dy * self.speed * dt + + self.is_moving = True + else: + self.is_moving = False + + # update player position based on momentum (after they were hit and bumped by an asteroid) + if self.momentum[0] or self.momentum[1]: + self.x += self.momentum[0] * self.speed * dt + self.y += self.momentum[1] * self.speed * dt + self.momentum[0] *= 0.97 + self.momentum[1] *= 0.97 + if abs(self.momentum[0]) < 0.5: + self.momentum[0] = 0 + if abs(self.momentum[1]) < 0.5: + self.momentum[1] = 0 + + # clamp inside canvas + canvas = getattr(window, "gameCanvas", None) + if canvas and self._half_w and self._half_h: + max_x = canvas.width - self._half_w + max_y = canvas.height - self._half_h + self.x = min(max(self._half_w, self.x), max_x) + self.y = min(max(self._half_h, self.y), max_y) + + def render(self, ctx, timestamp): + if not self.sprite: + log.debug("Player render: no sprite") + return + + self.update(timestamp) + + if not self._half_w or not self._half_h: + self._update_sprite_dims() + + scaled_w = self._half_w * 2 + scaled_h = self._half_h * 2 + + # Save the canvas state before applying rotation + ctx.save() + + # Move to player center and apply rotation + ctx.translate(self.x, self.y) + ctx.rotate(self.rotation) + + # Draw sprite centered at origin + ctx.drawImage(self.sprite.image, -self._half_w, -self._half_h, scaled_w, scaled_h) + + # Debug draw hitbox + if window.DEBUG_DRAW_HITBOXES: + ctx.strokeStyle = "white" + ctx.lineWidth = 2 + ctx.strokeRect(-self._half_w, -self._half_h, scaled_w, scaled_h) + + # Restore canvas state (removes rotation and translation) + ctx.restore() + + # Collision detection (done after restore so it's in world coordinates) + if self.active: + for asteroid in window.asteroids.asteroids: + self.check_collision(asteroid) + self.render_health_bar(ctx) + + super().render(ctx, timestamp) + + def render_health_bar(self, ctx): + outer_width = window.canvas.width // 4 + outer_height = 12 + inner_width = outer_width - 4 + inner_height = outer_height - 4 + padding = 30 + + ctx.drawImage( + self.bar_icon.image, + window.canvas.width - outer_width - padding - 30, + window.canvas.height - outer_height - padding - 2, + ) + + ctx.lineWidth = 1 + ctx.strokeStyle = "#FFFFFF" + ctx.strokeRect( + window.canvas.width - outer_width - padding, + window.canvas.height - outer_height - padding, + outer_width, + outer_height, + ) + + ctx.fillStyle = "#FF0000" + ctx.fillRect( + window.canvas.width - outer_width - padding + 2, + window.canvas.height - outer_height - padding + 2, + inner_width * self.health_history.popleft() / Player.FULL_HEALTH, + inner_height, + ) + self.health_history.append(self.health) + + ctx.fillStyle = "#00FF00" + ctx.fillRect( + window.canvas.width - outer_width - padding + 2, + window.canvas.height - outer_height - padding + 2, + inner_width * self.health / Player.FULL_HEALTH, + inner_height, + ) + + def check_collision(self, asteroid: Asteroid): + # skip if asteroid is too far in the background + if asteroid.size < asteroid.target_size * 0.70: + return + # use invicible flag (toggled when planet is done) + if self.invincible: + return + + ast_x, ast_y, ast_radius = asteroid.get_hit_circle() + player_x_min, player_x_max = self.x - self._half_w, self.x + self._half_w + player_y_min, player_y_max = self.y - self._half_h, self.y + self._half_h + + hitbox_closest_x = max(player_x_min, min(ast_x, player_x_max)) + hitbox_closest_y = max(player_y_min, min(ast_y, player_y_max)) + + # if the closest point on the rectangle is inside the asteroid's circle, we have collision: + if (hitbox_closest_x - ast_x) ** 2 + (hitbox_closest_y - ast_y) ** 2 < ast_radius**2: + distance_between_centers = math.dist((ast_x, ast_y), (self.x, self.y)) + # log.debug("Asteroid collision with distance %s", distance_between_centers) + asteroid.health -= max(80, 240 - distance_between_centers) + # Make Newton proud + self.momentum[0] = (self.x - ast_x) / distance_between_centers * 5.0 + self.momentum[1] = (self.y - ast_y) / distance_between_centers * 5.0 + asteroid.velocity_x += (ast_x - self.x) / 2.0 + asteroid.velocity_y += (ast_y - self.y) / 2.0 + self.health = max(0, self.health - 100 / distance_between_centers * 5 * asteroid.damage_mul) + + # # Reduce scanner progress when hit by asteroid + # if hasattr(window, 'scanner') and window.scanner: + # # Reduce progress by 2-6% of max progress based on damage taken + # damage_taken = 100 / (distance_between_centers * 5 * asteroid.damage_mul + # progress_loss = window.scanner._bar_max * (0.02 + (damage_taken / 1000) * 0.04) + # window.scanner.scanning_progress = max(0, window.scanner.scanning_progress - progress_loss) + # # log.debug("Scanner progress reduced by %f due to asteroid collision", progress_loss) + + window.audio_handler.play_bang() + window.debris.generate_debris(self.get_position(), Position(ast_x, ast_y)) + + def nudge_towards(self, pos: Position, gravity_strength: float = 0.75) -> None: + distance = self.get_position().distance(pos) + if distance == 0: return + + x_dir = (pos.x - self.x) / distance + y_dir = (pos.y - self.y) / distance + + self.x += x_dir * gravity_strength + self.y += y_dir * gravity_strength + + # x_dir = math.cos((pos.x - self.x )/(pos.y - self.y)) + # y_dir = math.sin((pos.x - self.x )/(pos.y - self.y)) + + # if abs(self.momentum[0]) < 1: + # self.momentum[0] += x_dir * momentum_amount + # if abs(self.momentum[1]) < 1: + # self.momentum[1] += y_dir * momentum_amount + + def get_hit_circle(self) -> tuple[float, float, float]: + """Get the hit circle for the player""" + if not self._half_w or not self._half_h: + self._update_sprite_dims() + r = min(self._half_w, self._half_h) * self.hitbox_scale + return (self.x, self.y, r) + + def get_aabb(self) -> tuple[float, float, float, float]: + """Get the axis-aligned bounding box (AABB) for the player""" + if not self._half_w or not self._half_h: + self._update_sprite_dims() + hw = self._half_w * self.hitbox_scale + hh = self._half_h * self.hitbox_scale + return (self.x - hw, self.y - hh, self.x + hw, self.y + hh) + + def reset_position(self): + self.x, self.y = self.default_pos + self.rotation = 0.0 + self.target_rotation = 0.0 + self.momentum = [0, 0] + + +@dataclass +class ScanStatus: + active: bool = False # Whether the scan is active + too_close: bool = False # Whether the scan is valid + player_interrupted: bool = False # Whether the scan was interrupted + locked: bool = False # Whether the scan is locked + + @property + def valid(self): + return not self.too_close and not self.player_interrupted and not self.locked + + +class Scanner: + def __init__( + self, + sprite: SpriteSheet, + player: Player, + min_x: float, + scan_mult: float = 1, + scale: float = 0.1, + disable_ship_ms: float = 1000, + beamwidth=100, + scanning_dur_s=15, + ): + self.sprite = sprite + self.scale = scale + self.player = player + self.min_x = min_x + self.disable_ship_ms = disable_ship_ms + self.disable_timer = 0 + + # Core scanning parameters + self.scanning_dur_ms = scanning_dur_s * 1000 + self.scan_mult = scan_mult + self.beamwidth = beamwidth + + # State variables + self.status = ScanStatus() + self.scanning_progress = 0 + self.finished = False + self._last_scan_tick = None + + # Calculate max based on current parameters + self._update_bar_max() + + def _update_bar_max(self): + """Update the maximum progress value based on current parameters""" + self._bar_max = self.scanning_dur_ms * self.scan_mult + + def set_scan_parameters(self, scan_mult: float | None = None, scanning_dur_s: float | None = None): + """Update scanning parameters and recalculate max value""" + if scan_mult is not None: + self.scan_mult = scan_mult + if scanning_dur_s is not None: + self.scanning_dur_ms = scanning_dur_s * 1000 + self._update_bar_max() + + def update(self, ctx, current_time): + if self.finished: + return + + keys = window.controls.pressed + + self.status.active = " " in keys + + self.status.too_close = self.player.x <= self.min_x + self.status.player_interrupted = self.player.momentum != [0, 0] + + # Lock if interrupted and stay locked until released + if self.status.player_interrupted: + self.status.locked = True + elif not self.status.active: + self.status.locked = False + + if self.status.active and self.status.valid: + self.player.is_disabled = True + + if self._last_scan_tick is None: + self._last_scan_tick = current_time + + elapsed_since_last = current_time - self._last_scan_tick + self.scanning_progress = min(self.scanning_progress + elapsed_since_last, self._bar_max) + self._last_scan_tick = current_time + else: + self._last_scan_tick = None + + # Re-enable player if disable_time has elapsed or player is not scanning + if current_time - self.disable_timer >= self.disable_ship_ms or not self.status.active: + if " " not in keys: + self.player.is_disabled = False + self.disable_timer = current_time + + def render_beam(self, ctx): # seprate function so it can go under the planet + + if not self.status.active or not self.status.valid: + window.audio_handler.play_scan(pause_it=True) + return + + window.audio_handler.play_scan() + + player_x, player_y = self.player.get_position() + origin_x = player_x - 150 + origin_y = player_y - 15 + + # Create animated pulsing effect based on time + pulse = (math.sin(time.time() * 8) + 1) / 2 # 0 to 1 + beam_alpha = 0.3 + pulse * 0.3 # Vary alpha from 0.3 to 0.6 + + # Create gradient for the beam + gradient = ctx.createLinearGradient(origin_x, origin_y, 0, player_y) + gradient.addColorStop(0, f"rgba(255, 100, 100, {beam_alpha})") + gradient.addColorStop(0.5, f"rgba(255, 50, 50, {beam_alpha * 0.8})") + gradient.addColorStop(1, f"rgba(255, 0, 0, {beam_alpha * 0.5})") + + # Main beam cone + ctx.fillStyle = gradient + ctx.beginPath() + ctx.moveTo(origin_x, origin_y) + ctx.lineTo(0, player_y - self.beamwidth) + ctx.lineTo(0, player_y + self.beamwidth) + ctx.closePath() + ctx.fill() + + # Add animated scanning lines + scan_cycle = (time.time() * 2) % 1 # 0 to 1, cycling every 0.5 seconds + num_lines = 5 + + for i in range(num_lines): + line_progress = (scan_cycle + i * 0.2) % 1 + line_x = origin_x - line_progress * origin_x + line_alpha = (1 - line_progress) * 0.8 + beam_height = self.beamwidth + + if line_alpha > 0.1: # Only draw visible lines + ctx.strokeStyle = f"rgba(255, 255, 255, {line_alpha})" + ctx.lineWidth = 2 + ctx.beginPath() + ctx.moveTo(line_x, player_y - beam_height) + ctx.lineTo(line_x, player_y + beam_height) + ctx.stroke() + + # Add edge glow effect + ctx.strokeStyle = f"rgba(255, 150, 150, {beam_alpha * 0.6})" + ctx.lineWidth = 3 + ctx.beginPath() + ctx.moveTo(origin_x, origin_y) + ctx.lineTo(0, player_y - self.beamwidth) + ctx.moveTo(origin_x, origin_y) + ctx.lineTo(0, player_y + self.beamwidth) + ctx.stroke() + + def render(self, ctx, current_time): + "Renders the scanner sprite and the progress bar" + if "f" in window.controls.pressed and window.player.active: + self.finished = True + + player_x, player_y = self.player.get_position() + # progress bar + outer_width = window.canvas.width // 4 + outer_height = 12 + inner_width = outer_width - 4 + inner_height = outer_height - 4 + padding = 30 + + ctx.drawImage( + self.sprite.image, + window.canvas.width - outer_width - padding - 30, + window.canvas.height + outer_height - padding - 2, + 16, + 16, + ) + + ctx.lineWidth = 1 + ctx.strokeStyle = "#FFFFFF" + ctx.strokeRect( + window.canvas.width - outer_width - padding, + window.canvas.height + outer_height - padding, + outer_width, + outer_height, + ) + + ctx.fillStyle = "#FF0000" + ctx.fillRect( + window.canvas.width - outer_width - padding + 2, + window.canvas.height + outer_height - padding + 2, + inner_width * self.scanning_progress / self._bar_max, + inner_height, + ) + + if self.finished: + return + + if self.status.active: + if self.status.valid: + scaled_w = self.sprite.width * self.scale + scaled_h = self.sprite.height * self.scale + ctx.drawImage(self.sprite.image, player_x - 175, player_y - 25, scaled_w, scaled_h) + elif self.status.too_close: + ctx.fillStyle = "white" + ctx.font = "15px Courier New" + ctx.fillText("Too close to planet!", player_x - 90, player_y - 50) + + if self.scanning_progress >= self._bar_max: + log.debug(f"Done scanning") + self.status.active = False + self.finished = True + + def reset(self): + self.finished = False + self.scanning_progress = 0 + self._update_bar_max() + +class PlayerExplosion(): + def __init__(self): + self.explosion_sprite = window.get_sprite("Explosion Animation") + self.active = False + self.current_frame = 0 + self.frame_count = 11 # Number of frames + self.frame_duration = 100 # milliseconds per frame + self.last_frame_time = 0 + self.position = (0, 0) + self.scale = 4.0 + self.finished = False + + def start_explosion(self, x: float, y: float): + """Start the explosion animation at the given position""" + self.active = True + self.current_frame = 0 + self.position = (x, y) + self.last_frame_time = 0 + self.finished = False + + def update(self, timestamp: float): + """Update the explosion animation""" + if not self.active or self.finished: + return + + if timestamp - self.last_frame_time >= self.frame_duration: + self.current_frame += 1 + self.last_frame_time = timestamp + + if self.current_frame >= self.frame_count: + self.finished = True + self.active = False + + def render(self, ctx, timestamp: float): + """Render the current explosion frame""" + if not self.active or self.finished: + return + + self.update(timestamp) + + frame_width = self.explosion_sprite.width // self.frame_count + frame_height = self.explosion_sprite.height + + source_x = self.current_frame * frame_width + source_y = 0 + + scaled_width = frame_width * self.scale + scaled_height = frame_height * self.scale + + ctx.drawImage( + self.explosion_sprite.image, + source_x, source_y, frame_width, frame_height, # source rectangle + self.position[0] - scaled_width/2, self.position[1] - scaled_height/2, # destination position + scaled_width, scaled_height # destination size + ) diff --git a/cool-cacti/static/scripts/scene_classes.py b/cool-cacti/static/scripts/scene_classes.py new file mode 100644 index 00000000..acd990b8 --- /dev/null +++ b/cool-cacti/static/scripts/scene_classes.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import overload + +from common import CanvasRenderingContext2D, Position + +# ==================================================== +# Scene Object abstract class +# ==================================================== + + +class SceneObject: + def __init__(self): + """every scene object keeps track of the last milisecond timestamp when it was rendered""" + self.last_timestamp = 0 + + def render(self, ctx: CanvasRenderingContext2D, timestamp: float): + # update the last rendered timestamp + self.last_timestamp = timestamp + + """ + A few subclasses use these position methods so moved them here for shared functionality. + SceneObject subclasses where these don't make sense can just ignore them. (e.g. SolarSystem) + """ + + @overload + def set_position(self, x: float, y: float): ... + + @overload + def set_position(self, x: Position): ... + + def set_position(self, x_or_pos, y=None): + if y is not None: + x = x_or_pos + self.x = x + self.y = y + else: + pos = x_or_pos + self.x = pos.x + self.y = pos.y + + def get_position(self) -> Position: + return Position(self.x, self.y) + + +# -------------------- +# Scene Class +# -------------------- + + +class Scene(SceneObject): + def __init__(self, name: str, scene_manager: SceneManager): + super().__init__() + self.name = name + self.active = False + self.scene_manager = scene_manager + + +# -------------------- +# Scene Manager Class +# -------------------- + + +class SceneManager: + def __init__(self): + self._scenes: list[Scene] = [] + + def add_scene(self, scene: Scene): + self._scenes.append(scene) + + def activate_scene(self, scene_name): + """ + Deactivate all scenes, and only activate the one with the provided name + """ + for scene in self._scenes: + scene.active = False + next(scene for scene in self._scenes if scene.name == scene_name).active = True + + def get_active_scene(self): + return next(scene for scene in self._scenes if scene.active) diff --git a/cool-cacti/static/scripts/scene_descriptions.py b/cool-cacti/static/scripts/scene_descriptions.py new file mode 100644 index 00000000..9db636a6 --- /dev/null +++ b/cool-cacti/static/scripts/scene_descriptions.py @@ -0,0 +1,553 @@ +from functools import partial + +from player import Player, PlayerExplosion +from common import PlanetState, Position, Rect +from consolelogger import getLogger +from scene_classes import Scene, SceneManager +from solar_system import SolarSystem +from spacemass import SpaceMass +from stars import StarSystem, StarSystem3d +from window import window +from overlay import TextOverlay, ResultsScreen, DeathScreen, Dialogue, Credits + +from js import document #type:ignore +canvas = document.getElementById("gameCanvas") +container = document.getElementById("canvasContainer") +log = getLogger(__name__, False) + +# -------------------- +# methods useful across various scenes +# -------------------- + +ORBITING_PLANETS_SCENE = "orbiting-planets-scene" +FINAL_SCENE = "final-scene" +START_SCENE = "start-scene" + +def get_controls(): + return window.controls + +def get_player(): + return window.player + +def get_asteroid_system(): + return window.asteroids + +def get_debris_system(): + return window.debris + +def get_scanner(): + return window.scanner + +def draw_black_background(ctx): + ctx.fillStyle = "black" + ctx.fillRect(0, 0, window.canvas.width, window.canvas.height) + +# -------------------- +# our main scene with the planets orbiting the sun +# -------------------- + +class OrbitingPlanetsScene(Scene): + """ + Scene that handles the functionality of the part of the game where planets are orbiting around the sun + and the player can select a level by clicking planets + """ + + def __init__(self, name: str, scene_manager: SceneManager, solar_system: SolarSystem): + super().__init__(name, scene_manager) + + self.solar_sys = solar_system + + self.stars = StarSystem( + num_stars=400, # as number of stars increase, the radius should decrease + radius_min=1, + radius_max=2, + pulse_freq_min=3, + pulse_freq_max=6, + ) + self.planet_info_overlay = TextOverlay("planet-info-overlay", scene_manager, "") + # attach a behavior to click event outside the overlay's button - hide the overlay + self.planet_info_overlay.other_click_callable = self.planet_info_overlay.deactivate + self.planet_info_overlay.set_button("Travel") + self.planet_info_overlay.muted = False + self.planet_info_overlay.center = True + self.scene_manager = scene_manager + # Debug button label + self._debug_btn_label = "" # disable the extra button by default + + self.show_cheats_menu() + + # just a temporary function for demo-ing project + def show_cheats_menu(self): + cheats_info = """ +Hello, thanks for checking out our project! +In order to more easily demo the functionality +of different parts of the game, we have included +the following cheats: + +In planet overview screen: +[C] - Instantly jump to credits / victory screen + +During ship flight: +[C] - Toggle collision boxes (for fun) +[K] - Kill the player (can start a new game) +[F] - Finish the current planet scan +""" + self.planet_info_overlay.set_button(None) + self.planet_info_overlay.set_text(cheats_info) + self.planet_info_overlay.margins = Position(300, 150) + self.planet_info_overlay.active = True + self.planet_info_overlay.center = False + + def render(self, ctx, timestamp): + + # some temporary functionality for testing + if "c" in window.controls.pressed: + self.scene_manager.activate_scene(FINAL_SCENE) + window.audio_handler.play_music_main() + + draw_black_background(ctx) + self.highlight_hovered_planet() + + self.stars.render(ctx, timestamp) + self.solar_sys.update_orbits(0.20) + self.solar_sys.render(ctx, timestamp) + + # If all planets are complete, switch to the final scene + if all(p.complete for p in self.solar_sys.planets): + self.scene_manager.activate_scene(FINAL_SCENE) + self._debug_btn_label = "View Credits Again" + return + + # from this scene, be ready to switch to a big planet scene if planet is clicked + if self.planet_info_overlay.active: + self.planet_info_overlay.render(ctx, timestamp) + else: + self.check_planet_click() + + # Debug: button to set all planets to complete + self._render_debug_complete_all_button(ctx) + + def _render_debug_complete_all_button(self, ctx): + label = self._debug_btn_label + if not label: return + ctx.save() + ctx.font = "14px Courier New" + text_width = ctx.measureText(label).width + pad_x, pad_y = 10, 8 + x, y = 16, 16 + w, h = text_width + pad_x * 2, 30 + bounds = Rect(x, y, w, h) + + # Background + ctx.fillStyle = "rgba(0, 0, 0, 0.75)" + ctx.fillRect(*bounds) + + # Hover state + is_hover = bounds.contains(get_controls().mouse.move) + ctx.strokeStyle = "#ffff00" if is_hover else "#00ff00" + ctx.lineWidth = 2 + ctx.strokeRect(*bounds) + ctx.fillStyle = ctx.strokeStyle + ctx.fillText(label, x + pad_x, y + h - 10) + + # Click handling + if window.controls.click and bounds.contains(window.controls.mouse.click): + for p in self.solar_sys.planets: + p.complete = True + log.debug("Debug: set all planet completions to True") + ctx.restore() + + def check_planet_click(self): + """Check whether a UI action needs to occur due to a click event.""" + + planet = self.solar_sys.get_object_at_position(window.controls.mouse.click) + if window.controls.click and planet: + planet_data = window.get_planet(planet.name) + log.debug("Clicked on: %s", planet.name) + self.planet_info_overlay.hint = "Click anywhere to close" + if planet.complete: + self.planet_info_overlay.set_button(None) + self.planet_info_overlay.set_text(planet_data.info) + self.planet_info_overlay.margins = Position(200, 50) + self.planet_info_overlay.active = True + self.planet_info_overlay.center = True + else: + self.planet_info_overlay.set_button("Travel") + self.planet_info_overlay.button_click_callable = partial(self.switch_planet_scene, planet.name) + self.planet_info_overlay.set_text("\n".join(planet_data.level)) + self.planet_info_overlay.margins = Position(300, 120) + self.planet_info_overlay.active = True + self.planet_info_overlay.center = False + + def highlight_hovered_planet(self): + # Reset all planets' highlight state first + for planet in self.solar_sys.planets: + planet.highlighted = False + + planet = self.solar_sys.get_object_at_position(window.controls.mouse.move) + if planet is not None and not self.planet_info_overlay.active: + planet.highlighted = True + + def switch_planet_scene(self, planet_name): + """Prepare what is needed to transition to a gameplay scene.""" + + planet_scene_name = f"{planet_name}-planet-scene" + log.debug("Activating planet scene: %s", planet_scene_name) + + planet = window.get_planet(planet_name) + if planet is None: + log.error("Planet not found: %s", planet_name) + return + + log.debug(planet) + self.planet_info_overlay.deactivate() + self.scene_manager.activate_scene(planet_scene_name) + self.solar_sys.get_planet(planet_name).switch_view() + get_player().reset_position() + get_player().active = True + get_asteroid_system().reset(planet) + get_debris_system().reset() + get_scanner().set_scan_parameters(planet.scan_multiplier) + get_scanner().reset() + +# -------------------- +# game scene with zoomed in planet on left +# -------------------- + +class PlanetScene(Scene): + """ + Scene that handles the functionality of the part of the game where the player's ship is active and dodging + asteroids. Also handles the scan results display as a child scene. + """ + + def __init__(self, name: str, scene_manager: SceneManager, planet: SpaceMass): + super().__init__(name, scene_manager) + + self.stars = StarSystem( + num_stars=100, # as number of stars increase, the radius should decrease + radius_min=1, + radius_max=3, + pulse_freq_min=3, + pulse_freq_max=6, + ) + self.planet = planet + planet.set_position(0, window.canvas.height // 2) + self.results_overlay = ResultsScreen(f"{planet.name}-results", scene_manager, self.planet) + self.results_overlay.other_click_callable = self.handle_scene_completion + self.results_overlay.muted = False + self.results_overlay.center = True + self.results_overlay.hint = "Click anywhere to continue" + + # Add death screen + self.death_screen = DeathScreen(f"{planet.name}-death", scene_manager) + self.death_screen.button_click_callable = self.handle_player_death + self.death_screen.set_button("Play Again") + + # Add explosion animation + self.player_explosion = PlayerExplosion() + self.explosion_started = False + + def render(self, ctx, timestamp): + draw_black_background(ctx) + self.stars.star_shift(timestamp, 5) + self.stars.render(ctx, timestamp) + get_scanner().update(ctx, timestamp) + get_scanner().render_beam(ctx) + self.planet.render(ctx, timestamp) + + # Update + render handles spawn and drawing + get_asteroid_system().update_and_render(ctx, timestamp) + self.check_special_level_interactions(timestamp) + + # Check for player death first + if get_player().health <= 0: + if not self.explosion_started: + window.audio_handler.play_explosion() + # Start explosion animation at player position + player_x, player_y = get_player().get_position() + self.player_explosion.start_explosion(player_x, player_y) + self.explosion_started = True + get_player().invincible = True + window.audio_handler.play_music_main(pause_it=True) + + # Render explosion instead of player + if self.player_explosion.active: + self.player_explosion.render(ctx, timestamp) + # Only show death screen after explosion is finished + elif self.player_explosion.finished: + self.death_screen.active = True + else: + # Normal player rendering when alive + get_player().render(ctx, timestamp) + + get_debris_system().update() + get_debris_system().render(ctx, timestamp) + + get_scanner().render(ctx, timestamp) + + # Activate the results sub-scene if scanner progress is complete + if get_scanner().finished: + self.results_overlay.active = True + get_player().invincible = True + elif get_player().health > 0: # Only reset invincibility if player is alive + get_player().invincible = False + + # Handle death screen display and interaction + if self.death_screen.active: + self.death_screen.render(ctx, timestamp) + # Handle results screen display and interaction + self.results_overlay.render(ctx, timestamp) + + def check_special_level_interactions(self, timestamp: int): + """ + Handle special level interactions + + This is probably not best place to handle the special level stuff like Jupiter gravity affecting + player and Mercury slowly damaging player, but it's crunch time so whatever works :) + """ + # nudge player in the direction of jupiter if on the left 2/3 of the screen + if self.planet.name.lower() == "jupiter": + get_player().nudge_towards(self.planet.get_position(), 0.5) + elif self.planet.name.lower() == "mercury": + get_player().health = max(0, get_player().health - (timestamp - self.last_timestamp) / 1_200_000) + + def handle_scene_completion(self): + """Handle when the scanning is finished and planet is complete.""" + log.debug(f"Finished planet {self.planet.name}! Reactivating orbiting planets scene.") + self.scene_manager.activate_scene(ORBITING_PLANETS_SCENE) + get_player().active = False + self.results_overlay.active = True + get_player().health = min(get_player().health + Player.FULL_HEALTH / 3, Player.FULL_HEALTH) + self.planet.switch_view() + self.planet.complete = True + + def handle_player_death(self): + """Handle when the player dies and clicks on the death screen.""" + window.audio_handler.play_music_death(pause_it=True) + log.debug(f"Player died on {self.planet.name}! Returning to orbiting planets scene.") + + # Reset all planet completions when player dies + orbiting_scene = next(scene for scene in self.scene_manager._scenes if scene.name == ORBITING_PLANETS_SCENE) + for planet in orbiting_scene.solar_sys.planets: + planet.complete = False + log.debug("All planet completions reset due to player death") + + window.audio_handler.play_explosion(pause_it=True) + self.scene_manager.activate_scene(ORBITING_PLANETS_SCENE) + get_player().active = False + get_player().health = 1000 # Reset player health to FULL_HEALTH + self.death_screen.deactivate() + self.explosion_started = False # Reset explosion state + self.planet.switch_view() + + # special level interaction: finishing earth gives player full health back + if self.planet.name.lower() == "earth": + get_player().health = Player.FULL_HEALTH + log.debug(window.audio_handler.music_death.paused) + +# -------------------- +# game intro scene with dialogue +# -------------------- + +class StartScene(Scene): + """Scene for handling the alien dialogue for introducing the game.""" + + def __init__(self, name: str, scene_manager: SceneManager, bobbing_timer = 135, bobbing_max = 20): + super().__init__(name, scene_manager) + self.stars = StarSystem( + num_stars=100, # as number of stars increase, the radius should decrease + radius_min=1, + radius_max=1, + pulse_freq_min=3, + pulse_freq_max=6, + ) + + self.dialogue_manager = Dialogue('dialogue', scene_manager, window.lore) + self.dialogue_manager.active = True + self.dialogue_manager.margins = Position(300, 150) + self.dialogue_manager.rect=(0, window.canvas.height-150, window.canvas.width, 150) + self.dialogue_manager.set_button("Skip Intro") + self.dialogue_manager.button_click_callable = self.finalize_scene + self.starsystem = StarSystem3d(100, max_depth=100) + self.player = None + self.bobbing_timer = bobbing_timer + self.bobbing_max = bobbing_max + self.is_bobbing_up = True + self.bobbing_offset = 0 + self.animation_timer = 0 + + def render(self, ctx, timestamp): + if self.player is None: + player = get_player() + player.is_disabled = True + + if timestamp - self.animation_timer >= self.bobbing_timer: + # log.debug(f"bobbing, val={self.bobbing_offset}") + self.animation_timer = timestamp + if self.is_bobbing_up: + self.bobbing_offset += 1 + else: + self.bobbing_offset -= 1 + + player.y = (window.canvas.height // 2 + self.bobbing_offset) + + if abs(self.bobbing_offset) > self.bobbing_max: + self.is_bobbing_up = not self.is_bobbing_up + + draw_black_background(ctx) + #self.stars.render(ctx, timestamp) + self.dialogue_manager.render(ctx, timestamp) + + self.starsystem.render(ctx, speed=0.3, scale=70) + player.render(ctx, timestamp) + if window.controls.click: + self.dialogue_manager.next() + window.audio_handler.play_music_thematic() + + if self.dialogue_manager.done: + self.finalize_scene() + + def finalize_scene(self): + window.audio_handler.play_music_thematic(pause_it=True) + window.audio_handler.play_music_main() + self.scene_manager.activate_scene(ORBITING_PLANETS_SCENE) + +# -------------------- +# final \ credits scene +# -------------------- + +class FinalScene(Scene): + """Scene for the final credits.""" + def __init__(self, name: str, scene_manager: SceneManager): + super().__init__(name, scene_manager) + # Sparse stars for space backdrop + self.stars = StarSystem( + num_stars=200, + radius_min=1, + radius_max=2, + pulse_freq_min=10, + pulse_freq_max=50, + ) + # Rotating Earth spritesheet + self.earth_sprite = window.get_sprite("earth") + self.earth_frame = 0 + self.earth_frame_duration = 200 + self.earth_last_frame_time = 0 + self.fill_color = "#00FF00" + + # Moon sprite for lunar surface + try: + self.moon_sprite = window.get_sprite("moon") + except Exception: + self.moon_sprite = None + + self.credits = Credits(window.credits, self.fill_color) + + def _draw_earth(self, ctx, timestamp): + # Advance frame based on time + if self.earth_sprite and self.earth_sprite.is_loaded: + if self.earth_last_frame_time == 0: + self.earth_last_frame_time = timestamp + if timestamp - self.earth_last_frame_time >= self.earth_frame_duration: + self.earth_frame = (self.earth_frame + 1) % max(1, self.earth_sprite.num_frames) + self.earth_last_frame_time = timestamp + + frame_size = self.earth_sprite.frame_size if self.earth_sprite.num_frames > 1 else self.earth_sprite.height + sx = (self.earth_frame % max(1, self.earth_sprite.num_frames)) * frame_size + sy = 0 + + # Position Earth in upper-right, smaller size like the reference image + target_size = int(min(window.canvas.width, window.canvas.height) * 0.15) + dw = dh = target_size + dx = window.canvas.width * 0.65 # Right side of screen + dy = window.canvas.height * 0.15 # Upper portion + + ctx.drawImage( + self.earth_sprite.image, + sx, sy, frame_size, frame_size, + dx, dy, dw, dh + ) + + def _draw_lunar_surface(self, ctx): + # Draw lunar surface with the top portion visible, like looking across the lunar terrain + if self.moon_sprite and getattr(self.moon_sprite, "is_loaded", False): + # Position moon sprite so its upper portion is visible as foreground terrain + surface_height = window.canvas.height * 0.5 + + # Scale to fill screen width + scale = (window.canvas.width / self.moon_sprite.width) + sprite_scaled_height = self.moon_sprite.height * scale + + # Position so the moon extends below the screen, showing only the top portion + dy = window.canvas.height - surface_height + + ctx.drawImage( + self.moon_sprite.image, + 0, 0, self.moon_sprite.width, self.moon_sprite.height, + window.canvas.width - (window.canvas.width * scale)/1.25, dy, # target left, top + window.canvas.width * scale, sprite_scaled_height # target width, height + ) + + def render(self, ctx, timestamp): + window.audio_handler.play_music_main(pause_it=True) + window.audio_handler.play_music_thematic() + + draw_black_background(ctx) + + # Sparse stars + self.stars.render(ctx, timestamp) + + # Update and render scrolling credits before lunar surface + self.credits.update(timestamp) + self.credits.render(ctx, timestamp) + + # Draw lunar surface after credits so it appears as foreground + self._draw_lunar_surface(ctx) + + # Draw Earth in the distance + self._draw_earth(ctx, timestamp) + + if self.credits.finished: + ctx.font = f"{max(12, int(min(window.canvas.width, window.canvas.height)) * 0.025)}px Courier New" + instruction = "Click anywhere to return to solar system" + ctx.fillText(instruction, window.canvas.width * 0.05, window.canvas.height * 0.25) + ctx.restore() + + # Handle click to go back to orbiting planets scene + if window.controls.click: + # Reset all planet completions so we don't immediately return to final scene + orbiting_scene = next(scene for scene in self.scene_manager._scenes if scene.name == ORBITING_PLANETS_SCENE) + for planet in orbiting_scene.solar_sys.planets: + planet.complete = False + log.debug("Reset all planet completions when returning from final scene") + self.scene_manager.activate_scene(ORBITING_PLANETS_SCENE) + +# -------------------- +# create scene manager +# -------------------- + +def create_scene_manager() -> SceneManager: + """ + Create all the scenes and add them to a scene manager that can be used to switch between them The object + instance returned by this is used by the main game loop in game.py to check which scene is active when a + frame is drawn and that scene's render method is called. Only one scene listed in the scene manager is + active at a time, though scenes may have their own subscenes, such as textboxes that they render as part of + their routine. + """ + manager = SceneManager() + planet_scene_state = PlanetState(0, window.canvas.height, 120.0, x=0, y=window.canvas.height // 2) + solar_system = SolarSystem([window.canvas.width, window.canvas.height], planet_scene_state=planet_scene_state) + orbiting_planets_scene = OrbitingPlanetsScene(ORBITING_PLANETS_SCENE, manager, solar_system) + start_scene = StartScene(START_SCENE, manager) + manager.add_scene(start_scene) + manager.add_scene(orbiting_planets_scene) + # Final victory scene (activated when all planets complete) + final_scene = FinalScene(FINAL_SCENE, manager) + manager.add_scene(final_scene) + + for planet in solar_system.planets: + big_planet_scene = PlanetScene(f"{planet.name}-planet-scene", manager, planet) + manager.add_scene(big_planet_scene) + + manager.activate_scene(START_SCENE) # initial scene + return manager diff --git a/cool-cacti/static/scripts/solar_system.py b/cool-cacti/static/scripts/solar_system.py new file mode 100644 index 00000000..e4ef03d6 --- /dev/null +++ b/cool-cacti/static/scripts/solar_system.py @@ -0,0 +1,146 @@ +import math + +from common import PlanetState, Position +from scene_classes import SceneObject +from spacemass import SpaceMass +from window import window + +GRAVI_CONST = 0.67 + +from consolelogger import getLogger + +log = getLogger(__name__) + + +class SolarSystem(SceneObject): + def __init__(self, screen_size=[512, 512], *, planet_scene_state: PlanetState): + super().__init__() + + # Sun position (center of screen) + self.sun_pos: Position = Position(screen_size[0] // 2, screen_size[1] // 2) + + # Sun + self.sun = SpaceMass(window.get_sprite("sun"), PlanetState(1000.0, 120.0, 0.0), planet_scene_state) + self.sun.set_position(self.sun_pos) + + # Inner planets + self.mercury = SpaceMass(window.get_sprite("mercury"), PlanetState(3.3, 10, 2.5), planet_scene_state) + self.venus = SpaceMass(window.get_sprite("venus"), PlanetState(48.7, 14, 2.0), planet_scene_state) + self.earth = SpaceMass(window.get_sprite("earth"), PlanetState(59.7, 16, 1.8), planet_scene_state) + self.mars = SpaceMass(window.get_sprite("mars"), PlanetState(6.4, 12, 1.5), planet_scene_state) + + # Outer planets + self.jupiter = SpaceMass(window.get_sprite("jupiter"), PlanetState(1898.0, 64.0, 1.0), planet_scene_state) + self.saturn = SpaceMass(window.get_sprite("saturn"), PlanetState(568.0, 46.0, 0.8), planet_scene_state) + self.uranus = SpaceMass(window.get_sprite("uranus"), PlanetState(86.8, 36.0, 0.6), planet_scene_state) + self.neptune = SpaceMass(window.get_sprite("neptune"), PlanetState(102.0, 15.0, 0.4), planet_scene_state) + + self.planets = [ + self.mercury, + self.venus, + self.earth, + self.mars, + self.jupiter, + self.saturn, + self.uranus, + self.neptune, + ] + + # Initial positions (distance from sun in pixels) + self.planet_distances = [110, 140, 160, 200, 270, 350, 420, 470] + self.planet_angles: list[float] = [20, 220, 100, 45, 0, 155, 270, 15] + + # Initialize planet positions + for i, planet in enumerate(self.planets): + angle_rad = math.radians(self.planet_angles[i]) + x = self.sun_pos.x + self.planet_distances[i] * math.cos(angle_rad) + y = self.sun_pos.y + self.planet_distances[i] * math.sin(angle_rad) + planet.set_position(Position(x, y)) + planet.complete = False + + def update(self): + self.update_orbits(0.20) + + def get_planet(self, planet_name: str) -> SpaceMass | None: + for planet in self.planets: + if planet.name == planet_name: + return planet + + def update_orbits(self, dt: float): + """Update planet positions using simple circular orbits""" + for i, planet in enumerate(self.planets): + angular_velocity = planet.state.initial_velocity * 0.01 + + # Update angle + self.planet_angles[i] += angular_velocity * dt * 60 # Scale for 60 FPS + + # Keep angle in range [0, 360) + self.planet_angles[i] = self.planet_angles[i] % 360 + + # Calculate new position using circular motion + angle_rad = math.radians(self.planet_angles[i]) + x = self.sun_pos.x + self.planet_distances[i] * math.cos(angle_rad) + y = self.sun_pos.y + self.planet_distances[i] * math.sin(angle_rad) + + # Update position + self.planets[i].set_position(Position(x, y)) + + def render(self, ctx, timestamp): + """Render the entire solar system""" + # Render sun at center + self.sun.render(ctx, timestamp) + + # Render all planets + highlighted_planet = None + for planet in self.planets: + if planet.highlighted: + highlighted_planet = planet + continue + planet.render(ctx, timestamp) + + # If a planet is highlighted, draw it last, so its text label is in front of other planets + if highlighted_planet: + highlighted_planet.render(ctx, timestamp) + + super().render(ctx, timestamp) + + # I Couldn't get this to work 〒__〒 + def calculateGForce(self, planet_index: int) -> float: + """Calculate gravitational force between the sun and a planet""" + # Get planet position + planet_pos = self.planets[planet_index].get_position() + planet = self.planets[planet_index] + + # Calculate distance between sun and planet + distance = planet_pos.distance(self.sun_pos) + + # Prevent division by zero + if distance == 0: + return 0 + + # F = G * m1 * m2 / r^2 + force = GRAVI_CONST * self.sun.state.mass * planet.state.mass / (distance * distance) + + return force + + def get_object_at_position(self, pos: Position) -> SpaceMass | None: + """Get the space object at the specified position, excluding the sun. + + Arguments: + pos (Position): The position to check. + + Returns: + The space object at the position if found, otherwise None. + """ + closest_planet = None + closest_distance = float("inf") + for planet in self.planets: + rect = planet.get_bounding_box() + if rect.left <= pos.x <= rect.right and rect.top <= pos.y <= rect.bottom: + # Calculate distance from click point to planet center + planet_center = Position(rect.left + rect.width / 2, rect.top + rect.height / 2) + distance = planet_center.distance(pos) + if distance < closest_distance: + closest_distance = distance + closest_planet = planet + return closest_planet diff --git a/cool-cacti/static/scripts/spacemass.py b/cool-cacti/static/scripts/spacemass.py new file mode 100644 index 00000000..1203eaf5 --- /dev/null +++ b/cool-cacti/static/scripts/spacemass.py @@ -0,0 +1,111 @@ +from common import PlanetState, Rect +from consolelogger import getLogger +from scene_classes import SceneObject +from window import SpriteSheet + +log = getLogger(__name__) + + +class SpaceMass(SceneObject): + def __init__(self, spritesheet: SpriteSheet, orbit_state: PlanetState, planet_scene_state: PlanetState) -> None: + super().__init__() + + self.spritesheet = spritesheet + self.name = spritesheet.key + + self.state: PlanetState = orbit_state + self._saved_state: PlanetState = planet_scene_state + + self.x = self.state.x + self.y = self.state.y + + self.current_frame = 0 + self.animation_timer = 0 + self.frame_delay = 135 # (approximately 6 FPS) + + self.highlighted = False + self.complete = False + + # State management + + def get_bounding_box(self) -> Rect: + # Scale sprite based on radius + sprite_size = int(self.state.radius) / 80.0 + frame_size = self.spritesheet.height + + left = self.x - frame_size // 2 * sprite_size + top = self.y - frame_size // 2 * sprite_size + size = frame_size * sprite_size + + return Rect(left, top, size, size) + + def render(self, ctx, timestamp): + # Update animation timing + if timestamp - self.animation_timer >= self.frame_delay: + self.current_frame = (self.current_frame + 1) % self.spritesheet.num_frames + self.animation_timer = timestamp + + bounds = self.get_bounding_box() + frame_position = self.spritesheet.get_frame_position(self.current_frame) + ctx.drawImage( + self.spritesheet.image, + frame_position.x, + frame_position.y, + self.spritesheet.frame_size, + self.spritesheet.frame_size, + bounds.left, + bounds.top, + bounds.width, + bounds.height, + ) + if self.complete: + highlight = "#00ff00" + else: + highlight = "#ffff00" # yellow highlight + + offset = 5 + # Draw highlight effect if planet is highlighted + if self.highlighted: + if self.complete: + # log.debug("planet complete") + highlight = "#00ff00" + else: + # log.debug("planet not complete") + highlight = "#ffff00" # yellow highlight + ctx.save() + ctx.strokeStyle = highlight + ctx.shadowColor = highlight + ctx.lineWidth = 3 + ctx.shadowBlur = 10 + + # Draw a circle around the planet + center_x = bounds.left + bounds.width / 2 + center_y = bounds.top + bounds.height / 2 + radius = bounds.width / 2 + offset # Slightly larger than the planet + + ctx.beginPath() + ctx.arc(center_x, center_y, radius, 0, 2 * 3.14159) + ctx.stroke() + + # draw planet name labels when hovering over + ctx.shadowBlur = 0 + ctx.beginPath() + ctx.moveTo(center_x, center_y - radius) + ctx.lineTo(center_x + 10, center_y - radius - 10) + ctx.font = "14px Courier New" + ctx.fillStyle = highlight + text_width = ctx.measureText(self.name.capitalize()).width + ctx.lineTo(center_x + 15 + text_width, center_y - radius - 10) + ctx.fillText(self.name.capitalize(), center_x + 15, center_y - radius - 15) + ctx.stroke() + + ctx.restore() + + super().render(ctx, timestamp) + + def switch_view(self) -> None: + """Configure planet view""" + self.state.x, self.state.y = self.x, self.y + self.state, self._saved_state = self._saved_state, self.state + self.x, self.y = self.state.x, self.state.y + self.highlighted = False # Clear highlighting when switching views diff --git a/cool-cacti/static/scripts/sprites.py b/cool-cacti/static/scripts/sprites.py new file mode 100644 index 00000000..4e1a2f17 --- /dev/null +++ b/cool-cacti/static/scripts/sprites.py @@ -0,0 +1,53 @@ +from common import Position +from consolelogger import getLogger +from js import window as js_window # type: ignore[attr-defined] + +log = getLogger(__name__) + + +class SpriteSheet: + """Wrapper for individual sprites with enhanced functionality.""" + + def __init__(self, key: str): + self.key = key.lower() + # Get raw image from js_window.sprites directly to avoid circular import + self.image = js_window.sprites[self.key] + + @property + def height(self): + """Height of the sprite image.""" + return self.image.height + + @property + def width(self): + """Width of the sprite image.""" + return self.image.width + + @property + def frame_size(self): + """Size of each frame (assuming square frames).""" + return self.height + + @property + def is_loaded(self): + return self.height > 0 and self.width > 0 + + @property + def num_frames(self): + """Number of frames in the spritesheet.""" + if not self.is_loaded: + log.warning("Frame size is zero for sprite '%s'", self.key) + return 1 + return self.width // self.frame_size + + def get_frame_position(self, frame: int) -> Position: + """Get the position of a specific frame in the spritesheet with overflow handling.""" + if self.num_frames == 0: + return Position(0, 0) + frame_index = frame % self.num_frames + x = frame_index * self.frame_size + return Position(x, 0) + + # Delegate other attributes to the underlying image + def __getattr__(self, name): + return getattr(self.image, name) diff --git a/cool-cacti/static/scripts/stars.py b/cool-cacti/static/scripts/stars.py new file mode 100644 index 00000000..d87f1954 --- /dev/null +++ b/cool-cacti/static/scripts/stars.py @@ -0,0 +1,206 @@ +import math +import random + +from scene_classes import SceneObject +from window import window +from js import document #type: ignore +loadingLabel = document.getElementById("loadingLabel") +container = document.getElementById("canvasContainer") +width, height = container.clientWidth, container.clientHeight + + +class Star: + def __init__(self, radius, x, y, pulse_freq, color, shade=0, fade_in=True) -> None: + self.radius = radius + self.frame_delay = 135 + self.pulse_freq = pulse_freq # renaming of animation timer + self.x = x + self.y = y + self.shade = shade # defines r,g, and b + self.alpha = 1 + self.color = color + self.fade_in = fade_in + self.animation_timer = 0 + self.glisten = False + + def render(self, ctx, timestamp, num_stars) -> None: + # pulse + if timestamp - self.animation_timer >= self.pulse_freq: + self.animation_timer = timestamp + if self.fade_in: + self.shade += 1 + else: + self.shade -= 1 + + if self.shade > 255 or self.shade < 1: + self.fade_in = not self.fade_in + + self.render_color = self.rgba_to_str(*self.color, self.shade / 255.0) + + # draw star + ctx.fillStyle = self.render_color + ctx.beginPath() + ctx.ellipse(self.x, self.y, self.radius, self.radius, 0, 0, 2 * math.pi) + ctx.fill() + + chance_glisten = random.randint(1, num_stars * 4) + if chance_glisten == num_stars: + self.glisten = True + # glisten + if self.shade > 240 and self.glisten: + glisten_line_col = self.render_color + + ctx.strokeStyle = glisten_line_col # or any visible color + ctx.lineWidth = 2 # thick enough to see + ctx.beginPath() + ctx.moveTo(self.x, self.y - self.radius - 5) # start drawing curve a bit lower than star pos + ctx.bezierCurveTo( + self.x - self.radius, + self.y - self.radius, + self.x + self.radius, + self.y + self.radius, + self.x, + self.y + self.radius + 5, + ) + ctx.stroke() + else: + self.glisten = False + + def rgba_to_str(self, r: int, g: int, b: int, a: int) -> str: + return f"rgba({r}, {g}, {b}, {a})" + + +class StarSystem(SceneObject): + + WHITE = (255, 255, 255) + YELLOW = (255, 223, 79) + BLUE = (100, 149, 237) + RED = (255, 99, 71) + PURPLE = (186, 85, 211) + + COLORS = [WHITE, YELLOW, BLUE, RED, PURPLE] + # chance for each color to be used, most will be white but other colors can also occur + WEIGHTS = [100, 15, 15, 15, 3] + + def __init__(self, num_stars, radius_min, radius_max, pulse_freq_min, pulse_freq_max, num_frames=50): + super().__init__() + + self.num_frames = num_frames + self.radius_min = radius_min + self.radius_max = radius_max + self.pulse_freq_min = pulse_freq_min + self.pulse_freq_max = pulse_freq_max + self.frame_delay = 135 + self.num_stars = num_stars + self.animation_timer = 0 + self.stars: list[Star] = [] # will be filled with star object instances + + for _ in range(num_stars): + self.stars.append(self.create_star("random", "random")) + + def random_color(self) -> tuple: + return random.choices(StarSystem.COLORS, weights=StarSystem.WEIGHTS)[0] + + def render(self, ctx, timestamp) -> None: + """Render every star.""" + for star in self.stars: + star.render(ctx, timestamp, self.num_stars) + + if len(self.stars) == 0: + raise ValueError("There are no stars! Did you populate?") + + super().render(ctx, timestamp) + + def create_star(self, x="random", y="random"): + if x == "random": + x = random.randint(0, window.canvas.width) + if y == "random": + y = random.randint(0, window.canvas.height) + + pulse_freq = random.randint(self.pulse_freq_min, self.pulse_freq_max) + radius = random.randint(self.radius_min, self.radius_max) + shade = random.randint(0, 255) + fade_in = random.choice([True, False]) + return Star(radius, x, y, pulse_freq, self.random_color(), shade=shade, fade_in=fade_in) + + def star_shift(self, current_time, shift_time): + if current_time - self.animation_timer >= shift_time: + self.animation_timer = current_time + replacement_stars = [] + for index, star in enumerate(self.stars): + star.x += 1 + if abs(star.x) > window.canvas.width or abs(star.y) > window.canvas.height: + self.stars.pop(index) + replacement_star = self.create_star(0, "random") + replacement_stars.append(replacement_star) + + for star in replacement_stars: + self.stars.append(star) + + def star_scale(self, current_time, shift_time): + if current_time - self.animation_timer >= shift_time: + self.animation_timer = current_time + +class Star3d(Star): + def __init__(self, radius, x, y, z, pulse_freq, shade=0, fade_in=True): + super().__init__(radius, x, y, pulse_freq, shade, fade_in) + self.z = z + + def update(self, speed, max_depth): + """Move the star closer by reducing z.""" + self.z -= speed + if self.z <= 0: # if it passes the camera, recycle + self.z = max_depth + self.x = random.uniform(-1, 1) + self.y = random.uniform(-1, 1) + + def project(self, cx, cy, max_radius, scale): + """Project 3D coords to 2D screen coords.""" + screen_x = cx + (self.x / self.z) * scale + screen_y = cy + (self.y / self.z) * scale + size = max(1, (1 / self.z) * scale * 0.5) # star grows as z decreases + if size > max_radius: + size = max_radius + + return screen_x, screen_y, size + + +class StarSystem3d: + def __init__(self, num_stars, max_depth=5, max_radius = 20): + self.num_stars = num_stars + self.max_depth = max_depth + self.max_radius = max_radius + self.stars: list[Star3d] = [] + for _ in range(num_stars): + self.stars.append(self.create_star()) + + def create_star(self): + x = random.randint(-width//2, width//2) + y = random.randint(-height//2, height//2) + z = random.uniform(20, self.max_depth) + pulse_freq = random.randint(30, 80) # tweak as desired + radius = 1 + shade = random.randint(150, 255) + fade_in = True + return Star3d(radius, x, y, z, pulse_freq, shade=shade, fade_in=fade_in) + + def render(self, ctx, speed=0.4, scale=300): + cx = window.canvas.width / 2 + cy = window.canvas.height / 2 + + for index, star in enumerate(self.stars): + star.update(speed, self.max_depth) + sx, sy, size = star.project(cx, cy, self.max_radius, scale) + + # If star leaves screen, recycle it + if sx < 0 or sx > window.canvas.width or sy < 0 or sy > window.canvas.height: + self.stars.pop(index) + self.stars.append(self.create_star()) + + # Draw star (brightens as it approaches) + shade = int(255 * (1 - star.z / self.max_depth)) + ctx.fillStyle = f"rgba({shade}, {shade}, {shade}, 1)" + ctx.beginPath() + ctx.ellipse(sx, sy, size, size, 0, 0, 2 * math.pi) + ctx.fill() + diff --git a/cool-cacti/static/scripts/window.py b/cool-cacti/static/scripts/window.py new file mode 100644 index 00000000..0ec61255 --- /dev/null +++ b/cool-cacti/static/scripts/window.py @@ -0,0 +1,145 @@ +"""Typed wrapper over window and stored objects + +Use instead of importing directly from js + +Usage +------- +from window import window +""" + +from typing import TYPE_CHECKING, Any + +from js import window # type: ignore[attr-defined] + +if TYPE_CHECKING: + from asteroid import AsteroidAttack + from audio import AudioHandler + from common import HTMLImageElement + from controls import GameControls + from debris import DebrisSystem + from player import Player, Scanner + +from common import SpriteSheet, AsteroidData, PlanetData + + +class SpritesInterface: + """Interface for accessing window.sprites with SpriteSheet wrapping.""" + + def __init__(self, js_window: Any) -> None: + self._window = js_window + + def __getitem__(self, key: str) -> "SpriteSheet": + """Access sprites as SpriteSheet objects.""" + return SpriteSheet(key, self._window.sprites[key]) + + +class WindowInterface: + """Typed interface for accessing window object properties with dynamic fallback. + + Sprites, AudioHandler, and Planets are internally managed and changes to them are not + reflected in the underlying JS objects. Other properties are accessed directly from the JS + window object. + """ + + def __init__(self, js_window: Any) -> None: + self._window = js_window + self._sprites = SpritesInterface(js_window) # Wrap sprites in SpritesInterface + self.DEBUG_DRAW_HITBOXES: bool = getattr(js_window, "DEBUG_DRAW_HITBOXES", False) + self.audio_handler = js_window.audio_handler + self._planet_dataclasses: dict[str, PlanetData] = {} + self._serialize_planets() + + def _serialize_planets(self) -> None: + """Convert raw planet data from JS to PlanetData dataclass instances.""" + raw_planets = getattr(self._window, 'planets', []) + self._planet_dataclasses = {} + + for planet_dict in raw_planets: + planet = PlanetData.from_dict(planet_dict) + self._planet_dataclasses[planet.name] = planet + + @property + def audio_handler(self) -> "AudioHandler": + return self._window.audio_handler + + @audio_handler.setter + def audio_handler(self, value: "AudioHandler") -> None: + self._window.audio_handler = value + + @property + def controls(self) -> "GameControls": + return self._window.controls + + @controls.setter + def controls(self, value: "GameControls") -> None: + self._window.controls = value + + @property + def player(self) -> "Player": + return self._window.player + + @player.setter + def player(self, value: "Player") -> None: + self._window.player = value + + @property + def asteroids(self) -> "AsteroidAttack": + return self._window.asteroids + + @asteroids.setter + def asteroids(self, value: "AsteroidAttack") -> None: + self._window.asteroids = value + + @property + def debris(self) -> "DebrisSystem": + return self._window.debris + + @debris.setter + def debris(self, value: "DebrisSystem") -> None: + self._window.debris = value + + @property + def scanner(self) -> "Scanner": + return self._window.scanner + + @scanner.setter + def scanner(self, value: "Scanner") -> None: + self._window.scanner = value + + @property + def planets(self) -> dict[str, PlanetData]: + return self._planet_dataclasses + + @planets.setter + def planets(self, value: dict[str, PlanetData]) -> None: + self._planet_dataclasses = value + + def get_planet(self, name: str) -> PlanetData | None: + return self._planet_dataclasses.get(name.title()) + + @property + def sprites(self) -> SpritesInterface: + """Access sprites as SpriteSheet objects.""" + return self._sprites + + def get_sprite(self, key: str) -> SpriteSheet: + """Get a sprite by key - more intuitive than sprites[key].""" + return self._sprites[key] + + def __getattr__(self, name: str) -> Any: + """Dynamic fallback for accessing any window property.""" + return getattr(self._window, name) + + def __setattr__(self, name: str, value: Any) -> None: + """Dynamic fallback for setting any window property.""" + if name.startswith("_"): + super().__setattr__(name, value) + else: + setattr(self._window, name, value) + + +# Create typed interface instance +window_interface = WindowInterface(window) + +# Expose for backward compatibility +window = window_interface diff --git a/cool-cacti/static/sprites/Explosion Animation.png b/cool-cacti/static/sprites/Explosion Animation.png new file mode 100644 index 00000000..988560af Binary files /dev/null and b/cool-cacti/static/sprites/Explosion Animation.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1015757868.png b/cool-cacti/static/sprites/asteroid sprites/1015757868.png new file mode 100644 index 00000000..04d2fd7e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1015757868.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1024695167.png b/cool-cacti/static/sprites/asteroid sprites/1024695167.png new file mode 100644 index 00000000..9d673523 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1024695167.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1057991680.png b/cool-cacti/static/sprites/asteroid sprites/1057991680.png new file mode 100644 index 00000000..a7aa6d6d Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1057991680.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1069494109.png b/cool-cacti/static/sprites/asteroid sprites/1069494109.png new file mode 100644 index 00000000..65c948f2 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1069494109.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/109305397.png b/cool-cacti/static/sprites/asteroid sprites/109305397.png new file mode 100644 index 00000000..38a214c7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/109305397.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1145028418.png b/cool-cacti/static/sprites/asteroid sprites/1145028418.png new file mode 100644 index 00000000..b51dbe48 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1145028418.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1145439098.png b/cool-cacti/static/sprites/asteroid sprites/1145439098.png new file mode 100644 index 00000000..6d7e7ffd Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1145439098.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1150396768.png b/cool-cacti/static/sprites/asteroid sprites/1150396768.png new file mode 100644 index 00000000..f2b76c40 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1150396768.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1153566565.png b/cool-cacti/static/sprites/asteroid sprites/1153566565.png new file mode 100644 index 00000000..f45e07cd Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1153566565.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/118564947.png b/cool-cacti/static/sprites/asteroid sprites/118564947.png new file mode 100644 index 00000000..b5f9573b Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/118564947.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/123618070.png b/cool-cacti/static/sprites/asteroid sprites/123618070.png new file mode 100644 index 00000000..85330978 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/123618070.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/124465317.png b/cool-cacti/static/sprites/asteroid sprites/124465317.png new file mode 100644 index 00000000..d2b60b61 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/124465317.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1246437094.png b/cool-cacti/static/sprites/asteroid sprites/1246437094.png new file mode 100644 index 00000000..fd47cf88 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1246437094.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1329742594.png b/cool-cacti/static/sprites/asteroid sprites/1329742594.png new file mode 100644 index 00000000..854404d0 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1329742594.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1424204855.png b/cool-cacti/static/sprites/asteroid sprites/1424204855.png new file mode 100644 index 00000000..1725e7c1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1424204855.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1446250313.png b/cool-cacti/static/sprites/asteroid sprites/1446250313.png new file mode 100644 index 00000000..bade0eb1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1446250313.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1468273590.png b/cool-cacti/static/sprites/asteroid sprites/1468273590.png new file mode 100644 index 00000000..adb8b0ce Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1468273590.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1488973772.png b/cool-cacti/static/sprites/asteroid sprites/1488973772.png new file mode 100644 index 00000000..97aed693 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1488973772.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/150029986.png b/cool-cacti/static/sprites/asteroid sprites/150029986.png new file mode 100644 index 00000000..933676bd Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/150029986.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1691535978.png b/cool-cacti/static/sprites/asteroid sprites/1691535978.png new file mode 100644 index 00000000..15e43511 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1691535978.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1725661796.png b/cool-cacti/static/sprites/asteroid sprites/1725661796.png new file mode 100644 index 00000000..48c282fb Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1725661796.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1739499255.png b/cool-cacti/static/sprites/asteroid sprites/1739499255.png new file mode 100644 index 00000000..20a087c1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1739499255.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/17996437.png b/cool-cacti/static/sprites/asteroid sprites/17996437.png new file mode 100644 index 00000000..0984f7e6 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/17996437.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1888508484.png b/cool-cacti/static/sprites/asteroid sprites/1888508484.png new file mode 100644 index 00000000..1d1b8c83 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1888508484.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1926091214.png b/cool-cacti/static/sprites/asteroid sprites/1926091214.png new file mode 100644 index 00000000..ec5d96ee Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1926091214.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/1952977097.png b/cool-cacti/static/sprites/asteroid sprites/1952977097.png new file mode 100644 index 00000000..a877ed46 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/1952977097.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/202411860.png b/cool-cacti/static/sprites/asteroid sprites/202411860.png new file mode 100644 index 00000000..3a9563c8 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/202411860.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2075950734.png b/cool-cacti/static/sprites/asteroid sprites/2075950734.png new file mode 100644 index 00000000..2dce77c3 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2075950734.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2079996232.png b/cool-cacti/static/sprites/asteroid sprites/2079996232.png new file mode 100644 index 00000000..0c3051ee Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2079996232.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2106171138.png b/cool-cacti/static/sprites/asteroid sprites/2106171138.png new file mode 100644 index 00000000..2c608da2 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2106171138.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2118383358.png b/cool-cacti/static/sprites/asteroid sprites/2118383358.png new file mode 100644 index 00000000..b9b80f1a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2118383358.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2183076256.png b/cool-cacti/static/sprites/asteroid sprites/2183076256.png new file mode 100644 index 00000000..d46e66e5 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2183076256.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2195883195.png b/cool-cacti/static/sprites/asteroid sprites/2195883195.png new file mode 100644 index 00000000..607913da Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2195883195.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/224063959.png b/cool-cacti/static/sprites/asteroid sprites/224063959.png new file mode 100644 index 00000000..353b7e43 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/224063959.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2241089568.png b/cool-cacti/static/sprites/asteroid sprites/2241089568.png new file mode 100644 index 00000000..2096ca4e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2241089568.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2274699037.png b/cool-cacti/static/sprites/asteroid sprites/2274699037.png new file mode 100644 index 00000000..98f9c1b9 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2274699037.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2299188070.png b/cool-cacti/static/sprites/asteroid sprites/2299188070.png new file mode 100644 index 00000000..85330978 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2299188070.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/233236783.png b/cool-cacti/static/sprites/asteroid sprites/233236783.png new file mode 100644 index 00000000..5356e197 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/233236783.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2363091580.png b/cool-cacti/static/sprites/asteroid sprites/2363091580.png new file mode 100644 index 00000000..1822803a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2363091580.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2384002807.png b/cool-cacti/static/sprites/asteroid sprites/2384002807.png new file mode 100644 index 00000000..ef2f3cc3 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2384002807.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/238590564.png b/cool-cacti/static/sprites/asteroid sprites/238590564.png new file mode 100644 index 00000000..8e3c85ce Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/238590564.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2445912905.png b/cool-cacti/static/sprites/asteroid sprites/2445912905.png new file mode 100644 index 00000000..4075361e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2445912905.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2450256434.png b/cool-cacti/static/sprites/asteroid sprites/2450256434.png new file mode 100644 index 00000000..509dd835 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2450256434.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2460470952.png b/cool-cacti/static/sprites/asteroid sprites/2460470952.png new file mode 100644 index 00000000..b91ee2ef Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2460470952.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2492836461.png b/cool-cacti/static/sprites/asteroid sprites/2492836461.png new file mode 100644 index 00000000..6363e944 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2492836461.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2516703929.png b/cool-cacti/static/sprites/asteroid sprites/2516703929.png new file mode 100644 index 00000000..18286a86 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2516703929.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2541679965.png b/cool-cacti/static/sprites/asteroid sprites/2541679965.png new file mode 100644 index 00000000..fc5d3958 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2541679965.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2607102593.png b/cool-cacti/static/sprites/asteroid sprites/2607102593.png new file mode 100644 index 00000000..23531159 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2607102593.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2633938101.png b/cool-cacti/static/sprites/asteroid sprites/2633938101.png new file mode 100644 index 00000000..dd0f46d1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2633938101.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/271782734.png b/cool-cacti/static/sprites/asteroid sprites/271782734.png new file mode 100644 index 00000000..2dce77c3 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/271782734.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2745850591.png b/cool-cacti/static/sprites/asteroid sprites/2745850591.png new file mode 100644 index 00000000..848f32d7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2745850591.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2764849719.png b/cool-cacti/static/sprites/asteroid sprites/2764849719.png new file mode 100644 index 00000000..ccac8517 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2764849719.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2785063265.png b/cool-cacti/static/sprites/asteroid sprites/2785063265.png new file mode 100644 index 00000000..e4221e8a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2785063265.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2789899429.png b/cool-cacti/static/sprites/asteroid sprites/2789899429.png new file mode 100644 index 00000000..41b4e6da Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2789899429.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2791378748.png b/cool-cacti/static/sprites/asteroid sprites/2791378748.png new file mode 100644 index 00000000..0fb2c6b9 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2791378748.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2844830477.png b/cool-cacti/static/sprites/asteroid sprites/2844830477.png new file mode 100644 index 00000000..7862dc07 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2844830477.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2896295212.png b/cool-cacti/static/sprites/asteroid sprites/2896295212.png new file mode 100644 index 00000000..b0beab28 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2896295212.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2924163492.png b/cool-cacti/static/sprites/asteroid sprites/2924163492.png new file mode 100644 index 00000000..ea65e49c Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2924163492.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/2925291501.png b/cool-cacti/static/sprites/asteroid sprites/2925291501.png new file mode 100644 index 00000000..f1f7d4ba Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/2925291501.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3077723619.png b/cool-cacti/static/sprites/asteroid sprites/3077723619.png new file mode 100644 index 00000000..a38560c2 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3077723619.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3130864082.png b/cool-cacti/static/sprites/asteroid sprites/3130864082.png new file mode 100644 index 00000000..a3bc860e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3130864082.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3167532641.png b/cool-cacti/static/sprites/asteroid sprites/3167532641.png new file mode 100644 index 00000000..104533b1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3167532641.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3168216978.png b/cool-cacti/static/sprites/asteroid sprites/3168216978.png new file mode 100644 index 00000000..15e43511 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3168216978.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3170379696.png b/cool-cacti/static/sprites/asteroid sprites/3170379696.png new file mode 100644 index 00000000..36c05e75 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3170379696.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3193832944.png b/cool-cacti/static/sprites/asteroid sprites/3193832944.png new file mode 100644 index 00000000..945ca9c2 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3193832944.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3288721588.png b/cool-cacti/static/sprites/asteroid sprites/3288721588.png new file mode 100644 index 00000000..17e90e25 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3288721588.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3297273057.png b/cool-cacti/static/sprites/asteroid sprites/3297273057.png new file mode 100644 index 00000000..c60001b7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3297273057.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3396683436.png b/cool-cacti/static/sprites/asteroid sprites/3396683436.png new file mode 100644 index 00000000..02200593 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3396683436.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3398415923.png b/cool-cacti/static/sprites/asteroid sprites/3398415923.png new file mode 100644 index 00000000..de19da49 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3398415923.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3417753021.png b/cool-cacti/static/sprites/asteroid sprites/3417753021.png new file mode 100644 index 00000000..0870257b Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3417753021.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3428788332.png b/cool-cacti/static/sprites/asteroid sprites/3428788332.png new file mode 100644 index 00000000..0fa9096a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3428788332.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3482339427.png b/cool-cacti/static/sprites/asteroid sprites/3482339427.png new file mode 100644 index 00000000..c5550123 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3482339427.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3517180902.png b/cool-cacti/static/sprites/asteroid sprites/3517180902.png new file mode 100644 index 00000000..1a5c8a54 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3517180902.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3517671125.png b/cool-cacti/static/sprites/asteroid sprites/3517671125.png new file mode 100644 index 00000000..9d1d70c3 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3517671125.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3535940278.png b/cool-cacti/static/sprites/asteroid sprites/3535940278.png new file mode 100644 index 00000000..e4986f36 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3535940278.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3575891895.png b/cool-cacti/static/sprites/asteroid sprites/3575891895.png new file mode 100644 index 00000000..02ff6698 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3575891895.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3622594717.png b/cool-cacti/static/sprites/asteroid sprites/3622594717.png new file mode 100644 index 00000000..91cfe09a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3622594717.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3634889999.png b/cool-cacti/static/sprites/asteroid sprites/3634889999.png new file mode 100644 index 00000000..45a5225a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3634889999.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3636126819.png b/cool-cacti/static/sprites/asteroid sprites/3636126819.png new file mode 100644 index 00000000..910bbc70 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3636126819.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3642894544.png b/cool-cacti/static/sprites/asteroid sprites/3642894544.png new file mode 100644 index 00000000..c4af9c5e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3642894544.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3700018417.png b/cool-cacti/static/sprites/asteroid sprites/3700018417.png new file mode 100644 index 00000000..6159edec Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3700018417.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3726866554.png b/cool-cacti/static/sprites/asteroid sprites/3726866554.png new file mode 100644 index 00000000..38f58c22 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3726866554.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3736627942.png b/cool-cacti/static/sprites/asteroid sprites/3736627942.png new file mode 100644 index 00000000..74abc7d4 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3736627942.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3763505851.png b/cool-cacti/static/sprites/asteroid sprites/3763505851.png new file mode 100644 index 00000000..206e20f8 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3763505851.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3776567904.png b/cool-cacti/static/sprites/asteroid sprites/3776567904.png new file mode 100644 index 00000000..4ac97c73 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3776567904.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3826558147.png b/cool-cacti/static/sprites/asteroid sprites/3826558147.png new file mode 100644 index 00000000..495fbfa7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3826558147.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/3989211066.png b/cool-cacti/static/sprites/asteroid sprites/3989211066.png new file mode 100644 index 00000000..2dac3126 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/3989211066.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/401461502.png b/cool-cacti/static/sprites/asteroid sprites/401461502.png new file mode 100644 index 00000000..5a0591e6 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/401461502.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/4021916695.png b/cool-cacti/static/sprites/asteroid sprites/4021916695.png new file mode 100644 index 00000000..60dfb81c Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/4021916695.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/402551533.png b/cool-cacti/static/sprites/asteroid sprites/402551533.png new file mode 100644 index 00000000..f04c982c Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/402551533.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/4071461444.png b/cool-cacti/static/sprites/asteroid sprites/4071461444.png new file mode 100644 index 00000000..b31e2c8e Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/4071461444.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/4193216520.png b/cool-cacti/static/sprites/asteroid sprites/4193216520.png new file mode 100644 index 00000000..452eaf11 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/4193216520.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/4247189585.png b/cool-cacti/static/sprites/asteroid sprites/4247189585.png new file mode 100644 index 00000000..4214deac Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/4247189585.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/4279862316.png b/cool-cacti/static/sprites/asteroid sprites/4279862316.png new file mode 100644 index 00000000..a63e21aa Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/4279862316.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/465935841.png b/cool-cacti/static/sprites/asteroid sprites/465935841.png new file mode 100644 index 00000000..8e97fb0a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/465935841.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/483449870.png b/cool-cacti/static/sprites/asteroid sprites/483449870.png new file mode 100644 index 00000000..f51854e9 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/483449870.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/514412427.png b/cool-cacti/static/sprites/asteroid sprites/514412427.png new file mode 100644 index 00000000..c5550123 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/514412427.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/569870057.png b/cool-cacti/static/sprites/asteroid sprites/569870057.png new file mode 100644 index 00000000..c60001b7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/569870057.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/605085139.png b/cool-cacti/static/sprites/asteroid sprites/605085139.png new file mode 100644 index 00000000..56a6410a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/605085139.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/687857303.png b/cool-cacti/static/sprites/asteroid sprites/687857303.png new file mode 100644 index 00000000..d79f5f44 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/687857303.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/767603859.png b/cool-cacti/static/sprites/asteroid sprites/767603859.png new file mode 100644 index 00000000..cd5df681 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/767603859.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/886635296.png b/cool-cacti/static/sprites/asteroid sprites/886635296.png new file mode 100644 index 00000000..2c59703f Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/886635296.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/938145699.png b/cool-cacti/static/sprites/asteroid sprites/938145699.png new file mode 100644 index 00000000..f49cccb9 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/938145699.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/950121403.png b/cool-cacti/static/sprites/asteroid sprites/950121403.png new file mode 100644 index 00000000..99b3d1af Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/950121403.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_00.png b/cool-cacti/static/sprites/asteroid sprites/recycle_00.png new file mode 100644 index 00000000..85ac1bfc Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_00.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_01.png b/cool-cacti/static/sprites/asteroid sprites/recycle_01.png new file mode 100644 index 00000000..db17320f Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_01.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_02.png b/cool-cacti/static/sprites/asteroid sprites/recycle_02.png new file mode 100644 index 00000000..710a1d93 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_02.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_03.png b/cool-cacti/static/sprites/asteroid sprites/recycle_03.png new file mode 100644 index 00000000..d98c1feb Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_03.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_04.png b/cool-cacti/static/sprites/asteroid sprites/recycle_04.png new file mode 100644 index 00000000..1b406fa1 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_04.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_05.png b/cool-cacti/static/sprites/asteroid sprites/recycle_05.png new file mode 100644 index 00000000..1b7e90a5 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_05.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_06.png b/cool-cacti/static/sprites/asteroid sprites/recycle_06.png new file mode 100644 index 00000000..b814a0e9 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_06.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_07.png b/cool-cacti/static/sprites/asteroid sprites/recycle_07.png new file mode 100644 index 00000000..ef3db48f Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_07.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_08.png b/cool-cacti/static/sprites/asteroid sprites/recycle_08.png new file mode 100644 index 00000000..04a15a24 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_08.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_09.png b/cool-cacti/static/sprites/asteroid sprites/recycle_09.png new file mode 100644 index 00000000..d19191c7 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_09.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_10.png b/cool-cacti/static/sprites/asteroid sprites/recycle_10.png new file mode 100644 index 00000000..b2331eac Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_10.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_11.png b/cool-cacti/static/sprites/asteroid sprites/recycle_11.png new file mode 100644 index 00000000..1c1be376 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_11.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_12.png b/cool-cacti/static/sprites/asteroid sprites/recycle_12.png new file mode 100644 index 00000000..9909ab53 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_12.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_13.png b/cool-cacti/static/sprites/asteroid sprites/recycle_13.png new file mode 100644 index 00000000..ffaf2808 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_13.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_14.png b/cool-cacti/static/sprites/asteroid sprites/recycle_14.png new file mode 100644 index 00000000..b0c5190b Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_14.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_15.png b/cool-cacti/static/sprites/asteroid sprites/recycle_15.png new file mode 100644 index 00000000..e2548d44 Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_15.png differ diff --git a/cool-cacti/static/sprites/asteroid sprites/recycle_items.png b/cool-cacti/static/sprites/asteroid sprites/recycle_items.png new file mode 100644 index 00000000..ee9b847a Binary files /dev/null and b/cool-cacti/static/sprites/asteroid sprites/recycle_items.png differ diff --git a/cool-cacti/static/sprites/asteroids.png b/cool-cacti/static/sprites/asteroids.png new file mode 100644 index 00000000..88dccb59 Binary files /dev/null and b/cool-cacti/static/sprites/asteroids.png differ diff --git a/cool-cacti/static/sprites/earth.png b/cool-cacti/static/sprites/earth.png new file mode 100644 index 00000000..3bd1d862 Binary files /dev/null and b/cool-cacti/static/sprites/earth.png differ diff --git a/cool-cacti/static/sprites/earthtest.png b/cool-cacti/static/sprites/earthtest.png new file mode 100644 index 00000000..18fcc8af Binary files /dev/null and b/cool-cacti/static/sprites/earthtest.png differ diff --git a/cool-cacti/static/sprites/health.png b/cool-cacti/static/sprites/health.png new file mode 100644 index 00000000..fbd57f8c Binary files /dev/null and b/cool-cacti/static/sprites/health.png differ diff --git a/cool-cacti/static/sprites/jupiter.png b/cool-cacti/static/sprites/jupiter.png new file mode 100644 index 00000000..feebe2db Binary files /dev/null and b/cool-cacti/static/sprites/jupiter.png differ diff --git a/cool-cacti/static/sprites/mars.png b/cool-cacti/static/sprites/mars.png new file mode 100644 index 00000000..bd0a5068 Binary files /dev/null and b/cool-cacti/static/sprites/mars.png differ diff --git a/cool-cacti/static/sprites/mercury.png b/cool-cacti/static/sprites/mercury.png new file mode 100644 index 00000000..c4c4a2ee Binary files /dev/null and b/cool-cacti/static/sprites/mercury.png differ diff --git a/cool-cacti/static/sprites/moon.png b/cool-cacti/static/sprites/moon.png new file mode 100644 index 00000000..11e7f1db Binary files /dev/null and b/cool-cacti/static/sprites/moon.png differ diff --git a/cool-cacti/static/sprites/neptune.png b/cool-cacti/static/sprites/neptune.png new file mode 100644 index 00000000..3680e83e Binary files /dev/null and b/cool-cacti/static/sprites/neptune.png differ diff --git a/cool-cacti/static/sprites/player.png b/cool-cacti/static/sprites/player.png new file mode 100644 index 00000000..b9c4b406 Binary files /dev/null and b/cool-cacti/static/sprites/player.png differ diff --git a/cool-cacti/static/sprites/saturn.png b/cool-cacti/static/sprites/saturn.png new file mode 100644 index 00000000..6986fc11 Binary files /dev/null and b/cool-cacti/static/sprites/saturn.png differ diff --git a/cool-cacti/static/sprites/scanner.png b/cool-cacti/static/sprites/scanner.png new file mode 100644 index 00000000..bb1509a2 Binary files /dev/null and b/cool-cacti/static/sprites/scanner.png differ diff --git a/cool-cacti/static/sprites/spaceship.png b/cool-cacti/static/sprites/spaceship.png new file mode 100644 index 00000000..0d9a34e4 Binary files /dev/null and b/cool-cacti/static/sprites/spaceship.png differ diff --git a/cool-cacti/static/sprites/sun.png b/cool-cacti/static/sprites/sun.png new file mode 100644 index 00000000..462d214b Binary files /dev/null and b/cool-cacti/static/sprites/sun.png differ diff --git a/cool-cacti/static/sprites/uranus.png b/cool-cacti/static/sprites/uranus.png new file mode 100644 index 00000000..5940fcd2 Binary files /dev/null and b/cool-cacti/static/sprites/uranus.png differ diff --git a/cool-cacti/static/sprites/venus.png b/cool-cacti/static/sprites/venus.png new file mode 100644 index 00000000..363171e1 Binary files /dev/null and b/cool-cacti/static/sprites/venus.png differ diff --git a/cool-cacti/static/styles.css b/cool-cacti/static/styles.css new file mode 100644 index 00000000..4864996e --- /dev/null +++ b/cool-cacti/static/styles.css @@ -0,0 +1,40 @@ +body { + margin: 0; + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + background: black; + overflow: hidden; +} + +#canvasContainer { + margin: 20px; + display: flex; + justify-content: center; + align-items: center; + background-color: black; + flex: 1; +} + +#gameCanvas { + margin: 0px; + background: black; + flex: 1; +} + +#loadingLabel { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-family: 'Courier New', monospace; + font-weight: 900; + font-size: 1.5rem; + color: #ffff00; + z-index: 10; +} + +audio { + display: none; +} \ No newline at end of file diff --git a/cool-cacti/templates/index.html b/cool-cacti/templates/index.html new file mode 100644 index 00000000..55e347bb --- /dev/null +++ b/cool-cacti/templates/index.html @@ -0,0 +1,61 @@ + + + + + + Codejam 2025 Project + + + + + + + +
+ +
+ +
Loading...
+ + {% for audio_file in audio_list %} + + {% endfor %} + + + from js import Image, window + + sprites_url = "{{ url_for('static', filename='sprites/') }}" + + window.sprites = {} + for sprite in {{ sprite_list }}: + # Skip the folder name "asteroid sprites" which would request "asteroid%20sprites.png" + if sprite == "asteroid sprites": + continue + window.sprites[sprite] = Image.new() + window.sprites[sprite].src = sprites_url + sprite + ".png" + + window.audio_list = {{ audio_list }} + + window.sprites["asteroids"] = Image.new() + window.sprites["asteroids"].src = sprites_url + "asteroids.png" + + window.planets = {{ planets_info|tojson|safe }} + for planet in window.planets: + planet["spritesheet"] = Image.new() + planet["spritesheet"].src = sprites_url + planet["sprite"] + + window.credits = {{ credits | tojson | safe }} + + window.lore = {{ lore|tojson|safe }} + + # exposing canvas globally (used by Player clamp logic) + from js import document + window.canvas = document.getElementById('gameCanvas') + + # initialize game scripts + from audio import AudioHandler + window.audio_handler = AudioHandler("{{ url_for('static', filename='') }}") + import game + + + \ No newline at end of file diff --git a/cool-cacti/tools/fetch_horizons.py b/cool-cacti/tools/fetch_horizons.py new file mode 100644 index 00000000..c0a974e7 --- /dev/null +++ b/cool-cacti/tools/fetch_horizons.py @@ -0,0 +1,52 @@ +import json +import logging +from datetime import UTC, datetime, timedelta +from pathlib import Path + +from horizons_api import HorizonsClient, TimePeriod + +# api access point +HORIZONS_URL = "https://ssd.jpl.nasa.gov/api/horizons.api" +HORIZONS_DATA_DIR = "horizons_data" + +SUN_ID = 10 + +# set logging config here, since this is a standalone script +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) + +if __name__ == "__main__": + client = HorizonsClient() + # create dir for horizons API data if it doesn't already exist + working_dir = Path.cwd() + horizons_path = working_dir / HORIZONS_DATA_DIR + if not horizons_path.exists(): + Path.mkdir(horizons_path, parents=True, exist_ok=True) + + with (horizons_path / "planets.json").open(encoding='utf-8') as f: + template = json.load(f) + + """ + This is a special query that returns info of major bodies ("MB") in the solar system, + useful for knowing the IDs of planets, moons etc. that horizons refers to things as internally. + """ + major_bodies = client.get_major_bodies(save_to=horizons_path / "major_bodies.txt") + + today = datetime.now(tz=UTC) + tomorrow = today + timedelta(days=1) + + for planet in template: + id: int = planet["id"] + name: str = planet["name"] + time_period = TimePeriod(start=today, end=tomorrow) + + object = client.get_object_data(id) + + if id == SUN_ID: + continue # skip sun since we don't need its position + + pos_response = client.get_vectors(id, time_period) + planet["info"] = object.text + + with(horizons_path / "planets.json").open("w", encoding='utf-8') as f: + json.dump(template, f, indent=4) diff --git a/cool-cacti/tools/generate_pyscript_config.py b/cool-cacti/tools/generate_pyscript_config.py new file mode 100644 index 00000000..6ff9977b --- /dev/null +++ b/cool-cacti/tools/generate_pyscript_config.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Script to automatically generate pyscript.json configuration file by scanning for all Python files. + +Pyscript config files don't allow directory replication, so it's necessary to map every file. +""" + +import argparse +import json +import logging +from pathlib import Path + +logging.basicConfig(level=logging.INFO) +log = logging.getLogger(__name__) + +WORKING_DIR = Path(__file__).parent.parent +OUTPUT_DIR = WORKING_DIR / "static" +APPLICATION_DIR = OUTPUT_DIR / "scripts" + + +def generate_pyscript_config(base_path: Path, output_file: str = "pyscript.json") -> None: + """Generate pyscript.json configuration by scanning for Python files. + + Args: + base_path: Base directory to scan + output_file: Output path for pyscript.json + + """ + if not base_path.exists(): + print(f"Error: Base directory '{base_path}' does not exist") + return + + files_config = {} + + # Find all Python files recursively + for py_file in base_path.rglob("*.py"): + # Get relative path from the working directory + rel_from_working = py_file.relative_to(WORKING_DIR) + pyscript_path = "/" + str(rel_from_working).replace("\\", "/") + + # Check if file is in a subdirectory of base_path + if py_file.parent != base_path: + subdir = py_file.relative_to(base_path).parent + files_config[pyscript_path] = str(subdir).replace("\\", "/") + "/" + else: + files_config[pyscript_path] = "" + + files_config = dict(sorted(files_config.items())) + + config = {"files": files_config} + + output_path = Path(OUTPUT_DIR) / output_file + + with output_path.open("w") as f: + json.dump(config, f, indent=2) + + log.info("Generated %s with %d Python files at %s", output_file, len(files_config), output_path) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Generate pyscript.json configuration") + parser.add_argument( + "--base-dir", + type=Path, + default=APPLICATION_DIR, + help=f"Base directory to scan for Python files (default: static/scripts)", + ) + parser.add_argument( + "--output", type=str, default="pyscript.json", help="Output file name (default: pyscript.json)" + ) + + args = parser.parse_args() + + generate_pyscript_config(args.base_dir, args.output) diff --git a/cool-cacti/tools/horizons_api/__init__.py b/cool-cacti/tools/horizons_api/__init__.py new file mode 100644 index 00000000..2c97c28c --- /dev/null +++ b/cool-cacti/tools/horizons_api/__init__.py @@ -0,0 +1,3 @@ +from .client import * # noqa: F403 +from .exceptions import * # noqa: F403 +from .models import * # noqa: F403 diff --git a/cool-cacti/tools/horizons_api/client.py b/cool-cacti/tools/horizons_api/client.py new file mode 100644 index 00000000..0408cbdd --- /dev/null +++ b/cool-cacti/tools/horizons_api/client.py @@ -0,0 +1,119 @@ +import logging +from pathlib import Path +from urllib import parse, request + +from .exceptions import HorizonsAPIError, ParsingError +from .models import MajorBody, ObjectData, TimePeriod, VectorData +from .parsers import MajorBodyTableParser, ObjectDataParser, VectorDataParser + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +__all__ = ("HorizonsClient",) + + +class HorizonsClient: + """A client for the JPL Horizons API.""" + + BASE_URL = "https://ssd.jpl.nasa.gov/api/horizons.api" + TIME_FORMAT = "%Y-%m-%d" + + def _request(self, params: dict, save_to: Path | None = None) -> str: + """Make a request to the Horizons API and return the result string.""" + params["format"] = "text" + url = f"{self.BASE_URL}?{parse.urlencode(params)}" + logger.info("Horizons query from %s", url) + + try: + with request.urlopen(url) as response: # noqa: S310 + data = response.read().decode() + + if save_to: + with Path.open(save_to, "w") as f: + f.write(data) + + except Exception as e: + logger.exception("Horizon query raising %s", type(e).__name__) + msg = f"Failed to retrieve data from Horizons API: {e}" + raise HorizonsAPIError(msg) from e + + return data + + def get_major_bodies(self, save_to: Path | None = None) -> list[MajorBody]: + """Get a list of major bodies. + + Arguments: + save_to (Path | None): Optional path to save the raw response data. + + Returns: + list[MajorBody]: A list of major bodies. + + """ + result_text = self._request( + { + "COMMAND": "MB", + "OBJ_DATA": "YES", + "MAKE_EPHEM": "NO", + }, + save_to=save_to, + ) + return MajorBodyTableParser().parse(result_text) + + def get_object_data(self, object_id: int, *, small_body: bool = False, save_to: Path | None = None) -> ObjectData: + """Get physical data for a specific body. + + Arguments: + object_id (int): The ID of the object. + small_body (bool): Whether the object is a small body. + save_to (Path | None): Optional path to save the raw response data. + + Returns: + ObjectData: The physical data for the object. + + """ + result_text = self._request( + { + "COMMAND": str(object_id) + (";" if small_body else ""), + "OBJ_DATA": "YES", + "MAKE_EPHEM": "NO", + }, + save_to=save_to, + ) + + return ObjectDataParser().parse(result_text) + + def get_vectors( + self, object_id: int, time_options: TimePeriod, center: int = 10, save_to: Path | None = None + ) -> VectorData: + """Get positional vectors for a specific body. + + Arguments: + object_id (int): The ID of the object. + time_options (TimePeriod): The time period for the ephemeris. + center (int): The object id for center for the ephemeris. Default 10 for the sun. + save_to (Path | None): Optional path to save the raw response data. + + Returns: + VectorData: The positional vectors for the object. + + """ + result_text = self._request( + { + "COMMAND": str(object_id), + "OBJ_DATA": "NO", + "MAKE_EPHEM": "YES", + "EPHEM_TYPE": "VECTORS", + "CENTER": f"@{center}", + "START_TIME": time_options.start.strftime(self.TIME_FORMAT), + "STOP_TIME": time_options.end.strftime(self.TIME_FORMAT), + "STEP_SIZE": time_options.step, + }, + save_to=save_to, + ) + + vector_data = VectorDataParser().parse(result_text) + if vector_data is None: + msg = "Failed to find all vector components in the text." + logger.warning(msg) + raise ParsingError(msg) + return vector_data diff --git a/cool-cacti/tools/horizons_api/exceptions.py b/cool-cacti/tools/horizons_api/exceptions.py new file mode 100644 index 00000000..b07a4363 --- /dev/null +++ b/cool-cacti/tools/horizons_api/exceptions.py @@ -0,0 +1,9 @@ +__all__ = ("HorizonsAPIError", "ParsingError") + + +class HorizonsAPIError(Exception): + """Base exception for Horizons API errors.""" + + +class ParsingError(Exception): + """Base exception for parsing errors.""" diff --git a/cool-cacti/tools/horizons_api/models.py b/cool-cacti/tools/horizons_api/models.py new file mode 100644 index 00000000..fb5d459b --- /dev/null +++ b/cool-cacti/tools/horizons_api/models.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from datetime import datetime + +__all__ = ( + "MajorBody", + "ObjectData", + "TimePeriod", + "VectorData", +) + + +@dataclass +class MajorBody: + """Represents a major body in the solar system.""" + + id: str + name: str | None = None + designation: str | None = None + aliases: str | None = None + + +@dataclass +class ObjectData: + """Represents physical characteristics of a celestial body.""" + + text: str + radius: float | None = None + + +@dataclass +class VectorData: + """Represents position and velocity vectors.""" + + x: float + y: float + z: float | None = None + + +@dataclass +class TimePeriod: + """Represents a time period for ephemeris data.""" + + start: datetime + end: datetime + step: str = "2d" diff --git a/cool-cacti/tools/horizons_api/parsers.py b/cool-cacti/tools/horizons_api/parsers.py new file mode 100644 index 00000000..907fd1be --- /dev/null +++ b/cool-cacti/tools/horizons_api/parsers.py @@ -0,0 +1,134 @@ +import logging +import re +from abc import ABC, abstractmethod + +from .exceptions import ParsingError +from .models import MajorBody, ObjectData, VectorData + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class BaseParser(ABC): + """Abstract base class for all parsers.""" + + @abstractmethod + def parse(self, text: str) -> object: + """Parse the given text and return structured data.""" + raise NotImplementedError + + +class MajorBodyTableParser(BaseParser): + """Parse a table of major bodies from the Horizons API.""" + + def parse(self, text: str) -> list[MajorBody]: + """Parse the text output from the NASA/JPL Horizons API into a list of objects. + + This function orchestrates the parsing process by calling helper methods to: + 1. Find the data section. + 2. Determine column boundaries. + 3. Parse each data row individually. + + Arguments: + text: The multi-line string data from the Horizons API. + + Returns: + A list of MajorBody objects. + + """ + lines = text.strip().split("\n") + + separator_line_index = self._find_separator_index(lines) + if separator_line_index is None: + logger.warning("Could not find header or separator line. Unable to parse.") + return [] + data_start_index = separator_line_index + 1 + data_end_index = self._find_data_end_index(lines, data_start_index) + column_boundaries = self._get_column_boundaries(lines[separator_line_index]) + if not column_boundaries: + logger.warning("Could not determine column boundaries. Parsing may be incomplete.") + return [] + + data_lines = lines[data_start_index:data_end_index] + + parsed_objects = [] + for line in data_lines: + body = self._parse_row(line, column_boundaries) + if body: + parsed_objects.append(body) + + return parsed_objects + + def _find_separator_index(self, lines: list[str]) -> int | None: + """Find the separator line and the starting index of the data rows.""" + header_line_index = -1 + separator_line_index = -1 + for i, line in enumerate(lines): + if "ID#" in line and "Name" in line: + header_line_index = i + if "---" in line and header_line_index != -1: + separator_line_index = i + break + + if separator_line_index == -1 or header_line_index + 1 != separator_line_index: + return None + + return separator_line_index + + def _find_data_end_index(self, lines: list[str], start_index: int) -> int: + """Find the end index of the data rows.""" + for i in range(start_index, len(lines)): + if not lines[i].strip(): + return i + return len(lines) + + def _get_column_boundaries(self, separator_line: str) -> list[tuple[int, int]] | None: + """Determine column boundaries from the separator line using its dash groups.""" + dash_groups = re.finditer(r"-+", separator_line) + return [match.span() for match in dash_groups] + + def _parse_row(self, line: str, column_boundaries: list[tuple[int, int]]) -> MajorBody | None: + """Parse a single data row string into a MajorBody object.""" + if not line.strip(): + return None + + try: + body_data = [line[start:end].strip() for start, end in column_boundaries] + except IndexError: # Line is malformed or shorter than expected + return None + + if not body_data or not body_data[0]: + return None + + return MajorBody(*body_data) + + +class ObjectDataParser(BaseParser): + """Parses the physical characteristics of an object.""" + + def parse(self, text: str) -> ObjectData: + """Parse the text to find the object's radius.""" + radius_match = re.search(r"Radius \(km\)\s*=\s*([\d\.]+)", text) + radius = float(radius_match.group(1)) if radius_match else None + return ObjectData(text=text, radius=radius) + + +class VectorDataParser(BaseParser): + """Parses vector data from the API response.""" + + def parse(self, text: str) -> VectorData | None: + """Parse the text to find X, Y, and Z vector components.""" + # TODO: should probably add error checking for the re searches and horizons queries + # looking for patterns like "X =-2367823E+10" or "Y = 27178E-02" since the API returns coordinates + # in scientific notation + pattern = r"\s*=\s*(-?[\d\.]+E[\+-]\d\d)" + x_match = re.search("X" + pattern, text) + y_match = re.search("Y" + pattern, text) + z_match = re.search("Z" + pattern, text) + + if not (x_match and y_match and z_match): + msg = "Failed to find all vector components in the text." + logger.warning(msg) + raise ParsingError(msg) + + return VectorData(x=float(x_match.group(1)), y=float(y_match.group(1)), z=float(z_match.group(1))) diff --git a/cool-cacti/tools/make_spritesheets.py b/cool-cacti/tools/make_spritesheets.py new file mode 100644 index 00000000..ff23c97e --- /dev/null +++ b/cool-cacti/tools/make_spritesheets.py @@ -0,0 +1,74 @@ +""" +just a quick and dirty script to turn a series of 50 .png sprites into a single spritesheet file. We're not +using this otherwise and we may well not need it again, but this can live here just in case we generate more +planet sprites on that website +""" + +import os +from pathlib import Path + +import numpy as np +from PIL import Image + +cur_dir = Path(__file__).resolve().parent + +# Planet spritesheets +for planet in "earth jupiter mars mercury neptune saturn sun uranus venus".split(): + planet_dir = cur_dir / f"{planet} sprites" + if planet_dir.exists(): + first_frame = Image.open(planet_dir / "sprite_1.png") + width, height = first_frame.size + spritesheet = Image.new("RGBA", (width * 50, height), (0, 0, 0, 0)) + for fr in range(1, 51): + frame = Image.open(planet_dir / f"sprite_{fr}.png") + spritesheet.paste(frame, (width * (fr - 1), 0)) + + spritesheet.save(cur_dir.parent / "static" / "sprites" / f"{planet}.png") + +# Asteroid spritesheet +asteroid_dir = cur_dir.parent / "static" / "sprites" / "asteroid sprites" +if asteroid_dir.exists(): + # Get all PNG files in the asteroid directory + asteroid_files = sorted([f for f in os.listdir(asteroid_dir) if f.endswith(".png")]) + + if asteroid_files: + # Load first asteroid to get dimensions + first_asteroid = Image.open(asteroid_dir / asteroid_files[0]) + width, height = first_asteroid.size + + # Calculate grid layout (try to make roughly square) + num_asteroids = len(asteroid_files) + cols = int(num_asteroids**0.5) + 1 + rows = (num_asteroids + cols - 1) // cols + + print(f"Creating asteroid spritesheet: {cols}x{rows} grid for {num_asteroids} asteroids") + + # Create the spritesheet + spritesheet = Image.new("RGBA", (width * cols, height * rows), (0, 0, 0, 0)) + + collision_radii = [] + # Paste each asteroid + for i, filename in enumerate(asteroid_files): + asteroid = Image.open(asteroid_dir / filename) + pixel_alpha_values = np.array(asteroid)[:, :, 3] + non_transparent_count = np.sum(pixel_alpha_values > 0) + collision_radii.append(int(np.sqrt(non_transparent_count / np.pi))) + + # Calculate position in grid + col = i % cols + row = i // cols + x = col * width + y = row * height + + spritesheet.paste(asteroid, (x, y)) + print(f"Added {filename} at position ({col}, {row})") + + # Save the spritesheet + output_path = cur_dir.parent / "static" / "sprites" / "asteroids.png" + spritesheet.save(output_path) + print(f"Asteroid spritesheet saved to: {output_path}") + print(f"Grid dimensions: {cols} columns x {rows} rows") + print(f"Each sprite: {width}x{height} pixels") + + print("Collision radii:") + print(collision_radii) diff --git a/cool-cacti/tools/process_recycle_sprites.py b/cool-cacti/tools/process_recycle_sprites.py new file mode 100644 index 00000000..2b3c9a9a --- /dev/null +++ b/cool-cacti/tools/process_recycle_sprites.py @@ -0,0 +1,208 @@ +""" +Script to extract sprites from recycle_items.png, resize them to 100x100 with padding, +and add them to the asteroid spritesheet. +""" + +import os +from pathlib import Path + +import numpy as np +from PIL import Image + +cur_dir = Path(__file__).resolve().parent + +def extract_recycle_sprites(): + """Extract individual sprites from recycle_items.png""" + recycle_path = cur_dir.parent / "static" / "sprites" / "asteroid sprites" / "recycle_items.png" + + if not recycle_path.exists(): + print(f"Error: {recycle_path} not found") + return [] + + # Load the recycle items spritesheet + recycle_sheet = Image.open(recycle_path) + sheet_width, sheet_height = recycle_sheet.size + + print(f"Recycle spritesheet dimensions: {sheet_width}x{sheet_height}") + + # Estimate sprite size by looking at the image + # We'll assume it's a horizontal strip of sprites + # Let's try to detect individual sprites by looking for vertical gaps + + # Convert to numpy array for analysis + sheet_array = np.array(recycle_sheet) + + # Check if there are transparent columns that separate sprites + alpha_channel = sheet_array[:, :, 3] if sheet_array.shape[2] == 4 else np.ones((sheet_height, sheet_width)) * 255 + + # Find columns that are completely transparent + transparent_cols = np.all(alpha_channel == 0, axis=0) + + # Find transitions from non-transparent to transparent (sprite boundaries) + boundaries = [] + in_sprite = False + sprite_start = 0 + + for col in range(sheet_width): + if not transparent_cols[col] and not in_sprite: + # Start of a sprite + sprite_start = col + in_sprite = True + elif transparent_cols[col] and in_sprite: + # End of a sprite + boundaries.append((sprite_start, col)) + in_sprite = False + + # Handle case where last sprite goes to the edge + if in_sprite: + boundaries.append((sprite_start, sheet_width)) + + print(f"Found {len(boundaries)} sprites with boundaries: {boundaries}") + + # If we can't detect boundaries automatically, assume equal-width sprites + if not boundaries: + # Let's assume 16 sprites in a horizontal row (common for item spritesheets) + sprite_width = sheet_width // 16 + boundaries = [(i * sprite_width, (i + 1) * sprite_width) for i in range(16)] + print(f"Using equal-width assumption: {sprite_width}px wide sprites") + + # Extract each sprite + sprites = [] + for i, (start_x, end_x) in enumerate(boundaries): + # Extract the sprite + sprite = recycle_sheet.crop((start_x, 0, end_x, sheet_height)) + + # Resize to 100x100 with padding + resized_sprite = resize_with_padding(sprite, (100, 100)) + sprites.append(resized_sprite) + + # Save individual sprite for debugging + debug_path = cur_dir.parent / "static" / "sprites" / "asteroid sprites" / f"recycle_{i:02d}.png" + resized_sprite.save(debug_path) + print(f"Saved recycle sprite {i} to {debug_path}") + + return sprites + +def resize_with_padding(image, target_size): + """Resize image to target size while maintaining aspect ratio and adding transparent padding""" + target_width, target_height = target_size + + # Calculate scaling factor to fit within target size + width_ratio = target_width / image.width + height_ratio = target_height / image.height + scale_factor = min(width_ratio, height_ratio) + + # Calculate new size after scaling + new_width = int(image.width * scale_factor) + new_height = int(image.height * scale_factor) + + # Resize the image + resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Create new image with transparent background + result = Image.new("RGBA", target_size, (0, 0, 0, 0)) + + # Center the resized image + x_offset = (target_width - new_width) // 2 + y_offset = (target_height - new_height) // 2 + + result.paste(resized, (x_offset, y_offset), resized if resized.mode == 'RGBA' else None) + + return result + +def rebuild_asteroid_spritesheet(): + """Rebuild the asteroid spritesheet including the new recycle sprites""" + asteroid_dir = cur_dir.parent / "static" / "sprites" / "asteroid sprites" + + # Get all existing asteroid PNG files (excluding recycle_items.png and newly created recycle_XX.png) + all_files = [f for f in os.listdir(asteroid_dir) if f.endswith(".png")] + asteroid_files = [f for f in all_files if f != "recycle_items.png" and not f.startswith("recycle_")] + + print(f"Found {len(asteroid_files)} original asteroid files") + + # Extract recycle sprites + recycle_sprites = extract_recycle_sprites() + print(f"Extracted {len(recycle_sprites)} recycle sprites") + + # Load all asteroid images + all_sprites = [] + collision_radii = [] + + # Add original asteroids + for filename in sorted(asteroid_files): + asteroid = Image.open(asteroid_dir / filename) + all_sprites.append(asteroid) + + # Calculate collision radius based on non-transparent pixels + pixel_alpha_values = np.array(asteroid)[:, :, 3] if np.array(asteroid).shape[2] == 4 else np.ones(asteroid.size[::-1]) * 255 + non_transparent_count = np.sum(pixel_alpha_values > 0) + collision_radii.append(int(np.sqrt(non_transparent_count / np.pi))) + + # Add recycle sprites + for i, sprite in enumerate(recycle_sprites): + all_sprites.append(sprite) + + # Calculate collision radius for recycle sprites + pixel_alpha_values = np.array(sprite)[:, :, 3] + non_transparent_count = np.sum(pixel_alpha_values > 0) + collision_radii.append(int(np.sqrt(non_transparent_count / np.pi))) + + # Create the combined spritesheet + if all_sprites: + # Assume all sprites are now the same size (100x100 for recycle, variable for asteroids) + # We need to standardize - let's make everything 100x100 + standardized_sprites = [] + + for sprite in all_sprites: + if sprite.size != (100, 100): + # Resize asteroid sprites to 100x100 with padding + standardized_sprite = resize_with_padding(sprite, (100, 100)) + standardized_sprites.append(standardized_sprite) + else: + standardized_sprites.append(sprite) + + # Calculate grid layout + num_sprites = len(standardized_sprites) + cols = int(num_sprites**0.5) + 1 + rows = (num_sprites + cols - 1) // cols + + print(f"Creating combined spritesheet: {cols}x{rows} grid for {num_sprites} sprites") + + # Create the spritesheet + sprite_size = 100 # All sprites are now 100x100 + spritesheet = Image.new("RGBA", (sprite_size * cols, sprite_size * rows), (0, 0, 0, 0)) + + # Paste each sprite + for i, sprite in enumerate(standardized_sprites): + col = i % cols + row = i // cols + x = col * sprite_size + y = row * sprite_size + + spritesheet.paste(sprite, (x, y)) + + sprite_type = "recycle" if i >= len(asteroid_files) else "asteroid" + print(f"Added {sprite_type} sprite {i} at position ({col}, {row})") + + # Save the new spritesheet + output_path = cur_dir.parent / "static" / "sprites" / "asteroids.png" + spritesheet.save(output_path) + print(f"Combined spritesheet saved to: {output_path}") + print(f"Grid dimensions: {cols} columns x {rows} rows") + print(f"Each sprite: {sprite_size}x{sprite_size} pixels") + print(f"Total sprites: {len(asteroid_files)} asteroids + {len(recycle_sprites)} recycle items = {num_sprites}") + + print("Collision radii:") + print(collision_radii) + + return True + else: + print("No sprites found to process") + return False + +if __name__ == "__main__": + success = rebuild_asteroid_spritesheet() + if success: + print("Successfully rebuilt asteroid spritesheet with recycle items!") + else: + print("Failed to rebuild spritesheet") diff --git a/cool-cacti/uv.lock b/cool-cacti/uv.lock new file mode 100644 index 00000000..cce1186f --- /dev/null +++ b/cool-cacti/uv.lock @@ -0,0 +1,411 @@ +version = 1 +requires-python = ">=3.12" + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 }, +] + +[[package]] +name = "code-jam-soon-to-be-awesome-project" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "flask" }, +] + +[package.dev-dependencies] +dev = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "pre-commit" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "flask", specifier = ">=3.1.1" }] + +[package.metadata.requires-dev] +dev = [ + { name = "numpy", specifier = ">=2.3.2" }, + { name = "pillow", specifier = ">=11.3.0" }, + { name = "pre-commit", specifier = "~=4.2.0" }, + { name = "ruff", specifier = "~=0.12.2" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047 }, +] + +[[package]] +name = "filelock" +version = "3.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, +] + +[[package]] +name = "flask" +version = "3.1.1" +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/c0/de/e47735752347f4128bcf354e0da07ef311a78244eba9e3dc1d4a5ab21a98/flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e", size = 753440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305 }, +] + +[[package]] +name = "identify" +version = "2.6.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ca/ffbabe3635bb839aa36b3a893c91a9b0d368cb4d8073e03a12896970af82/identify-2.6.13.tar.gz", hash = "sha256:da8d6c828e773620e13bfa86ea601c5a5310ba4bcd65edf378198b56a1f9fb32", size = 99243 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/ce/461b60a3ee109518c055953729bf9ed089a04db895d47e95444071dcdef2/identify-2.6.13-py2.py3-none-any.whl", hash = "sha256:60381139b3ae39447482ecc406944190f690d4a2997f2584062089848361b33b", size = 99153 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "numpy" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/6d/745dd1c1c5c284d17725e5c802ca4d45cfc6803519d777f087b71c9f4069/numpy-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bc3186bea41fae9d8e90c2b4fb5f0a1f5a690682da79b92574d63f56b529080b", size = 20956420 }, + { url = "https://files.pythonhosted.org/packages/bc/96/e7b533ea5740641dd62b07a790af5d9d8fec36000b8e2d0472bd7574105f/numpy-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f4f0215edb189048a3c03bd5b19345bdfa7b45a7a6f72ae5945d2a28272727f", size = 14184660 }, + { url = "https://files.pythonhosted.org/packages/2b/53/102c6122db45a62aa20d1b18c9986f67e6b97e0d6fbc1ae13e3e4c84430c/numpy-2.3.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b1224a734cd509f70816455c3cffe13a4f599b1bf7130f913ba0e2c0b2006c0", size = 5113382 }, + { url = "https://files.pythonhosted.org/packages/2b/21/376257efcbf63e624250717e82b4fae93d60178f09eb03ed766dbb48ec9c/numpy-2.3.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3dcf02866b977a38ba3ec10215220609ab9667378a9e2150615673f3ffd6c73b", size = 6647258 }, + { url = "https://files.pythonhosted.org/packages/91/ba/f4ebf257f08affa464fe6036e13f2bf9d4642a40228781dc1235da81be9f/numpy-2.3.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:572d5512df5470f50ada8d1972c5f1082d9a0b7aa5944db8084077570cf98370", size = 14281409 }, + { url = "https://files.pythonhosted.org/packages/59/ef/f96536f1df42c668cbacb727a8c6da7afc9c05ece6d558927fb1722693e1/numpy-2.3.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8145dd6d10df13c559d1e4314df29695613575183fa2e2d11fac4c208c8a1f73", size = 16641317 }, + { url = "https://files.pythonhosted.org/packages/f6/a7/af813a7b4f9a42f498dde8a4c6fcbff8100eed00182cc91dbaf095645f38/numpy-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:103ea7063fa624af04a791c39f97070bf93b96d7af7eb23530cd087dc8dbe9dc", size = 16056262 }, + { url = "https://files.pythonhosted.org/packages/8b/5d/41c4ef8404caaa7f05ed1cfb06afe16a25895260eacbd29b4d84dff2920b/numpy-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc927d7f289d14f5e037be917539620603294454130b6de200091e23d27dc9be", size = 18579342 }, + { url = "https://files.pythonhosted.org/packages/a1/4f/9950e44c5a11636f4a3af6e825ec23003475cc9a466edb7a759ed3ea63bd/numpy-2.3.2-cp312-cp312-win32.whl", hash = "sha256:d95f59afe7f808c103be692175008bab926b59309ade3e6d25009e9a171f7036", size = 6320610 }, + { url = "https://files.pythonhosted.org/packages/7c/2f/244643a5ce54a94f0a9a2ab578189c061e4a87c002e037b0829dd77293b6/numpy-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:9e196ade2400c0c737d93465327d1ae7c06c7cb8a1756121ebf54b06ca183c7f", size = 12786292 }, + { url = "https://files.pythonhosted.org/packages/54/cd/7b5f49d5d78db7badab22d8323c1b6ae458fbf86c4fdfa194ab3cd4eb39b/numpy-2.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:ee807923782faaf60d0d7331f5e86da7d5e3079e28b291973c545476c2b00d07", size = 10194071 }, + { url = "https://files.pythonhosted.org/packages/1c/c0/c6bb172c916b00700ed3bf71cb56175fd1f7dbecebf8353545d0b5519f6c/numpy-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c8d9727f5316a256425892b043736d63e89ed15bbfe6556c5ff4d9d4448ff3b3", size = 20949074 }, + { url = "https://files.pythonhosted.org/packages/20/4e/c116466d22acaf4573e58421c956c6076dc526e24a6be0903219775d862e/numpy-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:efc81393f25f14d11c9d161e46e6ee348637c0a1e8a54bf9dedc472a3fae993b", size = 14177311 }, + { url = "https://files.pythonhosted.org/packages/78/45/d4698c182895af189c463fc91d70805d455a227261d950e4e0f1310c2550/numpy-2.3.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dd937f088a2df683cbb79dda9a772b62a3e5a8a7e76690612c2737f38c6ef1b6", size = 5106022 }, + { url = "https://files.pythonhosted.org/packages/9f/76/3e6880fef4420179309dba72a8c11f6166c431cf6dee54c577af8906f914/numpy-2.3.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:11e58218c0c46c80509186e460d79fbdc9ca1eb8d8aee39d8f2dc768eb781089", size = 6640135 }, + { url = "https://files.pythonhosted.org/packages/34/fa/87ff7f25b3c4ce9085a62554460b7db686fef1e0207e8977795c7b7d7ba1/numpy-2.3.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ad4ebcb683a1f99f4f392cc522ee20a18b2bb12a2c1c42c3d48d5a1adc9d3d2", size = 14278147 }, + { url = "https://files.pythonhosted.org/packages/1d/0f/571b2c7a3833ae419fe69ff7b479a78d313581785203cc70a8db90121b9a/numpy-2.3.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938065908d1d869c7d75d8ec45f735a034771c6ea07088867f713d1cd3bbbe4f", size = 16635989 }, + { url = "https://files.pythonhosted.org/packages/24/5a/84ae8dca9c9a4c592fe11340b36a86ffa9fd3e40513198daf8a97839345c/numpy-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:66459dccc65d8ec98cc7df61307b64bf9e08101f9598755d42d8ae65d9a7a6ee", size = 16053052 }, + { url = "https://files.pythonhosted.org/packages/57/7c/e5725d99a9133b9813fcf148d3f858df98511686e853169dbaf63aec6097/numpy-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a7af9ed2aa9ec5950daf05bb11abc4076a108bd3c7db9aa7251d5f107079b6a6", size = 18577955 }, + { url = "https://files.pythonhosted.org/packages/ae/11/7c546fcf42145f29b71e4d6f429e96d8d68e5a7ba1830b2e68d7418f0bbd/numpy-2.3.2-cp313-cp313-win32.whl", hash = "sha256:906a30249315f9c8e17b085cc5f87d3f369b35fedd0051d4a84686967bdbbd0b", size = 6311843 }, + { url = "https://files.pythonhosted.org/packages/aa/6f/a428fd1cb7ed39b4280d057720fed5121b0d7754fd2a9768640160f5517b/numpy-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:c63d95dc9d67b676e9108fe0d2182987ccb0f11933c1e8959f42fa0da8d4fa56", size = 12782876 }, + { url = "https://files.pythonhosted.org/packages/65/85/4ea455c9040a12595fb6c43f2c217257c7b52dd0ba332c6a6c1d28b289fe/numpy-2.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:b05a89f2fb84d21235f93de47129dd4f11c16f64c87c33f5e284e6a3a54e43f2", size = 10192786 }, + { url = "https://files.pythonhosted.org/packages/80/23/8278f40282d10c3f258ec3ff1b103d4994bcad78b0cba9208317f6bb73da/numpy-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e6ecfeddfa83b02318f4d84acf15fbdbf9ded18e46989a15a8b6995dfbf85ab", size = 21047395 }, + { url = "https://files.pythonhosted.org/packages/1f/2d/624f2ce4a5df52628b4ccd16a4f9437b37c35f4f8a50d00e962aae6efd7a/numpy-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:508b0eada3eded10a3b55725b40806a4b855961040180028f52580c4729916a2", size = 14300374 }, + { url = "https://files.pythonhosted.org/packages/f6/62/ff1e512cdbb829b80a6bd08318a58698867bca0ca2499d101b4af063ee97/numpy-2.3.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:754d6755d9a7588bdc6ac47dc4ee97867271b17cee39cb87aef079574366db0a", size = 5228864 }, + { url = "https://files.pythonhosted.org/packages/7d/8e/74bc18078fff03192d4032cfa99d5a5ca937807136d6f5790ce07ca53515/numpy-2.3.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f66e7d2b2d7712410d3bc5684149040ef5f19856f20277cd17ea83e5006286", size = 6737533 }, + { url = "https://files.pythonhosted.org/packages/19/ea/0731efe2c9073ccca5698ef6a8c3667c4cf4eea53fcdcd0b50140aba03bc/numpy-2.3.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6ea4e5a65d5a90c7d286ddff2b87f3f4ad61faa3db8dabe936b34c2275b6f8", size = 14352007 }, + { url = "https://files.pythonhosted.org/packages/cf/90/36be0865f16dfed20f4bc7f75235b963d5939707d4b591f086777412ff7b/numpy-2.3.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3ef07ec8cbc8fc9e369c8dcd52019510c12da4de81367d8b20bc692aa07573a", size = 16701914 }, + { url = "https://files.pythonhosted.org/packages/94/30/06cd055e24cb6c38e5989a9e747042b4e723535758e6153f11afea88c01b/numpy-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:27c9f90e7481275c7800dc9c24b7cc40ace3fdb970ae4d21eaff983a32f70c91", size = 16132708 }, + { url = "https://files.pythonhosted.org/packages/9a/14/ecede608ea73e58267fd7cb78f42341b3b37ba576e778a1a06baffbe585c/numpy-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:07b62978075b67eee4065b166d000d457c82a1efe726cce608b9db9dd66a73a5", size = 18651678 }, + { url = "https://files.pythonhosted.org/packages/40/f3/2fe6066b8d07c3685509bc24d56386534c008b462a488b7f503ba82b8923/numpy-2.3.2-cp313-cp313t-win32.whl", hash = "sha256:c771cfac34a4f2c0de8e8c97312d07d64fd8f8ed45bc9f5726a7e947270152b5", size = 6441832 }, + { url = "https://files.pythonhosted.org/packages/0b/ba/0937d66d05204d8f28630c9c60bc3eda68824abde4cf756c4d6aad03b0c6/numpy-2.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:72dbebb2dcc8305c431b2836bcc66af967df91be793d63a24e3d9b741374c450", size = 12927049 }, + { url = "https://files.pythonhosted.org/packages/e9/ed/13542dd59c104d5e654dfa2ac282c199ba64846a74c2c4bcdbc3a0f75df1/numpy-2.3.2-cp313-cp313t-win_arm64.whl", hash = "sha256:72c6df2267e926a6d5286b0a6d556ebe49eae261062059317837fda12ddf0c1a", size = 10262935 }, + { url = "https://files.pythonhosted.org/packages/c9/7c/7659048aaf498f7611b783e000c7268fcc4dcf0ce21cd10aad7b2e8f9591/numpy-2.3.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:448a66d052d0cf14ce9865d159bfc403282c9bc7bb2a31b03cc18b651eca8b1a", size = 20950906 }, + { url = "https://files.pythonhosted.org/packages/80/db/984bea9d4ddf7112a04cfdfb22b1050af5757864cfffe8e09e44b7f11a10/numpy-2.3.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:546aaf78e81b4081b2eba1d105c3b34064783027a06b3ab20b6eba21fb64132b", size = 14185607 }, + { url = "https://files.pythonhosted.org/packages/e4/76/b3d6f414f4eca568f469ac112a3b510938d892bc5a6c190cb883af080b77/numpy-2.3.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:87c930d52f45df092f7578889711a0768094debf73cfcde105e2d66954358125", size = 5114110 }, + { url = "https://files.pythonhosted.org/packages/9e/d2/6f5e6826abd6bca52392ed88fe44a4b52aacb60567ac3bc86c67834c3a56/numpy-2.3.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:8dc082ea901a62edb8f59713c6a7e28a85daddcb67454c839de57656478f5b19", size = 6642050 }, + { url = "https://files.pythonhosted.org/packages/c4/43/f12b2ade99199e39c73ad182f103f9d9791f48d885c600c8e05927865baf/numpy-2.3.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af58de8745f7fa9ca1c0c7c943616c6fe28e75d0c81f5c295810e3c83b5be92f", size = 14296292 }, + { url = "https://files.pythonhosted.org/packages/5d/f9/77c07d94bf110a916b17210fac38680ed8734c236bfed9982fd8524a7b47/numpy-2.3.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed5527c4cf10f16c6d0b6bee1f89958bccb0ad2522c8cadc2efd318bcd545f5", size = 16638913 }, + { url = "https://files.pythonhosted.org/packages/9b/d1/9d9f2c8ea399cc05cfff8a7437453bd4e7d894373a93cdc46361bbb49a7d/numpy-2.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:095737ed986e00393ec18ec0b21b47c22889ae4b0cd2d5e88342e08b01141f58", size = 16071180 }, + { url = "https://files.pythonhosted.org/packages/4c/41/82e2c68aff2a0c9bf315e47d61951099fed65d8cb2c8d9dc388cb87e947e/numpy-2.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5e40e80299607f597e1a8a247ff8d71d79c5b52baa11cc1cce30aa92d2da6e0", size = 18576809 }, + { url = "https://files.pythonhosted.org/packages/14/14/4b4fd3efb0837ed252d0f583c5c35a75121038a8c4e065f2c259be06d2d8/numpy-2.3.2-cp314-cp314-win32.whl", hash = "sha256:7d6e390423cc1f76e1b8108c9b6889d20a7a1f59d9a60cac4a050fa734d6c1e2", size = 6366410 }, + { url = "https://files.pythonhosted.org/packages/11/9e/b4c24a6b8467b61aced5c8dc7dcfce23621baa2e17f661edb2444a418040/numpy-2.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:b9d0878b21e3918d76d2209c924ebb272340da1fb51abc00f986c258cd5e957b", size = 12918821 }, + { url = "https://files.pythonhosted.org/packages/0e/0f/0dc44007c70b1007c1cef86b06986a3812dd7106d8f946c09cfa75782556/numpy-2.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:2738534837c6a1d0c39340a190177d7d66fdf432894f469728da901f8f6dc910", size = 10477303 }, + { url = "https://files.pythonhosted.org/packages/8b/3e/075752b79140b78ddfc9c0a1634d234cfdbc6f9bbbfa6b7504e445ad7d19/numpy-2.3.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:4d002ecf7c9b53240be3bb69d80f86ddbd34078bae04d87be81c1f58466f264e", size = 21047524 }, + { url = "https://files.pythonhosted.org/packages/fe/6d/60e8247564a72426570d0e0ea1151b95ce5bd2f1597bb878a18d32aec855/numpy-2.3.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:293b2192c6bcce487dbc6326de5853787f870aeb6c43f8f9c6496db5b1781e45", size = 14300519 }, + { url = "https://files.pythonhosted.org/packages/4d/73/d8326c442cd428d47a067070c3ac6cc3b651a6e53613a1668342a12d4479/numpy-2.3.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0a4f2021a6da53a0d580d6ef5db29947025ae8b35b3250141805ea9a32bbe86b", size = 5228972 }, + { url = "https://files.pythonhosted.org/packages/34/2e/e71b2d6dad075271e7079db776196829019b90ce3ece5c69639e4f6fdc44/numpy-2.3.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9c144440db4bf3bb6372d2c3e49834cc0ff7bb4c24975ab33e01199e645416f2", size = 6737439 }, + { url = "https://files.pythonhosted.org/packages/15/b0/d004bcd56c2c5e0500ffc65385eb6d569ffd3363cb5e593ae742749b2daa/numpy-2.3.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f92d6c2a8535dc4fe4419562294ff957f83a16ebdec66df0805e473ffaad8bd0", size = 14352479 }, + { url = "https://files.pythonhosted.org/packages/11/e3/285142fcff8721e0c99b51686426165059874c150ea9ab898e12a492e291/numpy-2.3.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cefc2219baa48e468e3db7e706305fcd0c095534a192a08f31e98d83a7d45fb0", size = 16702805 }, + { url = "https://files.pythonhosted.org/packages/33/c3/33b56b0e47e604af2c7cd065edca892d180f5899599b76830652875249a3/numpy-2.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76c3e9501ceb50b2ff3824c3589d5d1ab4ac857b0ee3f8f49629d0de55ecf7c2", size = 16133830 }, + { url = "https://files.pythonhosted.org/packages/6e/ae/7b1476a1f4d6a48bc669b8deb09939c56dd2a439db1ab03017844374fb67/numpy-2.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:122bf5ed9a0221b3419672493878ba4967121514b1d7d4656a7580cd11dddcbf", size = 18652665 }, + { url = "https://files.pythonhosted.org/packages/14/ba/5b5c9978c4bb161034148ade2de9db44ec316fab89ce8c400db0e0c81f86/numpy-2.3.2-cp314-cp314t-win32.whl", hash = "sha256:6f1ae3dcb840edccc45af496f312528c15b1f79ac318169d094e85e4bb35fdf1", size = 6514777 }, + { url = "https://files.pythonhosted.org/packages/eb/46/3dbaf0ae7c17cdc46b9f662c56da2054887b8d9e737c1476f335c83d33db/numpy-2.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:087ffc25890d89a43536f75c5fe8770922008758e8eeeef61733957041ed2f9b", size = 13111856 }, + { url = "https://files.pythonhosted.org/packages/c1/9e/1652778bce745a67b5fe05adde60ed362d38eb17d919a540e813d30f6874/numpy-2.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:092aeb3449833ea9c0bf0089d70c29ae480685dd2377ec9cdbbb620257f84631", size = 10544226 }, +] + +[[package]] +name = "pillow" +version = "11.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800 }, + { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296 }, + { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726 }, + { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652 }, + { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787 }, + { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236 }, + { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950 }, + { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358 }, + { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079 }, + { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324 }, + { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067 }, + { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328 }, + { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652 }, + { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443 }, + { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474 }, + { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038 }, + { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407 }, + { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094 }, + { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503 }, + { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574 }, + { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060 }, + { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407 }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841 }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450 }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055 }, + { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110 }, + { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547 }, + { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554 }, + { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132 }, + { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001 }, + { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814 }, + { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124 }, + { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186 }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546 }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102 }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803 }, + { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520 }, + { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116 }, + { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597 }, + { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246 }, + { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336 }, + { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699 }, + { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789 }, + { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386 }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911 }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383 }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385 }, + { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129 }, + { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580 }, + { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860 }, + { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694 }, + { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888 }, + { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330 }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089 }, + { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206 }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370 }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500 }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/8b/3c73abc9c759ecd3f1f7ceff6685840859e8070c4d947c93fae71f6a0bf2/platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc", size = 21362 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567 }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873 }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302 }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154 }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223 }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542 }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164 }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611 }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "ruff" +version = "0.12.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/da/5bd7565be729e86e1442dad2c9a364ceeff82227c2dece7c29697a9795eb/ruff-0.12.8.tar.gz", hash = "sha256:4cb3a45525176e1009b2b64126acf5f9444ea59066262791febf55e40493a033", size = 5242373 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/1e/c843bfa8ad1114fab3eb2b78235dda76acd66384c663a4e0415ecc13aa1e/ruff-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:63cb5a5e933fc913e5823a0dfdc3c99add73f52d139d6cd5cc8639d0e0465513", size = 11675315 }, + { url = "https://files.pythonhosted.org/packages/24/ee/af6e5c2a8ca3a81676d5480a1025494fd104b8896266502bb4de2a0e8388/ruff-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a9bbe28f9f551accf84a24c366c1aa8774d6748438b47174f8e8565ab9dedbc", size = 12456653 }, + { url = "https://files.pythonhosted.org/packages/99/9d/e91f84dfe3866fa648c10512904991ecc326fd0b66578b324ee6ecb8f725/ruff-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2fae54e752a3150f7ee0e09bce2e133caf10ce9d971510a9b925392dc98d2fec", size = 11659690 }, + { url = "https://files.pythonhosted.org/packages/fe/ac/a363d25ec53040408ebdd4efcee929d48547665858ede0505d1d8041b2e5/ruff-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0acbcf01206df963d9331b5838fb31f3b44fa979ee7fa368b9b9057d89f4a53", size = 11896923 }, + { url = "https://files.pythonhosted.org/packages/58/9f/ea356cd87c395f6ade9bb81365bd909ff60860975ca1bc39f0e59de3da37/ruff-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae3e7504666ad4c62f9ac8eedb52a93f9ebdeb34742b8b71cd3cccd24912719f", size = 11477612 }, + { url = "https://files.pythonhosted.org/packages/1a/46/92e8fa3c9dcfd49175225c09053916cb97bb7204f9f899c2f2baca69e450/ruff-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb82efb5d35d07497813a1c5647867390a7d83304562607f3579602fa3d7d46f", size = 13182745 }, + { url = "https://files.pythonhosted.org/packages/5e/c4/f2176a310f26e6160deaf661ef60db6c3bb62b7a35e57ae28f27a09a7d63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dbea798fc0065ad0b84a2947b0aff4233f0cb30f226f00a2c5850ca4393de609", size = 14206885 }, + { url = "https://files.pythonhosted.org/packages/87/9d/98e162f3eeeb6689acbedbae5050b4b3220754554526c50c292b611d3a63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49ebcaccc2bdad86fd51b7864e3d808aad404aab8df33d469b6e65584656263a", size = 13639381 }, + { url = "https://files.pythonhosted.org/packages/81/4e/1b7478b072fcde5161b48f64774d6edd59d6d198e4ba8918d9f4702b8043/ruff-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ac9c570634b98c71c88cb17badd90f13fc076a472ba6ef1d113d8ed3df109fb", size = 12613271 }, + { url = "https://files.pythonhosted.org/packages/e8/67/0c3c9179a3ad19791ef1b8f7138aa27d4578c78700551c60d9260b2c660d/ruff-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:560e0cd641e45591a3e42cb50ef61ce07162b9c233786663fdce2d8557d99818", size = 12847783 }, + { url = "https://files.pythonhosted.org/packages/4e/2a/0b6ac3dd045acf8aa229b12c9c17bb35508191b71a14904baf99573a21bd/ruff-0.12.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:71c83121512e7743fba5a8848c261dcc454cafb3ef2934a43f1b7a4eb5a447ea", size = 11702672 }, + { url = "https://files.pythonhosted.org/packages/9d/ee/f9fdc9f341b0430110de8b39a6ee5fa68c5706dc7c0aa940817947d6937e/ruff-0.12.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:de4429ef2ba091ecddedd300f4c3f24bca875d3d8b23340728c3cb0da81072c3", size = 11440626 }, + { url = "https://files.pythonhosted.org/packages/89/fb/b3aa2d482d05f44e4d197d1de5e3863feb13067b22c571b9561085c999dc/ruff-0.12.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a2cab5f60d5b65b50fba39a8950c8746df1627d54ba1197f970763917184b161", size = 12462162 }, + { url = "https://files.pythonhosted.org/packages/18/9f/5c5d93e1d00d854d5013c96e1a92c33b703a0332707a7cdbd0a4880a84fb/ruff-0.12.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:45c32487e14f60b88aad6be9fd5da5093dbefb0e3e1224131cb1d441d7cb7d46", size = 12913212 }, + { url = "https://files.pythonhosted.org/packages/71/13/ab9120add1c0e4604c71bfc2e4ef7d63bebece0cfe617013da289539cef8/ruff-0.12.8-py3-none-win32.whl", hash = "sha256:daf3475060a617fd5bc80638aeaf2f5937f10af3ec44464e280a9d2218e720d3", size = 11694382 }, + { url = "https://files.pythonhosted.org/packages/f6/dc/a2873b7c5001c62f46266685863bee2888caf469d1edac84bf3242074be2/ruff-0.12.8-py3-none-win_amd64.whl", hash = "sha256:7209531f1a1fcfbe8e46bcd7ab30e2f43604d8ba1c49029bb420b103d0b5f76e", size = 12740482 }, + { url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718 }, +] + +[[package]] +name = "virtualenv" +version = "20.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/60/4f20960df6c7b363a18a55ab034c8f2bcd5d9770d1f94f9370ec104c1855/virtualenv-20.33.1.tar.gz", hash = "sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8", size = 6082160 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/ff/ded57ac5ff40a09e6e198550bab075d780941e0b0f83cbeabd087c59383a/virtualenv-20.33.1-py3-none-any.whl", hash = "sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67", size = 6060362 }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, +]