diff --git a/tubular-tulips/.github/workflows/pre-commit.yaml b/tubular-tulips/.github/workflows/pre-commit.yaml new file mode 100644 index 00000000..528b5141 --- /dev/null +++ b/tubular-tulips/.github/workflows/pre-commit.yaml @@ -0,0 +1,29 @@ +# GitHub Action workflow for running pre-commit hooks + +name: Run pre-commit hooks + +# 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: + pre-commit: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v6 + + - name: Run pre-commit hooks + run: uv run pre-commit run --all-files --show-diff-on-failure diff --git a/tubular-tulips/.gitignore b/tubular-tulips/.gitignore new file mode 100644 index 00000000..5934afe9 --- /dev/null +++ b/tubular-tulips/.gitignore @@ -0,0 +1,203 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$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 + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.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 +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# 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 +.envrc +.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/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml diff --git a/tubular-tulips/.pre-commit-config.yaml b/tubular-tulips/.pre-commit-config.yaml new file mode 100644 index 00000000..429025f8 --- /dev/null +++ b/tubular-tulips/.pre-commit-config.yaml @@ -0,0 +1,29 @@ +# 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.8 + hooks: + - id: ruff-check + args: + - --fix + - id: ruff-format + + - repo: local + hooks: + - id: pytest + name: pytest + entry: uv run pytest + language: system + pass_filenames: false + always_run: true diff --git a/tubular-tulips/Dockerfile b/tubular-tulips/Dockerfile new file mode 100644 index 00000000..626833d7 --- /dev/null +++ b/tubular-tulips/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.13-alpine AS builder + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /app + +COPY ./pyproject.toml . +COPY ./uv.lock . + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN --mount=type=ssh uv sync --frozen --no-cache + +FROM python:3.13-alpine + +WORKDIR /app + +COPY --from=builder /app /app + +COPY ./server.py /app +COPY ./cj12 /app/cj12 +COPY ./static /app/static +COPY ./typings /app/typings + +ENV PATH="/app/.venv/bin:$PATH" +CMD ["python", "server.py"] diff --git a/tubular-tulips/LICENSE b/tubular-tulips/LICENSE new file mode 100644 index 00000000..9ff92fc9 --- /dev/null +++ b/tubular-tulips/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Tubular Tulips + +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/tubular-tulips/Makefile b/tubular-tulips/Makefile new file mode 100644 index 00000000..efcc1179 --- /dev/null +++ b/tubular-tulips/Makefile @@ -0,0 +1,15 @@ +.PHONY: build +build: + @docker build -t cj12-tubular-tulips . + +.PHONY: run +run: + @docker run --name cj12 -d -p 8000:8000 cj12-tubular-tulips + +.PHONY: pre +pre: + @pre-commit run --all-files + +.PHONY: sync +sync: + @uv sync --all-extras --all-groups --all-packages -U diff --git a/tubular-tulips/README.md b/tubular-tulips/README.md new file mode 100644 index 00000000..9a4906fe --- /dev/null +++ b/tubular-tulips/README.md @@ -0,0 +1,125 @@ +# Super Duper Encryption Tool + +## What's this? + +It's a file encryption and decryption tool. + +## Where do I put in the password? + +Um... password? + +Oh, buddy. + +Passwords are low-entropy relics of the 90s, reused across sites, and breached before you’ve even finished typing them. You deserve better. + +Our _superior_ tool trades in secure keys, not passwords. The kind of keys that don’t fit on a sticky note, can’t be phished, and would make a password manager weep with joy. + +In the big '25, if you’re still saying “just use a strong password,” you’re already compromised, which is why we provide _actually_ secure alternatives. + +## Methods + +### Chess + +1. Arrange the chess board in any way you want. +2. Upload a file (or do it before this) to be encrypted/decrypted with the board. + +--- + +### Location + +1. Zoom in very far and click anywhere on the map. +2. Upload a file (or do it before this) to be encrypted/decrypted with the location. + +--- + +### Safe + +1. Spin the dial to make a unique sequence. +2. Upload a file (or do it before this) to be encrypted/decrypted with the sequence. + +--- + +### Pattern + +1. Connect dots in any patterns to form a unique connection sequence +2. To change the pattern, simply redo step 1 to make a new pattern. The old pattern will disappear automatically. +3. Upload a file (or do it before this) to be encrypted/decrypted with the pattern. + +A 3x3 grid is the default, but you may choose to use a 4x4 or 6x6 grid. + +As the key is simply the sequence in of the connected dots based on their position, a 3x3 encrypted file can be decrypted from a 4x4 pattern. + +--- + +### Music + +1. Place down notes on the grid. +2. Upload a file (or do it before this) to be encrypted/decrypted with the song. + +--- + +### Direction + +1. Drag to make a unique sequence of directions. +2. Upload a file (or do it before this) to be encrypted/decrypted with the sequence. + +--- + +### Colour Picker + +1. Click and drag on scale of each red, green and blue to generate the desired colour as your encryption key. The colour and its hex representation will be shown at the bottom of the scale. +2. Upload a file (or do it before this) to be encrypted/decrypted with the pattern. + +Simply telling the other person about the colour of key will give them a hard time getting the exact code for decryption. + +## Running locally + +1. [Install uv](https://docs.astral.sh/uv/getting-started/installation/) +2. Clone our repository: `git clone https://github.com/xerif-wenghoe/code-jam-12` +3. Change into the directory: `cd code-jam-12` +4. Sync dependencies: `uv sync` +5. Run the server: `uv run server.py` +6. Access the tool at http://localhost:8000 + +## Technical details + +- We use [Pyodide](https://pyodide.org/) for logic and DOM manipulation. +- All methods eventually generate a 256-bit key for AES. +- [We implemented AES ourselves](cj12/aes.py) using numpy, all in the browser! +- Encrypted data is contained inside our [custom container format](cj12/container.py) that stores: + - Magic bytes (`SDET`) + - Method used + - Original filename + - Hash of decrypted data + - Encrypted data + +### [LINK](https://www.youtube.com/watch?v=MmZzPMagkXM) to presentation video + +## Contributions + +- [Xerif](https://github.com/xerif-wenghoe) ⭐: + - Pattern method + - Colour picker method + - Documentation +- [interrrp](https://github.com/interrrp): + - Method framework + - Container format + - Location method + - Documentation +- [MaxMinMedian](https://github.com/max-min-median): + - AES implementation + - Chess method + - Safe method + - Code documentation +- [Atonement](https://github.com/cin-lawrence): + - Initial method framework + - Direction lock method +- [greengas](https://github.com/greengas): + - Initial UI + - Music method +- [Candy](https://discord.com/users/1329407643365802025): + - Location method idea + +## License + +This project is licensed under the [MIT license](LICENSE). diff --git a/tubular-tulips/cj12/__init__.py b/tubular-tulips/cj12/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tubular-tulips/cj12/aes.py b/tubular-tulips/cj12/aes.py new file mode 100644 index 00000000..79a21e7e --- /dev/null +++ b/tubular-tulips/cj12/aes.py @@ -0,0 +1,263 @@ +import numpy as np + +__all__ = ["decrypt", "encrypt"] + + +def encrypt(data: bytes, key: bytes) -> bytes: + return AES(key).encrypt(data) + + +def decrypt(data: bytes, key: bytes) -> bytes: + return AES(key).decrypt(data) + + +class AES: + """ + Perform AES-128, -192 or -256 encryption and decryption. + + Usage: + ``` + aes = AES(key: bytes) # sets up an AES encryptor/decryptor object using key + ``` + """ + + # Set up S-box. + # The S-box is a crucial step in the AES algorithm. Its purpose to act as a lookup + # table to replace bytes with other bytes. This introduces confusion. + # fmt: off + sbox = np.array([ + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, + 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, + 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, + 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, + 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, + 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, + 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, + 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, + 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, + 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, + 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, + 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, + 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, + 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, + 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, + 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, + 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, + 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, + 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, + 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, + 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, + 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, + 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, + 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, + 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, + 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, + 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, + ], dtype=np.uint8) + # fmt: on + + # Set up inverse S-box for decryption + sbox_inv = np.empty(shape=sbox.shape, dtype=np.uint8) + for i in range(len(sbox)): + sbox_inv[sbox[i]] = i + + # During the creation of the round keys, the Rcon array is used to add a certain + # value to the key each round, to produce the key for the next round. + Rcon = np.array( + [ + [0x01, 0x00, 0x00, 0x00], + [0x02, 0x00, 0x00, 0x00], + [0x04, 0x00, 0x00, 0x00], + [0x08, 0x00, 0x00, 0x00], + [0x10, 0x00, 0x00, 0x00], + [0x20, 0x00, 0x00, 0x00], + [0x40, 0x00, 0x00, 0x00], + [0x80, 0x00, 0x00, 0x00], + [0x1B, 0x00, 0x00, 0x00], + [0x36, 0x00, 0x00, 0x00], + ], + dtype=np.uint8, + ) + + shift_idx = np.array( + [ + [0, 1, 2, 3], # first row unshifted + [1, 2, 3, 0], # second row rolled left by 1 + [2, 3, 0, 1], # third row rolled left by 2 + [3, 0, 1, 2], # last row rolled left by 3 + ], + ) + + unshift_idx = np.array( + [[0, 1, 2, 3], [3, 0, 1, 2], [2, 3, 0, 1], [1, 2, 3, 0]], + ) + + @staticmethod + def sub_bytes(arr: np.ndarray, sbox: np.ndarray) -> np.ndarray: + return sbox[arr] + + # performs multiplication by 2 under GF(2^8). + @staticmethod + def mul2(x: int) -> int: + result = (x << 1) & 0xFF + if x & 0x80: + result ^= 0x1B + return result + + # These 2 methods act on each separate column of the data array. Values are 'mixed' + # by multplying the matrix: + # + # [[2, 3, 1, 1], + # [1, 2, 3, 1], # noqa: ERA001 + # [1, 1, 2, 3], # noqa: ERA001 + # [3, 1, 1, 2]] (under GF(2^8)) + @staticmethod + def mix_column(col: np.ndarray) -> np.ndarray: + x2 = AES.mul2 + c0 = x2(col[0]) ^ (x2(col[1]) ^ col[1]) ^ col[2] ^ col[3] + c1 = col[0] ^ x2(col[1]) ^ (x2(col[2]) ^ col[2]) ^ col[3] + c2 = col[0] ^ col[1] ^ x2(col[2]) ^ x2(col[3]) ^ col[3] + c3 = x2(col[0]) ^ col[0] ^ col[1] ^ col[2] ^ x2(col[3]) + return np.array([c0, c1, c2, c3], dtype=np.uint8) + + @staticmethod + def mix_columns(grid: np.ndarray) -> None: + for col in range(4): + grid[:, col] = AES.mix_column(grid[:, col]) + + @staticmethod + def unmix_column(col: np.ndarray) -> np.ndarray: + x2 = AES.mul2 + c02 = x2(col[0]) + c04 = x2(c02) + c08 = x2(c04) + c12 = x2(col[1]) + c14 = x2(c12) + c18 = x2(c14) + c22 = x2(col[2]) + c24 = x2(c22) + c28 = x2(c24) + c32 = x2(col[3]) + c34 = x2(c32) + c38 = x2(c34) + c0 = ( + (c08 ^ c04 ^ c02) + ^ (c18 ^ c12 ^ col[1]) + ^ (c28 ^ c24 ^ col[2]) + ^ (c38 ^ col[3]) + ) # [14, 11, 13, 9] + c1 = ( + (c08 ^ col[0]) + ^ (c18 ^ c14 ^ c12) + ^ (c28 ^ c22 ^ col[2]) + ^ (c38 ^ c34 ^ col[3]) + ) # [9, 14, 11, 13] + c2 = ( + (c08 ^ c04 ^ col[0]) + ^ (c18 ^ col[1]) + ^ (c28 ^ c24 ^ c22) + ^ (c38 ^ c32 ^ col[3]) + ) # [13, 9, 14, 11] + c3 = ( + (c08 ^ c02 ^ col[0]) + ^ (c18 ^ c14 ^ col[1]) + ^ (c28 ^ col[2]) + ^ (c38 ^ c34 ^ c32) + ) # [11, 13, 9, 14] + return np.array([c0, c1, c2, c3], dtype=np.uint8) + + @staticmethod + def unmix_columns(grid: np.ndarray) -> None: + for col in range(4): + grid[:, col] = AES.unmix_column(grid[:, col]) + + @staticmethod + def shift_rows(arr: np.ndarray, shifter: np.ndarray) -> None: + arr[:] = arr[:, np.arange(4).reshape(4, 1), shifter] + + def __init__(self, key: bytes) -> None: + if len(key) not in {16, 24, 32}: + msg = "Incorrect number of bits (should be 128, 192, or 256-bit)" + raise ValueError(msg) + self.key = np.frombuffer(key, dtype=np.uint8) + self.Nk = len(self.key) // 4 # No. of 32-bit words in `key` + self.Nr = self.Nk + 6 # No. of encryption rounds + self.Nb = 4 # No. of words in AES state + self.round_keys = self._key_expansion() + + # The actual AES key is expanded into either 11, 13 or 15 round keys. + def _key_expansion(self) -> np.ndarray: + words = np.empty((self.Nb * (self.Nr + 1) * 4,), dtype=np.uint8) + words[: len(self.key)] = self.key + words = words.reshape(-1, 4) + rcon_iter = iter(AES.Rcon) + for i in range(self.Nk, len(words)): + if i % self.Nk == 0: + words[i] = ( + AES.sub_bytes(np.roll(words[i - 1], -1), AES.sbox) + ^ next(rcon_iter) + ^ words[i - 4] + ) + elif self.Nk == 8 and i % self.Nk == 4: # noqa: PLR2004 + words[i] = AES.sub_bytes(words[i - 1], AES.sbox) ^ words[i - 4] + else: + words[i] = words[i - 1] ^ words[i - 4] + return words.reshape(-1, 4, 4).transpose(0, 2, 1) + + def encrypt(self, data: bytes) -> bytes: + pad_length = 16 - len(data) % 16 + padded = ( + np.concat( + (np.frombuffer(data, dtype=np.uint8), np.full(pad_length, pad_length)), + ) + .reshape(-1, 4, 4) + .transpose(0, 2, 1) + ) + + keys_iter = iter(self.round_keys) + + # Pre-round: add round key + padded ^= next(keys_iter) + + for round_num in range(self.Nr): + padded = AES.sub_bytes(padded, AES.sbox) + + AES.shift_rows(padded, AES.shift_idx) + + if round_num != self.Nr - 1: + for grid in padded: + AES.mix_columns(grid) + + padded ^= next(keys_iter) + + return padded.transpose(0, 2, 1).tobytes() + + def decrypt(self, data: bytes) -> bytes: + encrypted = ( + np.frombuffer(data, dtype=np.uint8) + .reshape(-1, 4, 4) + .transpose(0, 2, 1) + .copy() + ) + + keys_iter = reversed(self.round_keys) + + # Pre-round: add round key + encrypted ^= next(keys_iter) + + for round_num in range(self.Nr): + if round_num != 0: + for grid in encrypted: + AES.unmix_columns(grid) + + AES.shift_rows(encrypted, AES.unshift_idx) + encrypted = AES.sub_bytes(encrypted, AES.sbox_inv) + encrypted ^= next(keys_iter) + + encrypted = encrypted.transpose(0, 2, 1).tobytes() + return encrypted[: -encrypted[-1]] diff --git a/tubular-tulips/cj12/app.py b/tubular-tulips/cj12/app.py new file mode 100644 index 00000000..7e02c7f7 --- /dev/null +++ b/tubular-tulips/cj12/app.py @@ -0,0 +1,112 @@ +from contextlib import suppress +from hashlib import sha256 + +from js import URL, Blob, alert, document +from pyodide.ffi import to_js + +from cj12.aes import decrypt, encrypt +from cj12.container import Container, InvalidMagicError +from cj12.dom import ( + ButtonElement, + add_event_listener, + elem_by_id, + fetch_text, +) +from cj12.file import FileInput +from cj12.methods.methods import Methods, methods + + +class App: + async def start(self) -> None: + document.title = "Super Duper Encryption Tool" + document.body.innerHTML = await fetch_text("/ui.html") + + self._data: bytes | None = None + self._key: bytes | None = None + self._container: Container | None = None + + self._file_input = FileInput(self._on_data_received) + self._filename = "" + + self._encrypt_button = elem_by_id("encrypt-button", ButtonElement) + self._decrypt_button = elem_by_id("decrypt-button", ButtonElement) + add_event_listener(self._encrypt_button, "click", self._on_encrypt_button) + add_event_listener(self._decrypt_button, "click", self._on_decrypt_button) + + self._methods = Methods(self._on_key_received) + await self._methods.register_selections() + + async def _on_data_received(self, data: bytes, filename: str) -> None: + self._data = data + self._filename = filename + + with suppress(InvalidMagicError): + self._container = Container.from_bytes(data) + + if self._container is not None: + for method in methods: + if method.byte == self._container.method: + await self._methods.go_to(method) + + self._update_button_availability() + + async def _on_key_received(self, key: bytes | None) -> None: + self._key = key + self._update_button_availability() + + def _update_button_availability(self) -> None: + disabled = self._data is None or self._key is None + self._encrypt_button.disabled = disabled + self._decrypt_button.disabled = disabled + + if self._container is None: + self._decrypt_button.disabled = True + + async def _on_encrypt_button(self, _: object) -> None: + if self._methods.current is None: + return + + data, key = self._ensure_data_and_key() + + container = Container( + method=self._methods.current.byte, + original_filename=self._filename, + data_hash=sha256(data).digest(), + data=encrypt(data, sha256(key).digest()), + ) + + self._download_file(bytes(container), "encrypted_file.bin") + + async def _on_decrypt_button(self, _: object) -> None: + if self._container is None: + return + + _, key = self._ensure_data_and_key() + + decrypted = decrypt(self._container.data, sha256(key).digest()) + decrypted_hash = sha256(decrypted).digest() + + if decrypted_hash != self._container.data_hash: + alert("Incorrect key") + return + + self._download_file(decrypted, self._container.original_filename) + + def _download_file(self, data: bytes, filename: str) -> None: + u8 = to_js(data, create_pyproxies=False) + blob = Blob.new([u8], {"type": "application/octet-stream"}) + url = URL.createObjectURL(blob) + a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + + def _ensure_data_and_key(self) -> tuple[bytes, bytes]: + if self._data is None or self._key is None: + msg = "Data or key not set" + raise ValueError(msg) + + return (self._data, self._key) diff --git a/tubular-tulips/cj12/container.py b/tubular-tulips/cj12/container.py new file mode 100644 index 00000000..ef3e466b --- /dev/null +++ b/tubular-tulips/cj12/container.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import struct +from dataclasses import dataclass + + +class InvalidMagicError(Exception): ... + + +MAGIC = b"SDET" + + +@dataclass(frozen=True) +class Container: + method: int + original_filename: str + data_hash: bytes + data: bytes + + def __bytes__(self) -> bytes: + filename_bytes = self.original_filename.encode("utf-8") + + return MAGIC + struct.pack( + f" Container: + if not raw.startswith(MAGIC): + raise InvalidMagicError + + offset = len(MAGIC) + method, filename_length, data_length = struct.unpack( + " T: + """ + Get an element by its ID. + + Usage: + + - `elem_by_id("my-element")`: Return the element of ID `my-element` and raise + a TypeError if it cannot be found. + + - `elem_by_id("my-button", ButtonElement)`: Same as before, but return an + element of type `ButtonElement`. + """ + + elem = document.getElementById(elem_id) + + if isinstance(elem, JsNull): + msg = f"Element with ID {elem_id} not found" + raise TypeError(msg) + + return cast("T", elem) + + +def add_event_listener( + elem: JsDomElement, + event: str, + listener: Callable[[object], Awaitable[None] | None], +) -> None: + elem.addEventListener(event, create_proxy(listener)) + + +async def fetch_text(url: str) -> str: + resp = await pyfetch(url) + return await resp.text() diff --git a/tubular-tulips/cj12/file.py b/tubular-tulips/cj12/file.py new file mode 100644 index 00000000..2788e575 --- /dev/null +++ b/tubular-tulips/cj12/file.py @@ -0,0 +1,36 @@ +from collections.abc import Awaitable, Callable +from typing import Any + +from js import FileReader, Uint8Array, alert + +from cj12.dom import add_event_listener, elem_by_id + +# async on_data_received(data, filename) +DataReceiveCallback = Callable[[bytes, str], Awaitable[None]] + + +class FileInput: + def __init__(self, on_data_received: DataReceiveCallback) -> None: + self._callback = on_data_received + + self._reader = FileReader.new() + add_event_listener(self._reader, "load", self._on_data_load) + add_event_listener(self._reader, "error", self._on_error) + add_event_listener(elem_by_id("file-input"), "change", self._on_file_change) + + self._filename = "" + + def _on_file_change(self, event: Any) -> None: + file = event.target.files.item(0) + self._filename = file.name + elem_by_id("dropzone").innerText = f"{file.name} ({file.size / 1024:.2f} KB)" + self._reader.readAsArrayBuffer(file) + + async def _on_data_load(self, _: object) -> None: + await self._callback( + bytes(Uint8Array.new(self._reader.result)), + self._filename, + ) + + async def _on_error(self, _: object) -> None: + alert("Failed to read file") diff --git a/tubular-tulips/cj12/methods/__init__.py b/tubular-tulips/cj12/methods/__init__.py new file mode 100644 index 00000000..2e639d4a --- /dev/null +++ b/tubular-tulips/cj12/methods/__init__.py @@ -0,0 +1,3 @@ +from collections.abc import Awaitable, Callable + +KeyReceiveCallback = Callable[[bytes | None], Awaitable[None]] diff --git a/tubular-tulips/cj12/methods/chess.py b/tubular-tulips/cj12/methods/chess.py new file mode 100644 index 00000000..660989ec --- /dev/null +++ b/tubular-tulips/cj12/methods/chess.py @@ -0,0 +1,329 @@ +from collections.abc import Callable +from typing import Any + +from js import Promise, window + +from cj12.dom import add_event_listener, elem_by_id +from cj12.methods import KeyReceiveCallback + +SQUARE_SIZE = 44 +PIECE_SCALE = 2.5 +PIECE_BASE_OFFSET = 8 +MOUSE_DEADZONE_RADIUS = 7 + + +class ChessMethod: + byte = 0x02 + static_id = "chess" + name = "Chess" + description = "A certain chess position" + + on_key_received: KeyReceiveCallback | None = None + chessboard: list[list[str | None]] + + async def setup(self) -> None: + self.chesspieces = None + self.chessboard = [[None] * 8 for _ in range(8)] + self.dragging = None + self.last_mousedown = None + + self.canvas_board: Any = elem_by_id("background-canvas") + self.canvas_pieces: Any = elem_by_id("piece-canvas") + self.canvas_pieces.setAttribute("tabindex", "0") + + self.ctx_board = self.canvas_board.getContext("2d") + self.ctx_board.translate( + self.canvas_board.width / 2, + self.canvas_board.height / 2, + ) + self.ctx_board.imageSmoothingEnabled = False + + self.ctx_pieces = self.canvas_pieces.getContext("2d") + self.ctx_pieces.imageSmoothingEnabled = False + self.ctx_pieces.translate( + self.canvas_pieces.width / 2, + self.canvas_pieces.height / 2, + ) + + add_event_listener(self.canvas_pieces, "mousedown", self.on_mouse_down) + add_event_listener(self.canvas_pieces, "mouseup", self.on_mouse_up) + add_event_listener(self.canvas_pieces, "mousemove", self.on_mouse_move) + add_event_listener(self.canvas_pieces, "keydown", self.on_keypress) + + # Control buttons and handlers + self.btn_clear: Any = elem_by_id("btn-clear-board") + self.btn_initial: Any = elem_by_id("btn-initial-position") + add_event_listener(self.btn_clear, "click", self.on_clear_board) + add_event_listener(self.btn_initial, "click", self.on_initial_position) + await self.load_chesspieces() + + # Draw the initial board + self.draw_board() + # Start with the standard initial position for convenience + self.set_initial_position() + self.draw_pieces_on_board(0, 0) + await self.update_key() + + async def load_chesspieces(self) -> None: + def load_image(src: str) -> object: + def executor( + resolve: Callable[[object], None], + _reject: Callable[[object], None], + ) -> None: + img = window.Image.new() + img.onload = lambda *_, img=img: resolve(img) + img.src = src + + return Promise.new(executor) + + piece_names = [ + f"{color}_{piece}" + for piece in ("King", "Queen", "Rook", "Bishop", "Knight", "Pawn") + for color in "BW" + ] + images = await Promise.all( + [ + load_image(f"/methods/chess/pieces/{piece_name}.png") + for piece_name in piece_names + ], + ) + self.chesspieces = dict(zip(piece_names, images, strict=False)) + self.piece_width = images[0].width * PIECE_SCALE + self.piece_height = images[0].height * PIECE_SCALE + + # --- Controls --- + def clear_board(self) -> None: + self.chessboard = [[None] * 8 for _ in range(8)] + self.dragging = None + # Redraw pieces layer cleared + self.ctx_pieces.clearRect( + -self.canvas_pieces.width / 2, + -self.canvas_pieces.height / 2, + self.canvas_pieces.width, + self.canvas_pieces.height, + ) + self.draw_pieces_on_board(0, 0) + + async def on_clear_board(self, _event: object) -> None: + self.clear_board() + await self.update_key() + + def set_initial_position(self) -> None: + # Use the piece names already loaded in pickzones/chesspieces. + # Expected: W_King, W_Queen, W_Rook, W_Bishop, W_Knight, W_Pawn + # Board indices: row 0 at top (black side), row 7 at bottom (white side) + w = { + "K": "W_King", + "Q": "W_Queen", + "R": "W_Rook", + "B": "W_Bishop", + "N": "W_Knight", + "P": "W_Pawn", + } + b = { + "K": "B_King", + "Q": "B_Queen", + "R": "B_Rook", + "B": "B_Bishop", + "N": "B_Knight", + "P": "B_Pawn", + } + self.chessboard = [[None] * 8 for _ in range(8)] + # Black back rank (row 0) + for c, pc in enumerate(("R", "N", "B", "Q", "K", "B", "N", "R")): + self.chessboard[0][c] = b[pc] + # Black pawns (row 1) + for c in range(8): + self.chessboard[1][c] = b["P"] + # Empty rows 2..5 already None + # White pawns (row 6) + for c in range(8): + self.chessboard[6][c] = w["P"] + # White back rank (row 7) + for c, pc in enumerate(("R", "N", "B", "Q", "K", "B", "N", "R")): + self.chessboard[7][c] = w[pc] + + async def on_initial_position(self, _event: object) -> None: + self.set_initial_position() + # Redraw + self.draw_pieces_on_board(0, 0) + await self.update_key() + + def draw_board(self) -> None: + if self.chesspieces is None: + return + ctx, ssz = self.ctx_board, SQUARE_SIZE + ctx.fillStyle = "#FBDEBD" + ctx.fillRect(-4 * ssz - 5, -4 * ssz - 5, 8 * ssz + 10, 8 * ssz + 10) + ctx.clearRect(-4 * ssz - 2, -4 * ssz - 2, 8 * ssz + 4, 8 * ssz + 4) + ctx.fillRect(-4 * ssz, -4 * ssz, 8 * ssz, 8 * ssz) + ctx.fillStyle = "#603814" + for x in range(-4, 4): + for y in range(-4 + (x % 2 == 0), 4, 2): + ctx.fillRect(x * ssz, y * ssz, ssz, ssz) + self.pickzones = {} + piece_names = iter(self.chesspieces) + for row in (-1, 0, 1): + y = row * (self.piece_height + 1) + for col in (0, 1): + x = 4 * ssz + 10 + self.piece_width / 2 + col * (self.piece_width + 5) + for xx in -x, x: + self.pickzones[piece := next(piece_names)] = (xx, y) + self.draw_piece( + piece, + xx, + y, + (-self.piece_width / 2, -self.piece_height / 2), + ctx, + ) + + def draw_piece( + self, + piece_name: str, + x: float, + y: float, + offset: tuple[float, float] | None = None, + ctx: object | None = None, + ) -> None: # default anchor point is the center of the bottom edge + if self.chesspieces is None: + return + img = self.chesspieces[piece_name] + dx, dy = ( + (-self.piece_width / 2, -self.piece_height) if offset is None else offset + ) + (self.ctx_pieces if ctx is None else ctx).drawImage( + img, + x + dx, + y + dy, + self.piece_width, + self.piece_height, + ) + + def draw_pieces_on_board(self, mx: float, my: float) -> None: + self.ctx_pieces.clearRect( + -self.canvas_pieces.width / 2, + -self.canvas_pieces.height / 2, + self.canvas_pieces.width, + self.canvas_pieces.height, + ) + ssz = SQUARE_SIZE + dragged_piece_drawn = self.dragging is None + drag = self.dragging + for r in range(8): + piece_bottom_y = (r - 3) * ssz - PIECE_BASE_OFFSET + if not dragged_piece_drawn and (drag is not None) and my < piece_bottom_y: + self.draw_piece(drag, mx, my) + dragged_piece_drawn = True + for c in range(8): + if (piece := self.chessboard[r][c]) is not None: + self.draw_piece(piece, (c - 3.5) * ssz, piece_bottom_y) + if not dragged_piece_drawn and drag is not None: + self.draw_piece(drag, mx, my) + + def get_mouse_coords(self, event: object) -> tuple[float, float]: + rect = self.canvas_board.getBoundingClientRect() + mx = event.clientX - rect.left - rect.width // 2 + my = event.clientY - rect.top - rect.height // 2 + return mx, my + + def mouse_on_board_square(self, mx: float, my: float): # noqa: ANN201 + r, c = map(lambda x: int(x // SQUARE_SIZE) + 4, (my, mx)) # noqa: C417 + return (r, c) if r in range(8) and c in range(8) else None + + async def on_mouse_down(self, event: object) -> None: + mx, my = self.get_mouse_coords(event) + if board_square := self.mouse_on_board_square(mx, my): + r, c = board_square + self.dragging, self.chessboard[r][c] = self.chessboard[r][c], self.dragging + self.last_mousedown = mx, my + await self.update_key() + else: + for piece in self.pickzones: + if ( + abs(mx - self.pickzones[piece][0]) < self.piece_width // 2 + and abs(my - self.pickzones[piece][1]) < self.piece_height // 2 + ): + self.dragging = piece + self.last_mousedown = mx, my + break + else: + self.dragging = None + self.draw_pieces_on_board(mx, my) + + def on_mouse_move(self, event: object) -> None: + mx, my = self.get_mouse_coords(event) + self.last_mouse_pos = mx, my + if not self.dragging: + return + self.draw_pieces_on_board(mx, my) + + async def on_mouse_up(self, event: object) -> None: + mx, my = self.get_mouse_coords(event) + px, py = self.last_mousedown + if (px - mx) ** 2 + (py - my) ** 2 > MOUSE_DEADZONE_RADIUS**2: # noqa: SIM102 + if self.dragging is not None and ( + board_square := self.mouse_on_board_square(mx, my) + ): + r, c = board_square + self.chessboard[r][c], self.dragging = ( + self.dragging, + self.chessboard[r][c], + ) + self.draw_pieces_on_board(mx, my) + await self.update_key() + + async def on_double_click(self, event: object) -> None: + mx, my = self.get_mouse_coords(event) + + if (board_square := self.mouse_on_board_square(mx, my)) is None: + return + + r, c = board_square + if self.chessboard[r][c] is None: + return + + self.chessboard[r][c] = None + self.draw_pieces_on_board(mx, my) + await self.update_key() + + async def on_keypress(self, event: object) -> None: + piece = { + " ": None, + "K": "King", + "Q": "Queen", + "R": "Rook", + "B": "Bishop", + "N": "Knight", + "P": "Pawn", + }.get(event.key.upper(), False) + if ( + piece is False + or (board_square := self.mouse_on_board_square(*self.last_mouse_pos)) + is None + ): + return + r, c = board_square + self.chessboard[r][c] = piece and ( + f"B_{piece}" if self.chessboard[r][c] == f"W_{piece}" else f"W_{piece}" + ) + self.draw_pieces_on_board(*self.last_mouse_pos) + await self.update_key() + + async def update_key(self) -> None: + conversion = { + None: 0, + "W_King": 1, + "W_Queen": 2, + "W_Rook": 3, + "W_Bishop": 4, + "W_Knight": 5, + "W_Pawn": 6, + "B_King": 7, + "B_Queen": 8, + "B_Rook": 9, + "B_Bishop": 10, + "B_Knight": 11, + "B_Pawn": 12, + } + key = [conversion[piece] for row in self.chessboard for piece in row] + await self.on_key_received(bytes(key)) diff --git a/tubular-tulips/cj12/methods/colour_picker.py b/tubular-tulips/cj12/methods/colour_picker.py new file mode 100644 index 00000000..3ea1a482 --- /dev/null +++ b/tubular-tulips/cj12/methods/colour_picker.py @@ -0,0 +1,99 @@ +from cj12.dom import add_event_listener, elem_by_id +from cj12.methods import KeyReceiveCallback + +COLOUR_HEX = { + "red": "#FF0000", + "green": "#00FF00", + "blue": "#0000FF", +} + + +class ColourPickerMethod: + byte = 0x08 + static_id = "colour_picker" + name = "Colour Picker" + description = "A colour hex code lock" + + on_key_received: KeyReceiveCallback | None = None + + async def setup(self) -> None: + self.is_mouse_down: bool = False + self.hexcodes: dict[str, str] = { + "red": "00", + "green": "00", + "blue": "00", + } + + self.red_canvas = elem_by_id("red-canvas") + self.green_canvas = elem_by_id("green-canvas") + self.blue_canvas = elem_by_id("blue-canvas") + self.red_ctx = self.red_canvas.getContext("2d", {"willReadFrequently": True}) + self.green_ctx = self.green_canvas.getContext( + "2d", + {"willReadFrequently": True}, + ) + self.blue_ctx = self.blue_canvas.getContext("2d", {"willReadFrequently": True}) + + self.RGB_canvas = [self.red_canvas, self.blue_canvas, self.green_canvas] + self.RGB_ctx = [self.red_ctx, self.green_ctx, self.blue_ctx] + self.RGB = self.hexcodes.keys() + + self.setup_canvas() + self.setup_event_listener() + + def setup_canvas(self) -> None: + for canvas, ctx, colour in zip( + self.RGB_canvas, + self.RGB_ctx, + self.RGB, + strict=False, + ): + gradient = ctx.createLinearGradient(0, 0, canvas.width - 1, 0) + gradient.addColorStop(0, "black") + gradient.addColorStop(1, COLOUR_HEX[colour]) + ctx.fillStyle = gradient + ctx.fillRect(0, 0, canvas.width, canvas.height) + + def setup_event_listener(self) -> None: + def mouse_down(_evt: object) -> None: + self.is_mouse_down = True + + async def mouse_up(_evt: object) -> None: + self.is_mouse_down = False + if self.on_key_received is not None: + await self.on_key_received("".join(self.hexcodes.values()).encode()) + + async def on_click(evt: object) -> None: + self.update_colour(evt) + if self.on_key_received is not None: + await self.on_key_received("".join(self.hexcodes.values()).encode()) + + for canvas in self.RGB_canvas: + add_event_listener(canvas, "mousedown", mouse_down) + add_event_listener(canvas, "mousemove", self.on_move) + add_event_listener(canvas, "mouseup", mouse_up) + add_event_listener(canvas, "click", on_click) + add_event_listener(canvas, "mouseleave", mouse_up) + + def on_move(self, evt: object) -> None: + if not self.is_mouse_down: + return + + self.update_colour(evt) + + def update_colour(self, evt: object) -> None: + canvas = evt.target + for idx, colour in enumerate(self.hexcodes.keys()): + if canvas.id == f"{colour}-canvas": + pixel = ( + self.RGB_ctx[idx].getImageData(evt.offsetX, evt.offsetY, 1, 1).data + ) + + self.hexcodes[colour] = hex(pixel[idx])[2:].zfill(2) + + self.update_display() + + def update_display(self) -> None: + hexcode = "#" + "".join(self.hexcodes.values()) + elem_by_id("output-colour").style.backgroundColor = hexcode + elem_by_id("output-value").innerText = hexcode.upper() diff --git a/tubular-tulips/cj12/methods/direction.py b/tubular-tulips/cj12/methods/direction.py new file mode 100644 index 00000000..e781dbdf --- /dev/null +++ b/tubular-tulips/cj12/methods/direction.py @@ -0,0 +1,368 @@ +from dataclasses import dataclass +from math import atan2, cos, pi, sin, sqrt +from typing import ClassVar + +from cj12.dom import add_event_listener, elem_by_id +from cj12.methods import KeyReceiveCallback + +KNOB_RADIUS = 160 +KNOB_CENTER_RADIUS = 25 +DIRECTION_THRESHOLD = 40 +CANVAS_SIZE = 400 +MIN_DRAG_DISTANCE = 30 + + +@dataclass +class Direction: + name: str + angle: float + dx: int + dy: int + + +class DirectionLockMethod: + byte = 0x06 + static_id = "direction" + name = "Direction Lock" + description = "An 8-directional lock using a draggable knob" + + on_key_received: KeyReceiveCallback | None = None + + # Define the 8 directions + DIRECTIONS: ClassVar[list[Direction]] = [ + Direction("top", -pi / 2, 0, -1), + Direction("topright", -pi / 4, 1, -1), + Direction("right", 0, 1, 0), + Direction("bottomright", pi / 4, 1, 1), + Direction("bottom", pi / 2, 0, 1), + Direction("bottomleft", 3 * pi / 4, -1, 1), + Direction("left", pi, -1, 0), + Direction("topleft", -3 * pi / 4, -1, -1), + ] + + async def setup(self) -> None: + self.sequence: list[str] = [] + self.is_mouse_down: bool = False + self.is_dragging: bool = False + self.drag_start_x: float = 0 + self.drag_start_y: float = 0 + self.knob_center_x = CANVAS_SIZE // 2 + self.knob_center_y = CANVAS_SIZE // 2 + self.knob_offset_x = 0 + self.knob_offset_y = 0 + + self.canvas = elem_by_id("direction-canvas") + + # Set canvas size for high DPI displays + self.canvas.width = CANVAS_SIZE + self.canvas.height = CANVAS_SIZE + + self.ctx = self.canvas.getContext("2d") + + self.output_textarea = elem_by_id("direction-output") + + # Setup control buttons + self.btn_remove_last = elem_by_id("btn-remove-last") + self.btn_reset_all = elem_by_id("btn-reset-all") + + # Add event listeners for buttons + add_event_listener(self.btn_remove_last, "click", self.on_remove_last) + add_event_listener(self.btn_reset_all, "click", self.on_reset_all) + + self.draw_knob() + self.add_event_listeners() + + def draw_knob(self) -> None: # noqa: PLR0915 + """Draw the knob with 8 direction indicators and the draggable center knob""" + ctx = self.ctx + ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE) + + # Draw main knob circle (background) - transparent + ctx.beginPath() + ctx.arc(self.knob_center_x, self.knob_center_y, KNOB_RADIUS, 0, 2 * pi) + ctx.fillStyle = "rgba(240, 240, 240, 0.1)" # Very transparent + ctx.fill() + ctx.strokeStyle = "rgba(51, 51, 51, 0.3)" # Semi-transparent + ctx.lineWidth = 2 + ctx.stroke() + + # Draw direction trails (gray paths) + for direction in self.DIRECTIONS: + # Calculate trail start and end points + trail_start_x = self.knob_center_x + (KNOB_CENTER_RADIUS + 5) * cos( + direction.angle, + ) + trail_start_y = self.knob_center_y + (KNOB_CENTER_RADIUS + 5) * sin( + direction.angle, + ) + trail_end_x = self.knob_center_x + (KNOB_RADIUS - 15) * cos(direction.angle) + trail_end_y = self.knob_center_y + (KNOB_RADIUS - 15) * sin(direction.angle) + + # Draw trail line + ctx.beginPath() + ctx.moveTo(trail_start_x, trail_start_y) + ctx.lineTo(trail_end_x, trail_end_y) + ctx.strokeStyle = "rgba(128, 128, 128, 0.4)" # Semi-transparent gray + ctx.lineWidth = 2 + ctx.stroke() + + # Draw direction indicators + for direction in self.DIRECTIONS: + # Calculate position on the knob edge + x = self.knob_center_x + (KNOB_RADIUS - 10) * cos(direction.angle) + y = self.knob_center_y + (KNOB_RADIUS - 10) * sin(direction.angle) + + # Draw direction indicator with highlight if dragging towards it + ctx.beginPath() + ctx.arc(x, y, 10, 0, 2 * pi) + + # Highlight the direction being targeted + if self.is_dragging: + # Get current mouse position for direction detection + knob_x = self.knob_center_x + self.knob_offset_x + knob_y = self.knob_center_y + self.knob_offset_y + current_direction = self.get_direction_from_coords(knob_x, knob_y) + if current_direction and current_direction.name == direction.name: + ctx.fillStyle = "rgba(70, 130, 180, 0.8)" # Highlight color + else: + ctx.fillStyle = "rgba(102, 102, 102, 0.6)" + else: + ctx.fillStyle = "rgba(102, 102, 102, 0.6)" + ctx.fill() + + # Draw direction label + ctx.fillStyle = "rgba(51, 51, 51, 0.8)" + ctx.font = "14px Arial" + ctx.textAlign = "center" + ctx.textBaseline = "middle" + + # Position label outside the knob + label_x = self.knob_center_x + (KNOB_RADIUS + 20) * cos(direction.angle) + label_y = self.knob_center_y + (KNOB_RADIUS + 20) * sin(direction.angle) + ctx.fillText(direction.name, label_x, label_y) + + # Draw the draggable center knob (light blue) + knob_x = self.knob_center_x + self.knob_offset_x + knob_y = self.knob_center_y + self.knob_offset_y + + # Add shadow effect + ctx.beginPath() + ctx.arc(knob_x + 3, knob_y + 3, KNOB_CENTER_RADIUS, 0, 2 * pi) + ctx.fillStyle = "rgba(0, 0, 0, 0.3)" + ctx.fill() + + # Draw main knob with different color when dragging + ctx.beginPath() + ctx.arc(knob_x, knob_y, KNOB_CENTER_RADIUS, 0, 2 * pi) + if self.is_dragging: + ctx.fillStyle = "rgba(95, 158, 160, 0.9)" # Darker blue when dragging + else: + ctx.fillStyle = "rgba(135, 206, 235, 0.9)" # Light blue + ctx.fill() + ctx.strokeStyle = "rgba(70, 130, 180, 0.8)" # Steel blue border + ctx.lineWidth = 3 + ctx.stroke() + + # Draw center dot + ctx.beginPath() + ctx.arc(knob_x, knob_y, 4, 0, 2 * pi) + ctx.fillStyle = "rgba(51, 51, 51, 0.8)" + ctx.fill() + + # Draw drag line when dragging + if self.is_dragging and (self.knob_offset_x != 0 or self.knob_offset_y != 0): + ctx.beginPath() + ctx.moveTo(self.knob_center_x, self.knob_center_y) + ctx.lineTo(knob_x, knob_y) + ctx.strokeStyle = "rgba(70, 130, 180, 0.6)" + ctx.lineWidth = 2 + ctx.setLineDash([5, 5]) + ctx.stroke() + ctx.setLineDash([]) # Reset dash pattern + + def get_direction_from_coords(self, x: float, y: float) -> Direction | None: + """Determine which direction the coordinates represent""" + # Calculate distance from center + dx = x - self.knob_center_x + dy = y - self.knob_center_y + distance = sqrt(dx * dx + dy * dy) + + # Check if within knob radius + if distance < KNOB_RADIUS - DIRECTION_THRESHOLD: + return None + + # Calculate angle + angle = atan2(dy, dx) + + # Find the closest direction + min_diff = float("inf") + closest_direction = None + + for direction in self.DIRECTIONS: + # Calculate angle difference (handling wrap-around) + diff = abs(angle - direction.angle) + diff = min(diff, 2 * pi - diff) + + if diff < min_diff: + min_diff = diff + closest_direction = direction + + return closest_direction + + def calculate_drag_distance(self, current_x: float, current_y: float) -> float: + """Calculate the distance from drag start to current position""" + dx = current_x - self.drag_start_x + dy = current_y - self.drag_start_y + return sqrt(dx * dx + dy * dy) + + def constrain_knob_position(self, x: float, y: float) -> tuple[float, float]: + """Constrain the knob position to stay within the main circle""" + dx = x - self.knob_center_x + dy = y - self.knob_center_y + distance = sqrt(dx * dx + dy * dy) + + max_distance = KNOB_RADIUS - KNOB_CENTER_RADIUS - 10 + + if distance > max_distance: + # Normalize and scale to max distance + dx = (dx / distance) * max_distance + dy = (dy / distance) * max_distance + x = self.knob_center_x + dx + y = self.knob_center_y + dy + + return x, y + + def get_canvas_coordinates(self, event: object) -> tuple[float, float]: + """Get coordinates relative to canvas, handling boundary cases and scaling""" + rect = self.canvas.getBoundingClientRect() + x = event.clientX - rect.left + y = event.clientY - rect.top + + # Scale coordinates from CSS size to actual canvas size + scale_x = CANVAS_SIZE / rect.width + scale_y = CANVAS_SIZE / rect.height + + x = x * scale_x + y = y * scale_y + + # Clamp coordinates to canvas boundaries + x = max(0, min(x, CANVAS_SIZE)) + y = max(0, min(y, CANVAS_SIZE)) + + return x, y + + def on_mouse_down(self, event: object) -> None: + """Handle mouse down event""" + self.is_mouse_down = True + self.is_dragging = False + + x, y = self.get_canvas_coordinates(event) + + # Check if click is on the center knob + knob_x = self.knob_center_x + self.knob_offset_x + knob_y = self.knob_center_y + self.knob_offset_y + dx = x - knob_x + dy = y - knob_y + distance = sqrt(dx * dx + dy * dy) + + if distance <= KNOB_CENTER_RADIUS: + self.drag_start_x = x + self.drag_start_y = y + self.draw_knob() + + def on_mouse_move(self, event: object) -> None: + """Handle mouse move event""" + if not self.is_mouse_down: + return + + x, y = self.get_canvas_coordinates(event) + + # Check if we've moved enough to start dragging + if not self.is_dragging: + distance = self.calculate_drag_distance(x, y) + if distance >= MIN_DRAG_DISTANCE: + self.is_dragging = True + + if self.is_dragging: + # Check if mouse is outside canvas boundaries + if not self.is_within_canvas(x, y): + # Reset knob position immediately when dragged outside + self.reset_knob_position() + return + + # Update knob position + constrained_x, constrained_y = self.constrain_knob_position(x, y) + self.knob_offset_x = constrained_x - self.knob_center_x + self.knob_offset_y = constrained_y - self.knob_center_y + + self.draw_knob() + + async def on_mouse_up(self, event: object) -> None: + """Handle mouse up event""" + if not self.is_mouse_down: + return + + x, y = self.get_canvas_coordinates(event) + + # Only process mouse up if within canvas boundaries + if not self.is_within_canvas(x, y): + # Reset knob position and state without recording direction + self.reset_knob_position() + self.is_mouse_down = False + self.is_dragging = False + return + + # Only record direction if we were dragging and moved enough distance + if self.is_dragging: + direction = self.get_direction_from_coords(x, y) + if direction: + self.sequence.append(direction.name) + self.update_output() + + # Reset knob position + self.reset_knob_position() + + self.is_mouse_down = False + self.is_dragging = False + + # Send sequence if we have one + if self.sequence and self.on_key_received is not None: + sequence_str = ",".join(self.sequence) + await self.on_key_received(sequence_str.encode()) + + def update_output(self) -> None: + """Update the text area with current sequence""" + if self.output_textarea: + self.output_textarea.value = " → ".join(self.sequence) + + def add_event_listeners(self) -> None: + """Add mouse event listeners to canvas""" + add_event_listener(self.canvas, "mousedown", self.on_mouse_down) + add_event_listener(self.canvas, "mouseup", self.on_mouse_up) + add_event_listener(self.canvas, "mousemove", self.on_mouse_move) + + def is_within_canvas(self, x: float, y: float) -> bool: + """Check if coordinates are within canvas boundaries""" + return 0 <= x <= CANVAS_SIZE and 0 <= y <= CANVAS_SIZE + + def reset_knob_position(self) -> None: + """Reset knob to center position""" + self.knob_offset_x = 0 + self.knob_offset_y = 0 + self.draw_knob() + + async def on_remove_last(self, _event: object) -> None: + """Remove the last recorded move""" + if self.sequence: + self.sequence.pop() + self.update_output() + if self.on_key_received is not None: + sequence_str = ",".join(self.sequence) + await self.on_key_received(sequence_str.encode()) + + async def on_reset_all(self, _event: object) -> None: + """Reset all recorded moves""" + self.sequence.clear() + self.update_output() + if self.on_key_received is not None: + await self.on_key_received(b"") diff --git a/tubular-tulips/cj12/methods/location.py b/tubular-tulips/cj12/methods/location.py new file mode 100644 index 00000000..aaf5c4c5 --- /dev/null +++ b/tubular-tulips/cj12/methods/location.py @@ -0,0 +1,66 @@ +from js import window +from pyodide.ffi import create_proxy, to_js + +from cj12.methods import KeyReceiveCallback + + +class LocationMethod: + byte = 0x03 + static_id = "location" + name = "Location" + description = "A physical location" + + on_key_received: KeyReceiveCallback | None = None + + async def setup(self) -> None: + self._map = None + self._rect = None + self._marker = None + + m = window.L.map("map").setView(to_js([0, 0]), 1) + self._map = m + + layer_url = "https://tile.openstreetmap.org/{z}/{x}/{y}.png" + layer_opts = { + "maxZoom": 19, + "attribution": '© OpenStreetMap', + } + window.L.tileLayer(layer_url, layer_opts).addTo(m) + + async def on_click(e: object) -> None: + lat = float(e.latlng.lat) + lng = float(e.latlng.lng) + + rlat = round(lat, 3) + rlng = round(lng, 3) + key_str = f"{rlat:.4f},{rlng:.4f}" + + cell_half = 0.0005 + lat_min = rlat - cell_half + lat_max = rlat + cell_half + lng_min = rlng - cell_half + lng_max = rlng + cell_half + bounds = to_js([[lat_min, lng_min], [lat_max, lng_max]]) + + if self._rect is None: + self._rect = window.L.rectangle( + bounds, + { + "color": "#3388ff", + "weight": 1, + "fillOpacity": 0.1, + "interactive": False, + }, + ).addTo(m) + else: + self._rect.setBounds(bounds) + + if self._marker is None: + self._marker = window.L.marker(to_js([rlat, rlng])).addTo(m) + else: + self._marker.setLatLng(to_js([rlat, rlng])) + + if self.on_key_received is not None: + await self.on_key_received(key_str.encode()) + + m.on("click", create_proxy(on_click)) diff --git a/tubular-tulips/cj12/methods/methods.py b/tubular-tulips/cj12/methods/methods.py new file mode 100644 index 00000000..f9484452 --- /dev/null +++ b/tubular-tulips/cj12/methods/methods.py @@ -0,0 +1,84 @@ +from typing import Protocol + +from js import document + +from cj12.dom import add_event_listener, elem_by_id, fetch_text +from cj12.methods import KeyReceiveCallback +from cj12.methods.chess import ChessMethod +from cj12.methods.colour_picker import ColourPickerMethod +from cj12.methods.direction import DirectionLockMethod +from cj12.methods.location import LocationMethod +from cj12.methods.music import MusicMethod +from cj12.methods.password import PasswordMethod +from cj12.methods.pattern_lock import PatternLockMethod +from cj12.methods.safe import SafeMethod + + +class Method(Protocol): + byte: int + static_id: str + name: str + description: str + + on_key_received: KeyReceiveCallback | None = None + + async def setup(self) -> None: ... + + +methods: list[Method] = [ + PasswordMethod(), + ChessMethod(), + LocationMethod(), + SafeMethod(), + PatternLockMethod(), + MusicMethod(), + DirectionLockMethod(), + ColourPickerMethod(), +] + + +class Methods: + def __init__(self, on_key_received: KeyReceiveCallback) -> None: + self._on_key_received = on_key_received + self._container = elem_by_id("method") + self._html_cache: dict[str, str] = {} + self.current: Method | None = None + + async def _get_cached_html(self, method: Method) -> str: + if method.static_id not in self._html_cache: + url = f"/methods/{method.static_id}/page.html" + self._html_cache[method.static_id] = await fetch_text(url) + return self._html_cache[method.static_id] + + async def register_selections(self) -> None: + self._container.innerHTML = '
' + selections_container = elem_by_id("method-selections") + + for method in methods: + btn = document.createElement("button") + btn.className = "method-selection" + btn.innerHTML = f""" + +

{method.name}

+

{method.description}

+ """ + + async def wrapper(_: object, method: Method = method) -> None: + await self.go_to(method) + + add_event_listener(btn, "click", wrapper) + selections_container.appendChild(btn) + + async def _on_back(self, _: object) -> None: + await self._on_key_received(None) + await self.register_selections() + + async def go_to(self, method: Method) -> None: + self.current = method + self._container.innerHTML = f""" + + {await self._get_cached_html(method)} + """ + method.on_key_received = self._on_key_received + add_event_listener(elem_by_id("back"), "click", self._on_back) + await method.setup() diff --git a/tubular-tulips/cj12/methods/music.py b/tubular-tulips/cj12/methods/music.py new file mode 100644 index 00000000..354803e7 --- /dev/null +++ b/tubular-tulips/cj12/methods/music.py @@ -0,0 +1,246 @@ +from collections.abc import Callable + +from js import Promise, clearTimeout, setTimeout, window +from pyodide.ffi import create_proxy + +from cj12.dom import add_event_listener, elem_by_id +from cj12.methods import KeyReceiveCallback + +# NOTES + +# IMPORTANT +# Refactor setTimeout +# new art + +# OTHER +# Refactor self.currentColumn and self.playing +# Refactor constants, note numbering system +# Refactor event loop to use dtime +# change camelCase to snake_case + +# LEARN +# look into BETTER ASYNC LOADING copied from chess +# look into intervalevents + +# DONE +# Implement key system using self.grid +# Change grid size based on screen width +# Refactor all canvas to account for dpi +# Refactor all canvas to account for subpixels + + +class MusicMethod: + byte = 0x07 + static_id = "music" + name = "Music" + description = "A song" + + on_key_received: KeyReceiveCallback | None = None + grid: list[list[str | None]] + + async def setup(self) -> None: + self.canvas = elem_by_id("instrument-canvas") + self.playButton = elem_by_id("play-button") + self.bpmSlider = elem_by_id("bpm-slider") + self.bpmDisplay = elem_by_id("bpm-display") + + self.ctx = self.canvas.getContext("2d") + self.ctx.imageSmoothingEnabled = False + + self.rows = 16 + self.columns = 32 + + # Create the grid data structure + # SELF.GRID IS WHAT THE KEY SHOULD BE + self.grid = [[-1] * self.rows for _ in range(self.columns)] + + self.currentColumn = None + self.playing = False + + self.bpm = 120 + self.interval = 60000 / self.bpm + + rect_canvas = self.canvas.getBoundingClientRect() + dpr = window.devicePixelRatio or 1 + self.canvas.width = rect_canvas.width * dpr + self.canvas.height = rect_canvas.height * dpr + self.ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + self.canvas.width = self.width = rect_canvas.width + self.canvas.height = self.height = rect_canvas.height + self.box_width = self.width / self.columns + self.box_height = self.height / self.rows + + self.timeout_calls = [] + + self._draw_grid() + + # Control buttons and handlers + add_event_listener(self.canvas, "click", self._update_on_click) + add_event_listener(self.playButton, "click", self._toggle_play) + add_event_listener(self.bpmSlider, "change", self._change_bpm) + add_event_listener(window, "resize", self._height_setup) + await self.load_notes() + + def resize_canvas(self, ratio: float) -> None: + self.canvas.width = window.screen.width * ratio + self.canvas.height = window.screen.height * ratio + + # LEARN BETTER LOADING copied from chess + async def load_notes(self) -> None: + def load_sound(src: str) -> object: + def executor( + resolve: Callable[[object], None], + _reject: Callable[[object], None], + ) -> None: + sound = window.Audio.new() + sound.onloadeddata = lambda *_, sound=sound: resolve(sound) + sound.src = src + + return Promise.new(executor) + + note_names = [f"Piano.{i}" for i in range(7, 23)] + + self.notes = {} + for note_name in note_names: + self.notes[note_name] = await load_sound( + f"/methods/music/audio/{note_name}.mp3", + ) + + self.tick_proxy = create_proxy( + self._tick, + ) # It only works with this for some reason instead of @create_proxy + + # Main event loop, calls self + # TODO: REFACTOR setTimeout + def _tick(self) -> None: + if not self.playing or not elem_by_id("instrument-canvas"): + return + self._play_notes(self.grid[self.currentColumn]) + self._draw_grid() + self.currentColumn = (self.currentColumn + 1) % self.columns + self.timeout_calls.append(setTimeout(self.tick_proxy, self.interval)) + + def _height_setup(self, _event: object) -> None: + rect_canvas = self.canvas.getBoundingClientRect() + dpr = window.devicePixelRatio or 1 + self.canvas.width = rect_canvas.width * dpr + self.canvas.height = rect_canvas.height * dpr + self.ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + self.canvas.width = self.width = rect_canvas.width + self.canvas.height = self.height = rect_canvas.height + self.box_width = self.width / self.columns + self.box_height = self.height / self.rows + self._draw_grid() + + def _play_note(self, note_name: str) -> None: + notefound = self.notes[f"Piano.{note_name}"] + notefound.currentTime = 0 + notefound.play() + + def _play_notes(self, grid_section: list) -> None: + for number, on in enumerate(grid_section): + if on == 1: + self._play_note(f"{self.rows - number + 7 - 1}") # make more clear + + async def _update_on_click(self, event: object) -> None: + rect_canvas = self.canvas.getBoundingClientRect() + click_x = event.clientX - rect_canvas.left + click_y = event.clientY - rect_canvas.top + + column_clicked = int(click_x / self.box_width) + row_clicked = int(click_y / self.box_height) + + self.grid[column_clicked][row_clicked] *= -1 + if self.grid[column_clicked][row_clicked] == 1: + self._play_note(f"{self.rows - row_clicked + 7 - 1}") + self._draw_grid() + key = self._flatten_list() + await self.on_key_received(key.encode()) + + def _flatten_list(self) -> str: + return "".join( + "1" if cell == 1 else "0" for column in self.grid for cell in column + ) + + # look into intervalevents + def _toggle_play(self, _event: object) -> None: + self.playing = not self.playing + for timeout_call in self.timeout_calls: + clearTimeout(timeout_call) + if self.playing: + self.playButton.innerText = "⏸" + self.currentColumn = 0 + self._tick() + else: + self.playButton.innerText = "▶" + + self._draw_grid() + + def _change_bpm(self, event: object) -> None: + self.bpm = int(event.target.value) + self.interval = 60000 / self.bpm + self.bpmDisplay.innerText = f"BPM: {self.bpm}" + + # Draw the music grid (should pr) + def _draw_grid(self) -> None: + color_dict = { + 0: "pink", + 1: "purple", + 2: "blue", + 3: "green", + 4: "yellow", + 5: "orange", + 6: "red", + } + + self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height) + h = round(self.box_height) + w = round(self.box_width) + self.ctx.lineWidth = 1 + + # Draw the boxes + for col_idx, column in enumerate(self.grid): + for row_idx, row in enumerate(column): + if row == 1: + self.ctx.beginPath() + self.ctx.rect( + round(col_idx * self.box_width), + round(row_idx * self.box_height), + w, + h, + ) + self.ctx.fillStyle = color_dict[row_idx % 7] + self.ctx.fill() + elif col_idx == self.currentColumn and self.playing: + self.ctx.beginPath() + self.ctx.rect( + round(col_idx * self.box_width), + round(row_idx * self.box_height), + w, + h, + ) + self.ctx.fillStyle = "grey" + self.ctx.fill() + + # Draw the lines + for i in range(self.rows): + if i > 0: + self.ctx.beginPath() + self.ctx.strokeStyle = "grey" + self.ctx.moveTo(0, round(i * self.box_height)) + self.ctx.lineTo(self.canvas.width, round(i * self.box_height)) + self.ctx.stroke() + + for i in range(self.columns): + if i > 0: + self.ctx.beginPath() + if i % 2 == 0: + self.ctx.lineWidth = 1 + self.ctx.strokeStyle = "white" + else: + self.ctx.lineWidth = 1 + self.ctx.strokeStyle = "grey" + + self.ctx.moveTo(round(i * self.box_width), 0) + self.ctx.lineTo(round(i * self.box_width), self.canvas.height) + self.ctx.stroke() diff --git a/tubular-tulips/cj12/methods/password.py b/tubular-tulips/cj12/methods/password.py new file mode 100644 index 00000000..6e228b06 --- /dev/null +++ b/tubular-tulips/cj12/methods/password.py @@ -0,0 +1,30 @@ +from cj12.dom import InputElement, add_event_listener, elem_by_id +from cj12.methods import KeyReceiveCallback + + +class PasswordMethod: + byte = 0x01 + static_id = "password" + name = "Password" + description = "Plain password (deprecated)" + + on_key_received: KeyReceiveCallback | None = None + + async def setup(self) -> None: + self._input = [None] * 2 + self._warn_mismatch = elem_by_id("warn-mismatch") + for i in range(2): + self._input[i] = elem_by_id(f"password-input{i}", InputElement) + add_event_listener(self._input[i], "keydown", self._on_key_down) + + async def _on_key_down(self, _event: object) -> None: + if self.on_key_received is None: + return + if self._input[0].value != self._input[1].value: + self._warn_mismatch.style.color = "#FF0000" + self._warn_mismatch.innerText = "Passwords don't match!" + await self.on_key_received(None) + else: + self._warn_mismatch.style.color = "#00FF00" + self._warn_mismatch.innerText = "- OK -" + await self.on_key_received(self._input[0].value.encode()) diff --git a/tubular-tulips/cj12/methods/pattern_lock.py b/tubular-tulips/cj12/methods/pattern_lock.py new file mode 100644 index 00000000..acdb6841 --- /dev/null +++ b/tubular-tulips/cj12/methods/pattern_lock.py @@ -0,0 +1,175 @@ +from dataclasses import dataclass +from math import pi + +from cj12.dom import add_event_listener, elem_by_id +from cj12.methods import KeyReceiveCallback + +COLOUR_THEME = "#00ff00" + + +@dataclass +class Node: + x_coor: int + y_coor: int + connected: bool + + +class PatternLockMethod: + byte = 0x05 + static_id = "pattern_lock" + name = "Pattern Lock" + description = "A pattern traced lock" + + on_key_received: KeyReceiveCallback | None = None + + dot_radius: int = 15 + lock_grid_length: int = 300 + dimension: int = 3 # n by n dots + + async def setup(self) -> None: + self.init() + self.setup_event_listener() + + def init(self) -> None: + self.node_list: list[Node] = [] + self.last_node: Node | None = None + self.sequence: list[int] = [] + self.is_mouse_down: bool = False + self.connected_nodes: list[list[Node]] = [] + + self.canvas = elem_by_id("grid") + self.canvas.width = self.canvas.height = self.lock_grid_length + self.ctx = self.canvas.getContext("2d") + + self.generate_nodes() + self.draw_pattern() + + def generate_nodes(self) -> None: + """ + Generate all the dots + """ + node_length = self.lock_grid_length / self.dimension + for row in range(self.dimension): + for col in range(self.dimension): + self.node_list.append( + Node( + int(col * node_length + node_length / 2), + int(row * node_length + node_length / 2), + connected=False, + ), + ) + + def draw_pattern(self) -> None: + """ + Draw the dots and lines + """ + ctx = self.canvas.getContext("2d") + ctx.clearRect(0, 0, self.canvas.width, self.canvas.height) + + for node in self.node_list: + ctx.beginPath() + ctx.arc(node.x_coor, node.y_coor, self.dot_radius, 0, 2 * pi) + ctx.lineWidth = 0 + if node.connected: + ctx.fillStyle = COLOUR_THEME + else: + ctx.fillStyle = "white" + + ctx.fill() + ctx.stroke() + + self.draw_line() + + def draw_line(self) -> None: + """ + Draw the lines for connected dots + """ + ctx = self.ctx + for node1, node2 in self.connected_nodes: + ctx.beginPath() + ctx.moveTo(node1.x_coor, node1.y_coor) + ctx.lineTo(node2.x_coor, node2.y_coor) + ctx.strokeStyle = COLOUR_THEME + ctx.lineWidth = 2 + ctx.stroke() + + def on_move(self, evt: object) -> None: + if not self.is_mouse_down: + return + + rect = self.canvas.getBoundingClientRect() + + if hasattr(evt, "touches") and evt.touches.length: + current_x = evt.touches[0].clientX - rect.left + current_y = evt.touches[0].clientY - rect.top + else: + current_x = evt.clientX - rect.left + current_y = evt.clientY - rect.top + + node = self.get_node(current_x, current_y) + + if node and not node.connected: + node.connected = True + self.sequence.append(self.node_list.index(node)) + + if self.last_node: # and self.last_node is not node: + self.connected_nodes.append([self.last_node, node]) + + self.last_node = node + + self.draw_pattern() + + if self.last_node: + ctx = self.ctx + ctx.beginPath() + ctx.moveTo(self.last_node.x_coor, self.last_node.y_coor) + ctx.lineTo(current_x, current_y) + ctx.strokeStyle = COLOUR_THEME + ctx.lineWidth = 2 + ctx.stroke() + + def get_node(self, x: int, y: int) -> Node | None: + """ + Get the node of a given x, y coordinate in the canvas + """ + for node in self.node_list: + if ( + (x - node.x_coor) ** 2 + (y - node.y_coor) ** 2 + ) ** 0.5 <= self.dot_radius: + return node + + return None + + def on_dimension_change(self, evt: object) -> None: + self.dimension = 3 + int(evt.target.value) + self.init() + + def setup_event_listener(self) -> None: + """ + Register all the event listener in the canvas + """ + + def mouse_down(_event: object) -> None: + self.is_mouse_down = True + + for node in self.node_list: + node.connected = False + + self.connected_nodes.clear() + self.last_node = None + self.sequence.clear() + + async def mouse_up(_event: object) -> None: + self.is_mouse_down = False + self.draw_pattern() + + if self.on_key_received is not None: + await self.on_key_received( + "".join([str(x) for x in self.sequence]).encode(), + ) + + self.dimension_selection = elem_by_id("dial-num") + add_event_listener(self.canvas, "mousedown", mouse_down) + add_event_listener(self.canvas, "mouseup", mouse_up) + add_event_listener(self.canvas, "mousemove", self.on_move) + add_event_listener(self.dimension_selection, "input", self.on_dimension_change) diff --git a/tubular-tulips/cj12/methods/safe.py b/tubular-tulips/cj12/methods/safe.py new file mode 100644 index 00000000..2aeea147 --- /dev/null +++ b/tubular-tulips/cj12/methods/safe.py @@ -0,0 +1,251 @@ +import math + +from js import document + +from cj12.dom import add_event_listener, elem_by_id +from cj12.methods import KeyReceiveCallback + +KNOB_RADIUS = 120 +OUTER_RADIUS = 200 +TICK_CHOICES = ( + (12, (1, 1, 1)), + (24, (3, 1, 1)), + (64, (8, 4, 1)), + (72, (6, 3, 1)), + (100, (10, 5, 1)), +) +TICKS, TICK_INTERVALS = TICK_CHOICES[2] +TICK_LENGTHS = (25, 20, 10) +TICK_WIDTHS = (3, 2, 1) +GREY_GRADIENT = (int("0x33", 16), int("0xAA", 16)) +KNOB_SLICES = 180 +TWO_PI = 2 * math.pi +MOUSE_DEADZONE_RADIUS = 7 + + +class SafeMethod: + byte = 0x04 + static_id = "safe" + name = "Safe" + description = "A safe combination" + + on_key_received: KeyReceiveCallback | None = None + + @staticmethod + def grey(frac: float) -> str: + return ( + "#" + + f"{int(frac * GREY_GRADIENT[1] + (1 - frac) * GREY_GRADIENT[0]):02x}" * 3 + ) + + async def setup(self) -> None: # noqa: PLR0915 + self.combination = [] + self.last_mousedown = None # angle at which the mouse was clicked + self.last_dial_value = 0 # value at which the dial was previously left at + self.prev_angle = None # angle at which the mouse was last detected + self.total_angle = None + + self.offscreen_canvas = document.createElement("canvas") + self.offscreen_canvas.width = 600 + self.offscreen_canvas.height = 400 + ctx = self.offscreen_canvas.getContext("2d") + ctx.fillStyle = "#FFFFFF" + ctx.translate(self.offscreen_canvas.width / 2, self.offscreen_canvas.height / 2) + + self.dial_canvas = elem_by_id("dial-canvas") + self.dial_canvas.style.zIndex = 1 + ctx = self.dial_canvas.getContext("2d") + ctx.fillStyle = "#FFFFFF" + ctx.translate(self.dial_canvas.width / 2, self.dial_canvas.height / 2) + + self.static_canvas = elem_by_id("static-canvas") + self.static_canvas.style.zIndex = 0 + ctx = self.static_canvas.getContext("2d") + ctx.fillRect(0, 0, self.static_canvas.width, self.static_canvas.height) + ctx.translate(self.static_canvas.width / 2, self.static_canvas.height / 2) + ctx.fillStyle = "#FFFFFF" + + self.dial_input_range = elem_by_id("dial-num") + + # draw outer dial + ctx.save() + radial_grad = ctx.createRadialGradient(0, 0, KNOB_RADIUS, 0, 0, OUTER_RADIUS) + radial_grad.addColorStop(0.0, "#222222") + radial_grad.addColorStop(0.7, "#000000") + radial_grad.addColorStop(0.85, "#202020") + radial_grad.addColorStop(0.96, "#444444") + radial_grad.addColorStop(1, "#000000") + ctx.beginPath() + ctx.moveTo(OUTER_RADIUS, 0) + ctx.arc(0, 0, OUTER_RADIUS, 0, TWO_PI) + ctx.fillStyle = radial_grad + ctx.fill() + ctx.restore() + + # draw knob + d_theta = TWO_PI / KNOB_SLICES + for slc in range(KNOB_SLICES): + theta = TWO_PI * slc / KNOB_SLICES + ctx.save() + ctx.rotate(theta) + ctx.beginPath() + ctx.moveTo(0, 0) + ctx.lineTo(KNOB_RADIUS + (slc % 2) * 2, 0) + ctx.arc(0, 0, KNOB_RADIUS + (slc % 2) * 2, 0, d_theta * 1.005) + ctx.closePath() + sin2x, cos4x = math.sin(2 * theta), math.cos(4 * theta) + ctx.strokeStyle = ctx.fillStyle = SafeMethod.grey( + 1 - ((sin2x + cos4x) ** 2) / 4, + ) + ctx.stroke() + ctx.fill() + ctx.restore() + + ctx.beginPath() + ctx.moveTo(KNOB_RADIUS - 5, 0) + ctx.arc(0, 0, KNOB_RADIUS - 5, 0, TWO_PI) + ctx.moveTo(KNOB_RADIUS - 10, 0) + ctx.arc(0, 0, KNOB_RADIUS - 10, 0, TWO_PI) + ctx.strokeStyle = "#000000" + ctx.stroke() + + self.prerender_ticks() + self.draw_ticks() + self.output_div = elem_by_id("output") + + add_event_listener(self.dial_canvas, "mousedown", self.on_mouse_down) + add_event_listener(self.dial_canvas, "mousemove", self.on_mouse_move) + add_event_listener(self.dial_canvas, "mouseup", self.on_mouse_up) + add_event_listener(self.dial_input_range, "input", self.change_dial_type) + + self.btn_reset = elem_by_id("btn-reset") + add_event_listener(self.btn_reset, "click", self.reset_combination) + + def align_center(self) -> None: + self.div.style.alignItems = "center" + + def prerender_ticks(self) -> None: + ctx = self.offscreen_canvas.getContext("2d") + w, h = self.offscreen_canvas.width, self.offscreen_canvas.height + ctx.clearRect(-w / 2, -h / 2, w, h) + ctx.save() + for tick in range(TICKS): + ctx.save() + ctx.beginPath() + ctx.rotate(TWO_PI * tick / TICKS) + for t_type, interval in enumerate(TICK_INTERVALS): + if tick % interval != 0: + continue + ctx.roundRect( + -TICK_WIDTHS[t_type] / 2, + -OUTER_RADIUS + 4, + TICK_WIDTHS[t_type], + TICK_LENGTHS[t_type], + TICK_WIDTHS[t_type] / 2, + ) + break + ctx.fill() + ctx.restore() + + ctx.font = "24px sans-serif" + ctx.textAlign = "center" + ctx.textBaseline = "top" + for tick_numbering in range(0, TICKS, TICK_INTERVALS[0]): + ctx.save() + ctx.beginPath() + ctx.rotate(TWO_PI * tick_numbering / TICKS) + ctx.fillText( + str(tick_numbering), + 0, + -OUTER_RADIUS + 4 + TICK_LENGTHS[0] + 5, + ) + ctx.restore() + ctx.restore() + + def draw_ticks(self, angle: float = 0.0) -> None: + w, h = self.dial_canvas.width, self.dial_canvas.height + ctx = self.dial_canvas.getContext("2d") + ctx.clearRect(-w / 2, -h / 2, w, h) + ctx.save() + ctx.rotate(angle) + ctx.drawImage(self.offscreen_canvas, -w / 2, -h / 2) + ctx.restore() + ctx.beginPath() + ctx.moveTo(-5, -OUTER_RADIUS - 5) + ctx.lineTo(5, -OUTER_RADIUS - 5) + ctx.lineTo(0, -OUTER_RADIUS + 25) + ctx.closePath() + ctx.fillStyle = "#EE0000" + ctx.strokeStyle = "#660000" + ctx.fill() + ctx.stroke() + + def get_mouse_coords(self, event: object) -> None: + rect = self.dial_canvas.getBoundingClientRect() + mx = event.clientX - rect.left - rect.width // 2 + my = event.clientY - rect.top - rect.height // 2 + return mx, my + + async def on_mouse_down(self, event: object) -> None: + if self.total_angle is not None: + await self.register_knob_turn() + return + mx, my = self.get_mouse_coords(event) + if mx**2 + my**2 > OUTER_RADIUS**2: + return + self.total_angle = 0 + self.last_mousedown = ((mx, my), math.atan2(my, mx)) + + def on_mouse_move(self, event: object) -> None: + mx, my = self.get_mouse_coords(event) + if self.last_mousedown is None: + return + curr_angle = math.atan2(my, mx) + d_theta = curr_angle - self.last_mousedown[1] + diff = self.total_angle - d_theta + pi_diffs = abs(diff) // math.pi + if pi_diffs % 2 == 1: + pi_diffs += 1 + self.total_angle = d_theta + (-1 if diff < 0 else 1) * pi_diffs * math.pi + self.draw_ticks(self.total_angle + self.last_dial_value * TWO_PI / TICKS) + + async def on_mouse_up(self, event: object) -> None: + if self.last_mousedown is None: + return + mx, my = self.get_mouse_coords(event) + px, py = self.last_mousedown[0] + if (px - mx) ** 2 + (py - my) ** 2 > MOUSE_DEADZONE_RADIUS**2: + await self.register_knob_turn() + + async def change_dial_type(self, event: object) -> None: + global TICKS, TICK_INTERVALS + TICKS, TICK_INTERVALS = TICK_CHOICES[int(event.target.value)] + self.prerender_ticks() + await self.reset_combination(event) + + async def register_knob_turn(self) -> None: + val = (1 if self.total_angle >= 0 else -1) * round( + abs(self.total_angle) * TICKS / TWO_PI, + ) + self.combination.append(val) + self.last_mousedown = None + self.last_dial_value = (self.last_dial_value + val) % TICKS + self.draw_ticks(self.last_dial_value * TWO_PI / TICKS) + self.prev_angle = None + self.total_angle = None + self.output_div.innerText = " -> ".join( + f"{'+' if x > 0 else ''}{x}" for x in self.combination + ) + if self.on_key_received is not None: + await self.on_key_received(str(self.combination).encode()) + + async def reset_combination(self, _event: object) -> None: + self.last_mousedown = None + self.last_dial_value = 0 + self.draw_ticks() + self.prev_angle = None + self.total_angle = None + self.combination = [] + self.output_div.innerText = "" + if self.on_key_received is not None: + await self.on_key_received(str(self.combination).encode()) diff --git a/tubular-tulips/pyproject.toml b/tubular-tulips/pyproject.toml new file mode 100644 index 00000000..a7ef512e --- /dev/null +++ b/tubular-tulips/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "cj12" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = ["starlette>=0.47.2", "uvicorn>=0.35.0"] + +[dependency-groups] +dev = [ + "numpy>=2.3.2", + "pillow>=11.3.0", + "pre-commit>=4.2.0", + "pyodide-py>=0.28.1", + "pytest>=8.4.1", +] + +[tool.ruff] +lint.select = ["ALL"] +lint.ignore = ["D", "ANN401", "TD002", "TD003", "FIX002"] +lint.per-file-ignores = { "tests/**/*.py" = ["S101", "PLR2004"] } + +[tool.pyright] +typeCheckingMode = "off" diff --git a/tubular-tulips/server.py b/tubular-tulips/server.py new file mode 100644 index 00000000..729f0389 --- /dev/null +++ b/tubular-tulips/server.py @@ -0,0 +1,18 @@ +import os + +import uvicorn +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.staticfiles import StaticFiles + +app = Starlette( + routes=[ + Mount("/cj12", StaticFiles(directory="cj12")), + Mount("/", StaticFiles(directory="static", html=True)), + ], +) + +if __name__ == "__main__": + host: str = os.getenv("CJ12_HOST", "0.0.0.0") # noqa: S104 + port: int = os.getenv("CJ12_PORT", "8000") + uvicorn.run(app, host=host, port=int(port)) diff --git a/tubular-tulips/static/index.html b/tubular-tulips/static/index.html new file mode 100644 index 00000000..b8ade9a1 --- /dev/null +++ b/tubular-tulips/static/index.html @@ -0,0 +1,82 @@ + + + + + + + + + + + + + Loading... + + + + +

Loading...

+ + + diff --git a/tubular-tulips/static/methods/chess/img.png b/tubular-tulips/static/methods/chess/img.png new file mode 100644 index 00000000..91d32e7d Binary files /dev/null and b/tubular-tulips/static/methods/chess/img.png differ diff --git a/tubular-tulips/static/methods/chess/page.html b/tubular-tulips/static/methods/chess/page.html new file mode 100644 index 00000000..0cc8166f --- /dev/null +++ b/tubular-tulips/static/methods/chess/page.html @@ -0,0 +1,45 @@ + + + +
+ + +
+ +
+

Chessboard Lock

+

Drag the chesspieces to create a unique board state. + SPACE clears a piece under the mouse. Press Q, K, R, B, N or P to instantly place a piece.

+
+ + diff --git a/tubular-tulips/static/methods/chess/pieces/B_Bishop.png b/tubular-tulips/static/methods/chess/pieces/B_Bishop.png new file mode 100644 index 00000000..91fb50b1 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/B_Bishop.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/B_King.png b/tubular-tulips/static/methods/chess/pieces/B_King.png new file mode 100644 index 00000000..aedb11b7 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/B_King.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/B_Knight.png b/tubular-tulips/static/methods/chess/pieces/B_Knight.png new file mode 100644 index 00000000..c4d51cf3 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/B_Knight.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/B_Pawn.png b/tubular-tulips/static/methods/chess/pieces/B_Pawn.png new file mode 100644 index 00000000..8dfd79fb Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/B_Pawn.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/B_Queen.png b/tubular-tulips/static/methods/chess/pieces/B_Queen.png new file mode 100644 index 00000000..6ffef729 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/B_Queen.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/B_Rook.png b/tubular-tulips/static/methods/chess/pieces/B_Rook.png new file mode 100644 index 00000000..36ea6655 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/B_Rook.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/BlackPieces-Sheet.png b/tubular-tulips/static/methods/chess/pieces/BlackPieces-Sheet.png new file mode 100644 index 00000000..7d29eb6a Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/BlackPieces-Sheet.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/BlackPiecesWood-Sheet.png b/tubular-tulips/static/methods/chess/pieces/BlackPiecesWood-Sheet.png new file mode 100644 index 00000000..2e8faa53 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/BlackPiecesWood-Sheet.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/W_Bishop.png b/tubular-tulips/static/methods/chess/pieces/W_Bishop.png new file mode 100644 index 00000000..fe989b43 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/W_Bishop.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/W_King.png b/tubular-tulips/static/methods/chess/pieces/W_King.png new file mode 100644 index 00000000..74657be5 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/W_King.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/W_Knight.png b/tubular-tulips/static/methods/chess/pieces/W_Knight.png new file mode 100644 index 00000000..7b485ff0 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/W_Knight.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/W_Pawn.png b/tubular-tulips/static/methods/chess/pieces/W_Pawn.png new file mode 100644 index 00000000..bf92f448 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/W_Pawn.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/W_Queen.png b/tubular-tulips/static/methods/chess/pieces/W_Queen.png new file mode 100644 index 00000000..da54d947 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/W_Queen.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/W_Rook.png b/tubular-tulips/static/methods/chess/pieces/W_Rook.png new file mode 100644 index 00000000..dbd616c6 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/W_Rook.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/WhitePieces-Sheet.png b/tubular-tulips/static/methods/chess/pieces/WhitePieces-Sheet.png new file mode 100644 index 00000000..9c46e271 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/WhitePieces-Sheet.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/WhitePiecesWood-Sheet.png b/tubular-tulips/static/methods/chess/pieces/WhitePiecesWood-Sheet.png new file mode 100644 index 00000000..9775eeb5 Binary files /dev/null and b/tubular-tulips/static/methods/chess/pieces/WhitePiecesWood-Sheet.png differ diff --git a/tubular-tulips/static/methods/chess/pieces/readme.txt b/tubular-tulips/static/methods/chess/pieces/readme.txt new file mode 100644 index 00000000..cbcd86bb --- /dev/null +++ b/tubular-tulips/static/methods/chess/pieces/readme.txt @@ -0,0 +1,3 @@ +Chess pieces Pixel Art by DANI MACCARI +https://dani-maccari.itch.io/pixel-chess +No changes were made. diff --git a/tubular-tulips/static/methods/colour_picker/img.png b/tubular-tulips/static/methods/colour_picker/img.png new file mode 100644 index 00000000..39309683 Binary files /dev/null and b/tubular-tulips/static/methods/colour_picker/img.png differ diff --git a/tubular-tulips/static/methods/colour_picker/page.html b/tubular-tulips/static/methods/colour_picker/page.html new file mode 100644 index 00000000..a835e895 --- /dev/null +++ b/tubular-tulips/static/methods/colour_picker/page.html @@ -0,0 +1,80 @@ +
+ + + +
+
+
+
+
Colour Hex Value
+
+
+
+ + diff --git a/tubular-tulips/static/methods/direction/img.png b/tubular-tulips/static/methods/direction/img.png new file mode 100644 index 00000000..d2790280 Binary files /dev/null and b/tubular-tulips/static/methods/direction/img.png differ diff --git a/tubular-tulips/static/methods/direction/page.html b/tubular-tulips/static/methods/direction/page.html new file mode 100644 index 00000000..f108c0b5 --- /dev/null +++ b/tubular-tulips/static/methods/direction/page.html @@ -0,0 +1,185 @@ +
+
+

Direction Lock

+

+ Drag the light blue knob in the center + to input directions. The knob will animate as you drag it, and the + sequence will be recorded below. +

+
+ Tip: You need to drag at least 30 pixels for a direction + to be recorded. The gray trails show the available directions. +
+
+ +
+ +
+ +
+ + + +
+ + +
+
+
+ + diff --git a/tubular-tulips/static/methods/location/img.png b/tubular-tulips/static/methods/location/img.png new file mode 100644 index 00000000..8dd1f456 Binary files /dev/null and b/tubular-tulips/static/methods/location/img.png differ diff --git a/tubular-tulips/static/methods/location/page.html b/tubular-tulips/static/methods/location/page.html new file mode 100644 index 00000000..10aaa3e2 --- /dev/null +++ b/tubular-tulips/static/methods/location/page.html @@ -0,0 +1,13 @@ +
+ + diff --git a/tubular-tulips/static/methods/music/audio/Piano.10.mp3 b/tubular-tulips/static/methods/music/audio/Piano.10.mp3 new file mode 100644 index 00000000..49398f7f Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.10.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.11.mp3 b/tubular-tulips/static/methods/music/audio/Piano.11.mp3 new file mode 100644 index 00000000..7facc7b5 Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.11.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.12.mp3 b/tubular-tulips/static/methods/music/audio/Piano.12.mp3 new file mode 100644 index 00000000..e1b8d176 Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.12.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.13.mp3 b/tubular-tulips/static/methods/music/audio/Piano.13.mp3 new file mode 100644 index 00000000..0529c12b Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.13.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.14.mp3 b/tubular-tulips/static/methods/music/audio/Piano.14.mp3 new file mode 100644 index 00000000..f56b3d4a Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.14.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.15.mp3 b/tubular-tulips/static/methods/music/audio/Piano.15.mp3 new file mode 100644 index 00000000..3d102134 Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.15.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.16.mp3 b/tubular-tulips/static/methods/music/audio/Piano.16.mp3 new file mode 100644 index 00000000..fa3c8189 Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.16.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.17.mp3 b/tubular-tulips/static/methods/music/audio/Piano.17.mp3 new file mode 100644 index 00000000..c8133aa7 Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.17.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.18.mp3 b/tubular-tulips/static/methods/music/audio/Piano.18.mp3 new file mode 100644 index 00000000..c117b1fa Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.18.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.19.mp3 b/tubular-tulips/static/methods/music/audio/Piano.19.mp3 new file mode 100644 index 00000000..b295ba43 Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.19.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.20.mp3 b/tubular-tulips/static/methods/music/audio/Piano.20.mp3 new file mode 100644 index 00000000..03c9abad Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.20.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.21.mp3 b/tubular-tulips/static/methods/music/audio/Piano.21.mp3 new file mode 100644 index 00000000..87e0b568 Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.21.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.22.mp3 b/tubular-tulips/static/methods/music/audio/Piano.22.mp3 new file mode 100644 index 00000000..efb67cf5 Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.22.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.7.mp3 b/tubular-tulips/static/methods/music/audio/Piano.7.mp3 new file mode 100644 index 00000000..9e3c0e19 Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.7.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.8.mp3 b/tubular-tulips/static/methods/music/audio/Piano.8.mp3 new file mode 100644 index 00000000..2b505589 Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.8.mp3 differ diff --git a/tubular-tulips/static/methods/music/audio/Piano.9.mp3 b/tubular-tulips/static/methods/music/audio/Piano.9.mp3 new file mode 100644 index 00000000..9f745c35 Binary files /dev/null and b/tubular-tulips/static/methods/music/audio/Piano.9.mp3 differ diff --git a/tubular-tulips/static/methods/music/img.png b/tubular-tulips/static/methods/music/img.png new file mode 100644 index 00000000..e298ffa7 Binary files /dev/null and b/tubular-tulips/static/methods/music/img.png differ diff --git a/tubular-tulips/static/methods/music/page.html b/tubular-tulips/static/methods/music/page.html new file mode 100644 index 00000000..6e5748d7 --- /dev/null +++ b/tubular-tulips/static/methods/music/page.html @@ -0,0 +1,44 @@ +
+ +
+ + + diff --git a/tubular-tulips/static/methods/password/img.png b/tubular-tulips/static/methods/password/img.png new file mode 100644 index 00000000..2340a502 Binary files /dev/null and b/tubular-tulips/static/methods/password/img.png differ diff --git a/tubular-tulips/static/methods/password/page.html b/tubular-tulips/static/methods/password/page.html new file mode 100644 index 00000000..3ab9d494 --- /dev/null +++ b/tubular-tulips/static/methods/password/page.html @@ -0,0 +1,20 @@ +
+

Password Lock

+
+

Enter a password:

+
+

Now one more time:

+
+

+
+

Everyone knows that using passwords is silly. It’s like guarding a bank vault with a sticky note that says “1234” on the door — the illusion of security wrapped in human laziness. Let's face it, how many of us even have more than 2 passwords? We really recommend trying something else.

+
+ + diff --git a/tubular-tulips/static/methods/pattern_lock/img.png b/tubular-tulips/static/methods/pattern_lock/img.png new file mode 100644 index 00000000..91db80aa Binary files /dev/null and b/tubular-tulips/static/methods/pattern_lock/img.png differ diff --git a/tubular-tulips/static/methods/pattern_lock/page.html b/tubular-tulips/static/methods/pattern_lock/page.html new file mode 100644 index 00000000..9e56517e --- /dev/null +++ b/tubular-tulips/static/methods/pattern_lock/page.html @@ -0,0 +1,23 @@ + +
+ +
+ + diff --git a/tubular-tulips/static/methods/safe/img.png b/tubular-tulips/static/methods/safe/img.png new file mode 100644 index 00000000..3844fb54 Binary files /dev/null and b/tubular-tulips/static/methods/safe/img.png differ diff --git a/tubular-tulips/static/methods/safe/page.html b/tubular-tulips/static/methods/safe/page.html new file mode 100644 index 00000000..22af0a98 --- /dev/null +++ b/tubular-tulips/static/methods/safe/page.html @@ -0,0 +1,51 @@ + + + +
+ + +
+
+
+

Safe Lock

+

Enter a safe combination by spinning the safe wheel any other number of times clockwise or counterclockwise. + Use the slider to select a different dial. +

+
+
+
Current Combination
+
+ + diff --git a/tubular-tulips/static/ui.html b/tubular-tulips/static/ui.html new file mode 100644 index 00000000..f4b137bc --- /dev/null +++ b/tubular-tulips/static/ui.html @@ -0,0 +1,200 @@ +
+
+ +
+ + + + + +
+
+ + diff --git a/tubular-tulips/tests/__init__.py b/tubular-tulips/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tubular-tulips/tests/test_container.py b/tubular-tulips/tests/test_container.py new file mode 100644 index 00000000..d8c52084 --- /dev/null +++ b/tubular-tulips/tests/test_container.py @@ -0,0 +1,98 @@ +import pytest + +from cj12.container import Container, InvalidMagicError + + +def test_encode_decode() -> None: + c = Container(1, "secret.txt", b"A" * 32, b"Hello, world") + + encoded = bytes(c) + decoded = Container.from_bytes(encoded) + + assert decoded == c + + +def test_decode_invalid_bytes() -> None: + with pytest.raises(InvalidMagicError): + Container.from_bytes(b"invalid") + + +def test_hash_preservation() -> None: + """Test that the data hash is preserved during encoding/decoding.""" + original_hash = b"0123456789abcdef" * 2 # 32 bytes + c = Container(2, "test.bin", original_hash, b"test data") + + encoded = bytes(c) + decoded = Container.from_bytes(encoded) + + assert decoded.data_hash == original_hash + assert len(decoded.data_hash) == 32 + + +def test_hash_different_values() -> None: + """Test containers with different hash values.""" + hash1 = b"A" * 32 + hash2 = b"B" * 32 + + c1 = Container(1, "file1.txt", hash1, b"data1") + c2 = Container(1, "file1.txt", hash2, b"data1") + + # Same data but different hashes should create different containers + assert c1 != c2 + assert c1.data_hash != c2.data_hash + + # Encoding should preserve the difference + encoded1 = bytes(c1) + encoded2 = bytes(c2) + assert encoded1 != encoded2 + + # Decoding should preserve the hash difference + decoded1 = Container.from_bytes(encoded1) + decoded2 = Container.from_bytes(encoded2) + assert decoded1.data_hash == hash1 + assert decoded2.data_hash == hash2 + + +def test_hash_zero_bytes() -> None: + """Test container with all-zero hash.""" + zero_hash = b"\x00" * 32 + c = Container(0, "empty.txt", zero_hash, b"") + + encoded = bytes(c) + decoded = Container.from_bytes(encoded) + + assert decoded.data_hash == zero_hash + assert all(byte == 0 for byte in decoded.data_hash) + + +def test_hash_random_bytes() -> None: + """Test container with random-looking hash bytes.""" + random_hash = bytes(range(32)) # 0x00, 0x01, 0x02, ..., 0x1F + c = Container(255, "random.data", random_hash, b"random content") + + encoded = bytes(c) + decoded = Container.from_bytes(encoded) + + assert decoded.data_hash == random_hash + assert decoded.data_hash == bytes(range(32)) + + +def test_hash_in_encoded_format() -> None: + """Test that hash appears correctly in the encoded binary format.""" + test_hash = b"HASH" + b"X" * 28 # 32 bytes total + c = Container(1, "test.txt", test_hash, b"data") + + encoded = bytes(c) + + # The hash should be present in the encoded bytes + assert test_hash in encoded + + # Verify it's at the expected position after magic, method, lengths, and filename + magic_size = 4 # "SDET" + header_size = 12 # 3 * 4 bytes for method, filename_length, data_length + filename_size = len("test.txt") + + expected_hash_start = magic_size + header_size + filename_size + expected_hash_end = expected_hash_start + 32 + + assert encoded[expected_hash_start:expected_hash_end] diff --git a/tubular-tulips/tests/test_encryption.py b/tubular-tulips/tests/test_encryption.py new file mode 100644 index 00000000..87e0ca63 --- /dev/null +++ b/tubular-tulips/tests/test_encryption.py @@ -0,0 +1,18 @@ +import pytest + +from cj12.aes import decrypt, encrypt + + +def test_encryption() -> None: + data = b"Hello, world!" + key = b"1234567812345678" + + encrypted = encrypt(data, key) + decrypted = decrypt(encrypted, key) + + assert decrypted == data + + +def test_wrong_key_size() -> None: + with pytest.raises(ValueError, match="Incorrect number of bits"): + _ = encrypt(b"", b"12345") diff --git a/tubular-tulips/typings/js.pyi b/tubular-tulips/typings/js.pyi new file mode 100644 index 00000000..9cf2f176 --- /dev/null +++ b/tubular-tulips/typings/js.pyi @@ -0,0 +1,182 @@ +# This comes from https://github.com/pyodide/pyodide/blob/main/src/py/js.pyi +# with some minor modifications + +# ruff: noqa: N802, A002, N803, N815, N801 +# pyright: reportAny=false, reportExplicitAny=false + +from collections.abc import Callable, Iterable +from typing import Any, Literal, overload, override + +from _pyodide._core_docs import _JsProxyMetaClass +from pyodide.ffi import ( + JsArray, + JsDomElement, + JsException, + JsFetchResponse, + JsNull, + JsProxy, + JsTypedArray, +) +from pyodide.webloop import PyodideFuture + +def alert(msg: str) -> None: ... +def eval(code: str) -> Any: ... # noqa: A001 + +# in browser the cancellation token is an int, in node it's a special opaque +# object. +type _CancellationToken = int | JsProxy + +def setTimeout(cb: Callable[[], Any], timeout: float) -> _CancellationToken: ... +def clearTimeout(id: _CancellationToken) -> None: ... +def setInterval(cb: Callable[[], Any], interval: float) -> _CancellationToken: ... +def clearInterval(id: _CancellationToken) -> None: ... +def fetch( + url: str, + options: JsProxy | None = None, +) -> PyodideFuture[JsFetchResponse]: ... + +self: Any = ... +window: Any = ... + +# 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: int = 1 + @staticmethod + def from_(data: bytes) -> Uint8Array: ... + def set(self, data: bytes) -> None: ... + +class Float64Array(_TypedArray): + BYTES_PER_ELEMENT: int = 8 + +class URL(_JsObject): + @staticmethod + def createObjectURL(blob: JsProxy) -> URL: ... + @staticmethod + def revokeObjectURL(url: URL) -> None: ... + +class Blob(_JsObject): ... + +class JSON(_JsObject): + @staticmethod + def stringify(a: JsProxy) -> str: ... + @staticmethod + def parse(a: str) -> JsProxy: ... + +class _DomElement(JsDomElement): + innerHTML: str + innerText: str + className: str + def removeChild(self, child: _DomElement) -> None: ... + +class document(_JsObject): + title: str + body: _DomElement + children: list[_DomElement] + @overload + @staticmethod + def createElement(tagName: Literal["canvas"]) -> JsCanvasElement: ... + @overload + @staticmethod + def createElement(tagName: str) -> _DomElement: ... + @staticmethod + def appendChild(child: _DomElement) -> None: ... + @staticmethod + def getElementById(id: str) -> _DomElement | JsNull: ... + +class JsCanvasElement(_DomElement): + width: int | float + height: int | float + def getContext( + self, + ctxType: str, + *, + powerPreference: str = "", + premultipliedAlpha: bool = False, + antialias: bool = False, + alpha: bool = False, + depth: bool = False, + stencil: bool = False, + ) -> Any: ... + +class ArrayBuffer(_JsObject): + @staticmethod + def isView(x: Any) -> bool: ... + +class DOMException(JsException): ... + +class Map: + @staticmethod + def new(a: Iterable[Any]) -> Map: ... + +async def sleep(ms: float) -> None: ... + +class AbortSignal(_JsObject): + @staticmethod + def any(iterable: Iterable[AbortSignal]) -> AbortSignal: ... + @staticmethod + def timeout(ms: int) -> AbortSignal: ... + aborted: bool + reason: JsException + def throwIfAborted(self) -> None: ... + def onabort(self) -> None: ... + +class AbortController(_JsObject): + @staticmethod + def new() -> AbortController: ... + signal: AbortSignal + def abort(self, reason: JsException | None = None) -> None: ... + +class Response(_JsObject): + @staticmethod + def new(body: Any) -> Response: ... + +class Promise(_JsObject): + @staticmethod + def resolve(value: Any) -> Promise: ... + +class FileReader(_DomElement): + result: JsProxy + + @override + @staticmethod + def new() -> FileReader: ... + def readAsArrayBuffer(self, file: object) -> None: ... diff --git a/tubular-tulips/uv.lock b/tubular-tulips/uv.lock new file mode 100644 index 00000000..68cb8903 --- /dev/null +++ b/tubular-tulips/uv.lock @@ -0,0 +1,391 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + +[[package]] +name = "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, upload-time = "2023-08-12T20:38:17.776Z" } +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 = "cj12" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "numpy" }, + { name = "pillow" }, + { name = "pre-commit" }, + { name = "pyodide-py" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "starlette", specifier = ">=0.47.2" }, + { name = "uvicorn", specifier = ">=0.35.0" }, +] + +[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 = "pyodide-py", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=8.4.1" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +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" } +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 = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "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, upload-time = "2025-08-09T19:35:00.6Z" } +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" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "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, upload-time = "2024-06-04T18:44:11.171Z" } +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" +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, upload-time = "2025-07-24T21:32:07.553Z" } +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 = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "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, upload-time = "2025-07-01T09:16:30.666Z" } +wheels = [ + { 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, upload-time = "2025-07-01T09:14:35.276Z" }, + { 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, upload-time = "2025-07-01T09:14:37.203Z" }, + { 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, upload-time = "2025-07-01T09:14:39.344Z" }, + { 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, upload-time = "2025-07-01T09:14:41.843Z" }, + { 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, upload-time = "2025-07-01T09:14:44.008Z" }, + { 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, upload-time = "2025-07-03T13:10:15.628Z" }, + { 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, upload-time = "2025-07-03T13:10:21.857Z" }, + { 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, upload-time = "2025-07-01T09:14:45.698Z" }, + { 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, upload-time = "2025-07-01T09:14:47.415Z" }, + { 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, upload-time = "2025-07-01T09:14:49.636Z" }, + { 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, upload-time = "2025-07-01T09:14:51.962Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" }, + { 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, upload-time = "2025-07-01T09:14:59.79Z" }, + { 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, upload-time = "2025-07-01T09:15:01.648Z" }, + { 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, upload-time = "2025-07-03T13:10:27.018Z" }, + { 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, upload-time = "2025-07-03T13:10:33.01Z" }, + { 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, upload-time = "2025-07-01T09:15:03.365Z" }, + { 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, upload-time = "2025-07-01T09:15:05.655Z" }, + { 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, upload-time = "2025-07-01T09:15:07.358Z" }, + { 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, upload-time = "2025-07-01T09:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" }, + { 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, upload-time = "2025-07-01T09:15:17.429Z" }, + { 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, upload-time = "2025-07-01T09:15:19.423Z" }, + { 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, upload-time = "2025-07-03T13:10:38.404Z" }, + { 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, upload-time = "2025-07-03T13:10:44.987Z" }, + { 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, upload-time = "2025-07-01T09:15:21.237Z" }, + { 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, upload-time = "2025-07-01T09:15:23.186Z" }, + { 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, upload-time = "2025-07-01T09:15:25.1Z" }, + { 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, upload-time = "2025-07-01T09:15:27.378Z" }, + { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" }, + { 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, upload-time = "2025-07-01T09:15:35.194Z" }, + { 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, upload-time = "2025-07-01T09:15:37.114Z" }, + { 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, upload-time = "2025-07-03T13:10:50.248Z" }, + { 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, upload-time = "2025-07-03T13:10:56.432Z" }, + { 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, upload-time = "2025-07-01T09:15:39.436Z" }, + { 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, upload-time = "2025-07-01T09:15:41.269Z" }, + { 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, upload-time = "2025-07-01T09:15:43.13Z" }, + { 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, upload-time = "2025-07-01T09:15:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" }, + { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" }, +] + +[[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, upload-time = "2025-05-07T22:47:42.121Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.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/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +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 = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +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" } +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]] +name = "pyodide-py" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +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" } +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]] +name = "pytest" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/ba/45911d754e8eba3d5a841a5ce61a65a685ff1798421ac054f85aa8747dfb/pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c", size = 1517714, upload-time = "2025-06-18T05:48:06.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, +] + +[[package]] +name = "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, upload-time = "2024-08-06T20:33:50.674Z" } +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]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "0.47.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +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" } +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 = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +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" } +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]] +name = "virtualenv" +version = "20.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +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" } +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" }, +]