diff --git a/.env b/.env index d1c1519c1..48bc7a655 100644 --- a/.env +++ b/.env @@ -1,5 +1,4 @@ DEBUG=on -DUMMY_COMPILER=on SECRET_KEY=django-insecure-nm#!8%zhc0wwi#m_*l9l)=m*6gs4&o_^-e5b5vj*k05&yaqc1 DATABASE_URL=sqlite:///dev.db API_BASE=http://127.0.0.1:8000/api diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63e280292..a15c344f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: branches: - main pull_request: + jobs: backend_test_docker_dev: name: backend tests (docker dev container) @@ -11,45 +12,20 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v6 - - name: Build decompme_backend image - run: |- - docker build backend --target dev -t decompme_backend - - name: Run tests + - name: Build backend development image + run: docker build backend --target dev -t decompme_backend + - name: Run backend tests run: |- - sudo sysctl -w kernel.apparmor_restrict_unprivileged_unconfined=0 - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - # NOTE: CI does not run as 'ubuntu' user export USER_1000=$(getent passwd 1000 | cut -d: -f1) - for dir in backend/.venv sandbox local_files compilers libraries; do - mkdir -p "$dir" - sudo chown "${USER_1000}:${USER_1000}" "$dir" - done + mkdir -p backend/.venv + sudo chown "${USER_1000}:${USER_1000}" backend/.venv docker run \ -v $(pwd):/decomp.me \ - -v $(pwd)/local_files:/local_files \ - -v $(pwd)/compilers:/compilers \ - -v $(pwd)/libraries:/libraries \ - --security-opt apparmor=unconfined \ - --security-opt seccomp=unconfined \ - --cap-drop all \ - --cap-add setuid \ - --cap-add setgid \ - --cap-add setfcap \ - --tmpfs /sandbox/tmp:exec,uid=1000,gid=1000,size=64M,mode=0700 \ --entrypoint /bin/bash \ - -e COMPILER_BASE_PATH=/compilers \ - -e LIBRARY_BASE_PATH=/libraries \ - -e LOCAL_FILE_DIR=/local_files \ - -e USE_SANDBOX_JAIL=on \ - -e SANDBOX_DISABLE_PROC=true \ - -e TIMEOUT_SCALE_FACTOR=10 \ - -e DUMMY_COMPILER=1 \ + -e DATABASE_URL=sqlite:///:memory: \ + -e SECRET_KEY=secret-key-secret-key-secret-key-secret-key-secret-key-secret-key \ decompme_backend \ - -c 'cd /decomp.me/backend && \ - uv sync && \ - uv run compilers/download.py --compilers-dir ${COMPILER_BASE_PATH} && \ - uv run libraries/download.py --libraries-dir ${LIBRARY_BASE_PATH} && \ - uv run python manage.py test' + -c 'cd /decomp.me/backend && uv sync && uv run python manage.py test' backend_test_docker_prod: name: backend tests (docker prod container) @@ -57,65 +33,71 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v6 - - name: Build decompme_backend image - run: |- - docker build backend --target prod -t decompme_backend - - name: Fetch compilers and libraries + - name: Build backend production image + run: docker build backend --target prod -t decompme_backend + - name: Run backend tests run: |- - python3 -m pip install requests - python3 backend/compilers/download.py - python3 backend/libraries/download.py - export USER_1000=$(getent passwd 1000 | cut -d: -f1) - sudo chown -R ${USER_1000}:${USER_1000} backend/compilers - sudo chown -R ${USER_1000}:${USER_1000} backend/libraries - - name: Run tests + docker run \ + --entrypoint /bin/bash \ + -e DATABASE_URL=sqlite:///:memory: \ + -e SECRET_KEY=secret-key-secret-key-secret-key-secret-key-secret-key-secret-key \ + decompme_backend \ + -c 'uv run python manage.py test' + + cromper_test: + name: cromper tests (docker dev container) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - name: Build Cromper development image + run: docker build cromper --target dev -t decompme_cromper + - name: Run Cromper tests run: |- sudo sysctl -w kernel.apparmor_restrict_unprivileged_unconfined=0 sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 export USER_1000=$(getent passwd 1000 | cut -d: -f1) - mkdir -p local_files && sudo chown ${USER_1000}:${USER_1000} local_files - docker run \ - -v $(pwd)/local_files:/local_files \ - -v $(pwd)/backend/compilers:/compilers \ - -v $(pwd)/backend/libraries:/libraries \ + for dir in cromper/.venv cromper/compilers cromper/libraries; do + mkdir -p "$dir" + sudo chown -R "$USER_1000:$USER_1000" "$dir" + done + docker run --rm \ + -v $(pwd)/cromper:/cromper \ --security-opt apparmor=unconfined \ --security-opt seccomp=unconfined \ --cap-drop all \ --cap-add setuid \ --cap-add setgid \ --cap-add setfcap \ - --tmpfs /sandbox/tmp:exec,uid=1000,gid=1000,size=64M,mode=0700 \ + --tmpfs /tmp/sandbox:exec,uid=1000,gid=1000,size=256M,mode=0700 \ --entrypoint /bin/bash \ - -e LOCAL_FILE_DIR=/local_files \ - -e COMPILER_BASE_PATH=/compilers \ - -e LIBRARY_BASE_PATH=/libraries \ - -e USE_SANDBOX_JAIL=on \ + -e COMPILER_BASE_PATH=/cromper/compilers \ + -e LIBRARY_BASE_PATH=/cromper/libraries \ + -e USE_SANDBOX_JAIL=true \ -e SANDBOX_DISABLE_PROC=true \ - -e TIMEOUT_SCALE_FACTOR=10 \ - -e DUMMY_COMPILER=1 \ - -e DATABASE_URL=sqlite:///:memory: \ - -e SECRET_KEY=secret-key-secret-key-secret-key-secret-key-secret-key-secret-key \ - decompme_backend \ - -c 'uv run python manage.py test' + decompme_cromper \ + -c 'cd /cromper && \ + uv sync --locked --all-extras && \ + uv run --locked compilers/download.py --compilers-dir $COMPILER_BASE_PATH && \ + uv run --locked libraries/download.py --libraries-dir $LIBRARY_BASE_PATH && \ + uv run --locked pytest' docker_compose_test: name: test docker compose runs-on: ubuntu-latest - timeout-minutes: 10 - + timeout-minutes: 15 steps: - uses: actions/checkout@v6 - name: Fetch compilers and libraries run: | python3 -m pip install requests - python3 backend/compilers/download.py - python3 backend/libraries/download.py + python3 cromper/compilers/download.py + python3 cromper/libraries/download.py export USER_1000=$(getent passwd 1000 | cut -d: -f1) - sudo chown -R ${USER_1000}:${USER_1000} backend/compilers - sudo chown -R ${USER_1000}:${USER_1000} backend/libraries + sudo chown -R "${USER_1000}:${USER_1000}" cromper/compilers cromper/libraries - - name: Setup dummy production env + - name: Setup dummy production environment run: | echo 'POSTGRES_USER=decompme' >> docker.prod.env echo 'POSTGRES_PASSWORD=decompme' >> docker.prod.env @@ -123,14 +105,17 @@ jobs: echo 'DATABASE_URL=psql://decompme:decompme@postgres:5432/decompme' >> docker.prod.env echo 'CONN_MAX_AGE=60' >> docker.prod.env echo 'CONN_HEALTH_CHECKS="true"' >> docker.prod.env - echo 'SANDBOX_DISABLE_PROC="true"' >> docker.prod.env echo 'ALLOWED_HOSTS="backend,backend-green,backend-blue,localhost,127.0.0.1"' >> docker.prod.env + echo 'CROMPER_URL=http://cromper:8888' >> docker.prod.env + echo 'CROMPER_PORT=8888' >> docker.prod.env echo 'USE_SANDBOX_JAIL="on"' >> docker.prod.env + echo 'SANDBOX_DISABLE_PROC="true"' >> docker.prod.env echo 'CI=true' >> docker.prod.env echo 'ACTIVE_SLOT=blue' >> .deploy.env echo 'BLUE_TAG=latest' >> .deploy.env echo 'GREEN_TAG=latest' >> .deploy.env echo 'NGINX_TAG=latest' >> .deploy.env + echo 'CROMPER_TAG=latest' >> .deploy.env - name: Comment out SSL server configuration from nginx run: | @@ -138,50 +123,48 @@ jobs: - name: Prepare production runtime config run: | - cp ./nginx/production/runtime/geo.conf.example ./nginx/production/runtime/geo.conf + cp ./nginx/production/runtime/geo.conf.example ./nginx/production/runtime/geo.conf cp ./nginx/production/runtime/upstream.conf.example ./nginx/production/runtime/upstream.conf - name: Build production images run: | - docker compose -f docker-compose.prod.yaml build nginx backend-blue frontend-blue + docker compose -f docker-compose.prod.yaml build cromper nginx backend-blue frontend-blue - name: Ensure production services run: | - timeout 60s python3 deploy.py ensure || true + timeout 120s python3 deploy.py ensure - name: View docker compose logs (blue) + if: always() run: | timeout 15s docker compose -f docker-compose.prod.yaml logs -f || true - name: Check docker compose status (blue) - run: | - docker compose -f docker-compose.prod.yaml ps + run: docker compose -f docker-compose.prod.yaml ps - name: Deploy green slot - run: | - python3 deploy.py deploy --no-pull latest green + run: python3 deploy.py deploy --no-pull latest green - name: View docker compose logs (green) + if: always() run: | timeout 15s docker compose -f docker-compose.prod.yaml logs -f || true - name: Check docker compose status (green) - run: | - docker compose -f docker-compose.prod.yaml ps + run: docker compose -f docker-compose.prod.yaml ps - name: Shut everything down if: always() - run: | - docker compose -f docker-compose.prod.yaml down + run: docker compose -f docker-compose.prod.yaml down - - name: Print out image sizes + - name: Print image sizes if: always() - run: | - docker image ls + run: docker image ls frontend_lint: - name: biome + name: frontend lint and tests runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v6 - name: Setup Node.js 24 @@ -203,9 +186,10 @@ jobs: cd frontend yarn test - mypy: - name: mypy + backend_lint: + name: backend lint and type check runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v6 - name: Install uv @@ -218,12 +202,15 @@ jobs: python-version: "3.14" - run: |- cd backend - uv sync - uv run mypy + uv sync --all-extras + uv run mypy . + uv run ruff check + uv run ruff format --check - ruff: - name: ruff + cromper_lint: + name: cromper lint and type check runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@v6 - name: Install uv @@ -235,7 +222,8 @@ jobs: with: python-version: "3.14" - run: |- - cd backend - uv sync - uv run ruff check . - uv run ruff format --check . + cd cromper + uv sync --locked --all-extras + uv run --locked mypy . + uv run --locked ruff check + uv run --locked ruff format --check diff --git a/.github/workflows/ghcr.yaml b/.github/workflows/ghcr.yaml index 99dad23e8..71662b167 100644 --- a/.github/workflows/ghcr.yaml +++ b/.github/workflows/ghcr.yaml @@ -21,6 +21,9 @@ jobs: - name: backend context: backend target: prod + - name: cromper + context: cromper + target: prod - name: frontend context: frontend target: prod diff --git a/backend/.dockerignore b/backend/.dockerignore index 46fb6aa83..90771823b 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -8,10 +8,7 @@ __pycache__/ Dockerfile* docker.*.env -# compilers -compilers/*/** -!compilers/download.py -# libraries -libraries/*/** -!libraries/download.py +# TEMPORARY: because i'm switching between main/cromper branches +compilers/** +libraries/** diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..3fc302dcb --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,3 @@ +# TEMPORARY: because i'm switching between main/cromper branches +compilers/** +libraries/** diff --git a/backend/Dockerfile b/backend/Dockerfile index 1ded00b7e..24ba4c99f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,5 +1,3 @@ -# This image intentionally targets amd64 because the bundled -# compiler/binutils artifacts are x86 Linux binaries. FROM --platform=linux/amd64 ubuntu:26.04 AS base ENV DEBIAN_FRONTEND=noninteractive @@ -7,225 +5,47 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates \ - software-properties-common \ - && rm -rf /var/lib/apt/lists/* - -FROM base AS python314 - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ + git \ + netcat-traditional \ python-is-python3 \ python3 \ python3.14-dev \ python3.14-venv \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=ghcr.io/astral-sh/uv:0.9.11 /uv /bin/uv - -FROM base AS nsjail - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - autoconf \ - bison \ - flex \ - g++ \ - gcc \ - git \ - libnl-route-3-dev \ - libprotobuf-dev \ - libtool \ - make \ - pkg-config \ - protobuf-compiler \ - && rm -rf /var/lib/apt/lists/* - -RUN git clone "https://github.com/google/nsjail" \ - --recursive --branch 3.6 /nsjail \ - && cd /nsjail \ - && make - -FROM base AS binutils - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - binutils-aarch64-linux-gnu \ - binutils-arm-none-eabi \ - binutils-djgpp \ - binutils-mingw-w64-i686 \ - binutils-mips-linux-gnu \ - binutils-mipsel-linux-gnu \ - binutils-powerpc-linux-gnu \ - binutils-sh-elf \ - binutils-x86-64-linux-gnu \ - bzip2 \ - libarchive-tools \ - wget \ - && rm -rf /var/lib/apt/lists/* - -RUN mkdir /binutils /binutils-libs - -RUN cp /usr/bin/*-as /binutils/ \ - && cp /usr/bin/*-objcopy /binutils/ \ - && cp /usr/bin/*-objdump /binutils/ \ - && cp /usr/bin/*-nm /binutils/ - -RUN cp /lib/x86_64-linux-gnu/libbfd*.so* /binutils-libs/ \ - && cp /lib/x86_64-linux-gnu/libctf*.so* /binutils-libs/ \ - && cp /lib/x86_64-linux-gnu/libdebuginfod*.so* /binutils-libs/ \ - && cp /lib/x86_64-linux-gnu/libopcodes*.so* /binutils-libs/ \ - && cp /lib/x86_64-linux-gnu/libsframe*.so* /binutils-libs/ - -WORKDIR /binutils - -# Patched mips binutils -RUN ARCHIVE=binutils-mips-ps2-decompals-linux-x86-64.tar.gz; \ - URL=https://github.com/decompals/binutils-mips-ps2-decompals/releases/download/v0.8/$ARCHIVE; \ - SHA256=40f8dda9caae9ec2cd88811d7c4a322d5dd0fa9fe3b65ef6c9280c96b58415ea; \ - wget -O "$ARCHIVE" "$URL" \ - && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ - && tar xvzf "$ARCHIVE" mips-ps2-decompals-as mips-ps2-decompals-nm mips-ps2-decompals-objdump \ - && rm "$ARCHIVE" \ - && chmod +x mips-ps2-decompals-* - -# Patched PowerPC binutils -RUN ARCHIVE=linux-x86_64.zip; \ - URL=https://github.com/encounter/gc-wii-binutils/releases/download/2.42-2/$ARCHIVE; \ - SHA256=64e4bf7aee5c06075d8fa818ebd2a0a3a72e0eeef3896bdec2f198b0e950fe63; \ - wget -O "$ARCHIVE" "$URL" \ - && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ - && bsdtar -xvf "$ARCHIVE" powerpc-eabi-as powerpc-eabi-nm powerpc-eabi-objdump \ - && rm "$ARCHIVE" \ - && chmod +x powerpc-eabi-* - -# Xbox 360 (Xenon) PowerPC binutils -RUN ARCHIVE=linux-x86_64.zip; \ - URL=https://github.com/encounter/xbox360-binutils/releases/download/2.45.1-1/$ARCHIVE; \ - SHA256=33e01b228787ed507ebd08755370d5dac2053c2a37b0647a48c6c19cc528218c; \ - wget -O "$ARCHIVE" "$URL" \ - && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ - && bsdtar -xvf "$ARCHIVE" powerpc-xenon-pe-as powerpc-xenon-pe-nm powerpc-xenon-pe-objdump \ - && rm "$ARCHIVE" \ - && chmod +x powerpc-xenon-pe-* - -# MSDOS specific -RUN ARCHIVE=omftools.tar.gz; \ - URL=https://github.com/OmniBlade/binutils-gdb/releases/download/omf-build/$ARCHIVE; \ - SHA256=b17dbbd3ee257bb870731df141ca44dde7eedee44d79294ab2acd1bccfed7834; \ - wget -O "$ARCHIVE" "$URL" \ - && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ - && tar xvzf "$ARCHIVE" jwasm \ - && rm "$ARCHIVE" -RUN ARCHIVE=omftools-linux-x86_64.tar.gz; \ - URL=https://github.com/decompals/binutils-omf/releases/download/v0.4/$ARCHIVE; \ - SHA256=d3a2e95e2b6079bb444f4f8f4d2d798954c58b2e7e59fb10523538696133b0c7; \ - wget -O "$ARCHIVE" "$URL" \ - && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ - && tar xvzf "$ARCHIVE" omf-nm omf-objdump \ - && rm "$ARCHIVE" - -# Android binutils -RUN ARCHIVE=android-ndk-r8e-linux-x86_64.tar.bz2; \ - URL=https://github.com/decompme/compilers/releases/download/compilers/$ARCHIVE; \ - SHA256=126dce67022049dca1be063ea0f8a6c07070f9f0b139339fc9e6a8ff617ed7fc; \ - wget -O "$ARCHIVE" "$URL" \ - && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ - && bash -c 'tar xjvf "$0" --strip-components=5 toolchains/x86-4.7/prebuilt/linux-x86_64/bin/i686-linux-android-{as,nm,objdump}' "$ARCHIVE" \ - && rm "$ARCHIVE" - - -FROM python314 AS dependencies - -RUN add-apt-repository -y ppa:dosemu2/ppa \ - && add-apt-repository -y ppa:stsp-0/dj64 \ - && apt-get update \ - && apt-get install -y -o APT::Immediate-Configure=false --no-install-recommends \ - cpp \ - dj64 \ - dos2unix \ - dosemu2 \ - g++ \ - iptables \ - libc6-dev-i386 \ - libnl-route-3-200 \ - libprotobuf32t64 \ - libtinfo6 \ - netcat-traditional \ wget \ && rm -rf /var/lib/apt/lists/* -# NOTE: libtinfo5 is required for clang -RUN ARCHIVE=libtinfo5_6.3-2ubuntu0.2_amd64.deb; \ - URL=https://security.ubuntu.com/ubuntu/pool/universe/n/ncurses/$ARCHIVE; \ - SHA256=b9bb64e716a7d9de05b1b33992763142ca81bcae3a7f8ce7e29fa3c6fd32f1e8; \ - wget -O "$ARCHIVE" "$URL" \ - && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ - && apt install "./$ARCHIVE" \ - && rm "$ARCHIVE" - -RUN mkdir -p /etc/fonts - - -# Ensure /sandbox has correct ownership -RUN mkdir -p /sandbox \ - && chown -R ubuntu:ubuntu /sandbox - -# Switch to non-root user -USER ubuntu - -COPY --from=nsjail /nsjail/nsjail /bin/nsjail - -COPY --from=binutils /binutils/ /usr/bin/ -COPY --from=binutils /binutils-libs/ /usr/lib/x86_64-linux-gnu/ - -COPY --from=ghcr.io/decompals/wibo:1.1.1 /usr/local/bin/wibo /usr/bin/ +COPY --from=ghcr.io/astral-sh/uv:0.9.11 /uv /bin/uv WORKDIR /backend +RUN chown ubuntu:ubuntu /backend -FROM dependencies AS dev - -USER root -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - git \ - && rm -rf /var/lib/apt/lists/* +FROM base AS dev USER ubuntu ENTRYPOINT ["/backend/docker_entrypoint.sh"] -FROM python314 AS uv-install - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - git \ - && rm -rf /var/lib/apt/lists/* +FROM base AS uv-install USER ubuntu -WORKDIR /backend - COPY pyproject.toml uv.lock /backend/ RUN uv sync --locked --no-dev -FROM dependencies AS prod +FROM base AS prod COPY --from=uv-install /backend/.venv /backend/.venv -COPY manage.py /backend -COPY housekeeping.py /backend - +COPY manage.py db_housekeeping.py /backend/ COPY decompme /backend/decompme -COPY libraries /backend/libraries -COPY compilers /backend/compilers - COPY coreapp /backend/coreapp - COPY docker_prod_entrypoint.sh /backend/docker_prod_entrypoint.sh +USER ubuntu + ENTRYPOINT ["/backend/docker_prod_entrypoint.sh"] diff --git a/backend/coreapp/apps.py b/backend/coreapp/apps.py index 286b5a5e4..f97118326 100644 --- a/backend/coreapp/apps.py +++ b/backend/coreapp/apps.py @@ -1,5 +1,9 @@ +import logging + from django.apps import AppConfig +logger = logging.getLogger(__name__) + class CoreappConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" diff --git a/backend/coreapp/compiler_utils.py b/backend/coreapp/compiler_utils.py new file mode 100644 index 000000000..f5e25002a --- /dev/null +++ b/backend/coreapp/compiler_utils.py @@ -0,0 +1,99 @@ +""" +Compiler utilities for the Django backend. + +This module contains utility functions for working with compilers +that don't require the full compilation infrastructure. +""" + +from dataclasses import dataclass +from enum import Enum + + +@dataclass(frozen=True) +class Platform: + id: str + name: str + description: str + arch: str + compilers: list[str] + has_decompiler: bool = False + + +# TODO copied from cromper, should deduplicate +class Language(Enum): + C = "C" + OLD_CXX = "C++" + CXX = "C++" + PASCAL = "Pascal" + ASSEMBLY = "Assembly" + OBJECTIVE_C = "ObjectiveC" + + def get_file_extension(self) -> str: + return { + Language.C: "c", + Language.CXX: "cpp", + Language.OLD_CXX: "c++", + Language.PASCAL: "p", + Language.ASSEMBLY: "s", + Language.OBJECTIVE_C: "m", + }[self] + + +@dataclass(frozen=True) +class Compiler: + id: str + platform: Platform + flags: str + diff_flags: str + language: Language = Language.C + + +def filter_compiler_flags(compiler_flags: str) -> str: + """ + Filter out compiler flags that are not relevant for matching or display. + + This is used to clean up flags before storing them in the database + or displaying them to users. + """ + # Remove irrelevant flags that are part of the base compiler configs or + # don't affect matching, but clutter the compiler settings field. + skip_flags_with_args = { + "-B", + "-I", + "-U", + } + skip_flags = { + "-ffreestanding", + "-non_shared", + "-Xcpluscomm", + "-Wab,-r4300_mul", + "-c", + } + + # Parse and filter flags + filtered_flags = [] + skip_next = False + + for flag in compiler_flags.split(): + if skip_next: + skip_next = False + continue + + if flag in skip_flags: + continue + + # Check if this flag takes an argument + flag_matches_skip = False + for skip_flag in skip_flags_with_args: + if flag.startswith(skip_flag): + if flag == skip_flag: + # Flag and argument are separate, skip next token + skip_next = True + # Either way, skip this flag + flag_matches_skip = True + break + + if not flag_matches_skip: + filtered_flags.append(flag) + + return " ".join(filtered_flags) diff --git a/backend/coreapp/cromper_client.py b/backend/coreapp/cromper_client.py new file mode 100644 index 000000000..13903cec4 --- /dev/null +++ b/backend/coreapp/cromper_client.py @@ -0,0 +1,255 @@ +import base64 +import json +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Dict, Optional + +import requests +from django.conf import settings + +from coreapp.compiler_utils import Compiler, Platform + + +@dataclass +class CompilationResult: + """Result of a compilation operation.""" + + elf_object: bytes + errors: str + + +@dataclass +class DiffResult: + """Result of a diff operation.""" + + result: Optional[dict[str, Any]] + errors: str + + +class CromperError(Exception): + pass + + +class CromperTimeoutError(CromperError): + """Exception raised when a cromper request times out.""" + + pass + + +if TYPE_CHECKING: + from coreapp.models.scratch import Asm + +logger = logging.getLogger(__name__) + + +class CromperClient: + """Client for communicating with the cromper service.""" + + def __init__(self, base_url: str, timeout: int = 10): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self.session = requests.Session() + self._compilers_cache: Optional[Dict[str, Compiler]] = None + self._platforms_cache: Optional[Dict[str, Platform]] = None + + def _make_request( + self, method: str, endpoint: str, **kwargs: Any + ) -> Dict[str, Any]: + """Make a request to the cromper service.""" + url = f"{self.base_url}{endpoint}" + try: + response = self.session.request(method, url, timeout=self.timeout, **kwargs) + response.raise_for_status() + return response.json() + except requests.exceptions.Timeout as e: + logger.error(f"Timeout communicating with cromper service: {e}") + raise CromperTimeoutError(f"cromper service timeout: {e}") + except requests.exceptions.RequestException as e: + logger.error(f"Error communicating with cromper service: {e}") + raise CromperError(f"cromper service error: {e}") + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON response from cromper service: {e}") + raise CromperError("Invalid response from cromper service") + + def get_compilers(self) -> Dict[str, Compiler]: + """Get all compilers from cromper service, with caching.""" + if self._compilers_cache is None: + logger.info("Fetching compilers from cromper service...") + response = self._make_request("GET", "/compiler") + response_json = response.get("compilers", {}) + + self._compilers_cache = {} + for key in response_json: + platform_id = response_json[key].get("platform") + platform = self.get_platform_by_id(platform_id) + del response_json[key]["platform"] + self._compilers_cache[key] = Compiler( + id=key, platform=platform, **response_json[key] + ) + + logger.info(f"Cached {len(self._compilers_cache)} compilers") + return self._compilers_cache + + def get_platforms(self) -> Dict[str, Platform]: + """Get all platforms from cromper service, with caching.""" + if self._platforms_cache is None: + logger.info("Fetching platforms from cromper service...") + response = self._make_request("GET", "/platform") + self._platforms_cache = {k: Platform(**v) for (k, v) in response.items()} + logger.info(f"Cached {len(self._platforms_cache)} platforms") + return self._platforms_cache + + def get_libraries(self, platform: str = "") -> list[dict[str, Any]]: + """Get available libraries from the cromper service.""" + params = {} + if platform: + params["platform"] = platform + + response = self._make_request("GET", "/library", params=params) + return response.get("libraries", []) + + def get_compiler_by_id(self, compiler_id: str) -> Compiler: + """Get a specific compiler by ID.""" + compilers = self.get_compilers() + if compiler_id not in compilers: + raise ValueError(f"Unknown compiler: {compiler_id}") + return compilers[compiler_id] + + def get_platform_by_id(self, platform_id: str) -> Platform: + """Get a specific platform by ID.""" + platforms = self.get_platforms() + for id, platform in platforms.items(): + if id == platform_id: + return platform + raise ValueError(f"Unknown platform: {platform_id}") + + def refresh_cache(self) -> None: + """Force refresh of compilers and platforms cache.""" + self._compilers_cache = None + self._platforms_cache = None + # Trigger reload + self.get_compilers() + self.get_platforms() + + def compile_code( + self, + compiler_id: str, + compiler_flags: str, + code: str, + context: str, + function: str = "", + libraries: list[dict[str, str]] = [], + ) -> Dict[str, Any]: + """Compile code using the cromper service.""" + data = { + "compiler_id": compiler_id, + "compiler_flags": compiler_flags, + "code": code, + "context": context, + "function": function, + "libraries": libraries, + } + response = self._make_request("POST", "/compile", json=data) + + if not response.get("success"): + error_msg = response.get("error", "Unknown compilation error") + raise CromperError(error_msg) + + # Decode the base64 elf object + elf_object_b64 = response.get("elf_object", "") + elf_object = base64.b64decode(elf_object_b64) + + return {"elf_object": elf_object, "errors": response.get("errors", "")} + + def assemble_asm(self, platform_id: str, asm: "Asm") -> Dict[str, Any]: + """Assemble assembly using the cromper service.""" + data = { + "platform_id": platform_id, + "asm_data": asm.data, + "asm_hash": asm.hash, + } + + response = self._make_request("POST", "/assemble", json=data) + + if not response.get("success"): + error_msg = response.get("error", "Unknown assembly error") + raise CromperError(error_msg) + + # Decode the base64 elf object + elf_object_b64 = response.get("elf_object", "") + elf_object = base64.b64decode(elf_object_b64) + + return { + "hash": response.get("hash"), + "arch": response.get("arch"), + "elf_object": elf_object, + } + + def diff( + self, + platform_id: str, + target_elf: bytes, + compiled_elf: bytes, + diff_label: str = "", + diff_flags: list[str] = [], + ) -> Dict[str, Any]: + """Generate diff using the cromper service.""" + # Encode elf object as base64 + target_elf_b64 = base64.b64encode(target_elf).decode("utf-8") + compiled_elf_b64 = base64.b64encode(compiled_elf).decode("utf-8") + + data = { + "platform_id": platform_id, + "target_elf": target_elf_b64, + "compiled_elf": compiled_elf_b64, + "diff_label": diff_label, + "diff_flags": diff_flags, + } + + response = self._make_request("POST", "/diff", json=data) + + if not response.get("success"): + error_msg = response.get("error", "Unknown diff error") + raise CromperError(error_msg) + + return { + "result": response.get("result"), + "errors": response.get("errors"), + } + + def decompile( + self, + platform_id: str, + compiler_id: str, + asm: str, + default_source_code: str = "", + context: str = "", + ) -> str: + """Decompile assembly using the cromper service.""" + data = { + "platform_id": platform_id, + "compiler_id": compiler_id, + "asm": asm, + "default_source_code": default_source_code, + "context": context, + } + + response = self._make_request("POST", "/decompile", json=data) + + if not response.get("success"): + error_msg = response.get("error", "Unknown decompilation error") + raise CromperError(error_msg) + + return response.get("decompiled_code", "") + + +# Global cromper client instance +_cromper_client: Optional[CromperClient] = None + + +def get_cromper_client() -> CromperClient: + """Get the global cromper client instance.""" + global _cromper_client + if _cromper_client is None: + _cromper_client = CromperClient(settings.CROMPER_URL) + return _cromper_client diff --git a/backend/coreapp/decompiler_wrapper.py b/backend/coreapp/decompiler_wrapper.py deleted file mode 100644 index 3a8899bc4..000000000 --- a/backend/coreapp/decompiler_wrapper.py +++ /dev/null @@ -1,47 +0,0 @@ -import logging - -from coreapp import compilers -from coreapp.compilers import Compiler -from coreapp.m2c_wrapper import M2CError, M2CWrapper -from coreapp.platforms import Platform - -logger = logging.getLogger(__name__) - -MAX_M2C_ASM_LINES = 15000 - -DECOMP_WITH_CONTEXT_FAILED_PREAMBLE = "/* Decompilation with context failed; here's the decompilation without context: */\n" - - -class DecompilerWrapper: - @staticmethod - def decompile( - default_source_code: str, - platform: Platform, - asm: str, - context: str, - compiler: Compiler, - ) -> str: - if compiler == compilers.DUMMY: - return f"decompiled({asm})" - - if not M2CWrapper.is_platform_supported(platform.id): - return f"/* No decompiler yet implemented for {platform.arch} */\n{default_source_code}" - - ret = default_source_code - if len(asm.splitlines()) > MAX_M2C_ASM_LINES: - return "/* Too many lines to decompile; please run m2c manually */" - - try: - ret = M2CWrapper.decompile(asm, context, platform.id, compiler) - except M2CError as e: - # Attempt to decompile the source without context as a last-ditch effort - try: - ret = M2CWrapper.decompile(asm, "", platform.id, compiler) - ret = f"{e}\n{DECOMP_WITH_CONTEXT_FAILED_PREAMBLE}\n{ret}" - except M2CError as e: - ret = f"{e}\n{default_source_code}" - except Exception: - logger.exception("Error running m2c") - ret = f"/* Internal error while running m2c */\n{default_source_code}" - - return ret diff --git a/backend/coreapp/error.py b/backend/coreapp/error.py index bb4d9639c..26abff0c6 100644 --- a/backend/coreapp/error.py +++ b/backend/coreapp/error.py @@ -1,9 +1,8 @@ from sqlite3 import IntegrityError -from subprocess import CalledProcessError -from typing import Any, ClassVar, Self +from typing import Any from rest_framework.response import Response -from rest_framework.status import HTTP_400_BAD_REQUEST, HTTP_500_INTERNAL_SERVER_ERROR +from rest_framework.status import HTTP_500_INTERNAL_SERVER_ERROR from rest_framework.views import exception_handler @@ -12,15 +11,7 @@ def custom_exception_handler(exc: Exception, context: Any) -> Response | None: # to get the standard error response. response = exception_handler(exc, context) - if isinstance(exc, SubprocessError): - response = Response( - data={ - "code": exc.SUBPROCESS_NAME, - "detail": exc.msg, - }, - status=HTTP_400_BAD_REQUEST, - ) - elif isinstance(exc, AssertionError) or isinstance(exc, IntegrityError): + if isinstance(exc, AssertionError) or isinstance(exc, IntegrityError): response = Response( data={ "detail": str(exc), @@ -32,67 +23,3 @@ def custom_exception_handler(exc: Exception, context: Any) -> Response | None: response.data["kind"] = exc.__class__.__name__ return response - - -class SubprocessError(Exception): - SUBPROCESS_NAME: ClassVar[str] = "Subprocess" - msg: str - stdout: str - stderr: str - - def __init__(self, message: str): - self.msg = f"{self.SUBPROCESS_NAME} error: {message}" - - super().__init__(self.msg) - self.stdout = "" - self.stderr = "" - - @classmethod - def from_process_error(cls, ex: CalledProcessError) -> Self: - command = ex.cmd[0] if isinstance(ex.cmd, list) else ex.cmd - error = cls(f"{command} returned {ex.returncode}") - error.stdout = ex.stdout or "" - error.stderr = ex.stderr or "" - if error.stdout: - error.msg = error.stdout - error.args = (error.msg,) - return error - - -class DiffError(SubprocessError): - SUBPROCESS_NAME: ClassVar[str] = "Diff" - - -class ObjdumpError(SubprocessError): - SUBPROCESS_NAME: ClassVar[str] = "objdump" - - -class NmError(SubprocessError): - SUBPROCESS_NAME: ClassVar[str] = "nm" - - -class CompilationError(SubprocessError): - SUBPROCESS_NAME: ClassVar[str] = "Compiler" - - -class SandboxError(SubprocessError): - SUBPROCESS_NAME: ClassVar[str] = "Sandbox" - - -class AssemblyError(SubprocessError): - SUBPROCESS_NAME: ClassVar[str] = "Assembler" - - @classmethod - def from_process_error(cls, ex: CalledProcessError) -> Self: - error = super().from_process_error(ex) - - error_lines = [] - for line in error.stdout.splitlines(): - if "asm.s:" in line: - error_lines.append(line[line.find("asm.s:") + len("asm.s:") :].strip()) - else: - error_lines.append(line) - error.msg = "\n".join(error_lines) - error.args = (error.msg,) - - return error diff --git a/backend/coreapp/middleware.py b/backend/coreapp/middleware.py index 734afd7a1..1c6c8a19d 100644 --- a/backend/coreapp/middleware.py +++ b/backend/coreapp/middleware.py @@ -14,9 +14,6 @@ logger = logging.getLogger(__name__) -if TYPE_CHECKING: - pass - class AnonymousUser(auth.models.AnonymousUser): profile: Profile diff --git a/backend/coreapp/models/scratch.py b/backend/coreapp/models/scratch.py index 50da3fbf9..db0460bb5 100644 --- a/backend/coreapp/models/scratch.py +++ b/backend/coreapp/models/scratch.py @@ -2,7 +2,8 @@ import json import logging from collections.abc import Sequence -from typing import Any +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any import itsdangerous from django.conf import settings @@ -10,12 +11,26 @@ from django.db import IntegrityError, models from django.utils.crypto import get_random_string -from ..libraries import Library +if TYPE_CHECKING: + from coreapp.compiler_utils import Language + from .profile import Profile logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class Library: + name: str + version: str + + def to_json(self) -> dict[str, str]: + return { + "name": self.name, + "version": self.version, + } + + def gen_scratch_id() -> str: ret = get_random_string(length=5) @@ -197,6 +212,40 @@ def verify_claim_token(self, token: str) -> bool: except itsdangerous.BadData: return False + def get_language(self) -> "Language": + from coreapp.cromper_client import get_cromper_client + + cromper = get_cromper_client() + compiler = cromper.get_compiler_by_id(self.compiler) + + """ + Strategy for extracting a scratch's language: + - If the scratch's compiler has a LanguageFlagSet in its flags, attempt to match a language flag against that + - Otherwise, fallback to the compiler's default language + """ + # TODO need a more robust way to do this + # language_flag_set = next( + # (i for i in compiler.flags if isinstance(i, LanguageFlagSet)), + # None, + # ) + + # if language_flag_set: + # matches = [ + # (flag, language) + # for flag, language in language_flag_set.flags.items() + # if flag in self.compiler_flags + # ] + + # if matches: + # # taking the longest avoids detecting C++ as C + # longest_match = max(matches, key=lambda m: len(m[0])) + # return longest_match[1].value + + # If we're here, either the compiler doesn't have a LanguageFlagSet, or the scratch doesn't + # have a flag within it. + # Either way: fall back to the compiler default. + return compiler.language + class ScratchAdmin(admin.ModelAdmin[Scratch]): raw_id_fields = ["owner", "parent", "family", "context_fk"] diff --git a/backend/coreapp/serializers.py b/backend/coreapp/serializers.py index 2a3b48874..478cfe089 100644 --- a/backend/coreapp/serializers.py +++ b/backend/coreapp/serializers.py @@ -3,19 +3,15 @@ from django.contrib.auth.models import User from html_json_forms.serializers import JSONFormSerializer from rest_framework import serializers -from rest_framework.exceptions import APIException from rest_framework.relations import PKOnlyObject, SlugRelatedField -from coreapp import platforms - -from . import compilers -from .libraries import Library +from .cromper_client import get_cromper_client from .models.best_fork import BestFork from .models.github import GitHubUser from .models.preset import Preset from .models.profile import Profile from .models.project import Project, ProjectMember -from .models.scratch import Context, Scratch +from .models.scratch import Context, Library, Scratch def serialize_profile(profile: Profile, num_scratches: bool = False) -> dict[str, Any]: @@ -111,19 +107,23 @@ def get_num_scratches(self, preset: Preset) -> int: return int(annotated_count) return Scratch.objects.filter(preset=preset).count() - def validate_platform(self, platform: str) -> str: + def validate_platform(self, platform_id: str) -> str: + cromper = get_cromper_client() + try: - platforms.from_id(platform) + platform = cromper.get_platform_by_id(platform_id) except Exception: - raise serializers.ValidationError(f"Unknown platform: {platform}") - return platform + raise serializers.ValidationError(f"Unknown platform: {platform_id}") - def validate_compiler(self, compiler: str) -> str: + return platform.id + + def validate_compiler(self, compiler_id: str) -> str: + cromper = get_cromper_client() try: - compilers.from_id(compiler) - except Exception: - raise serializers.ValidationError(f"Unknown compiler: {compiler}") - return compiler + compiler = cromper.get_compiler_by_id(compiler_id) + except ValueError: + raise serializers.ValidationError(f"Unknown compiler: {compiler_id}") + return compiler.id def validate(self, data: dict[str, Any]) -> dict[str, Any]: if self.instance is not None: @@ -143,13 +143,18 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: if not isinstance(compiler_id, str) or not isinstance(platform_id, str): raise serializers.ValidationError("Compiler and platform are required.") - compiler = compilers.from_id(compiler_id) - platform = platforms.from_id(platform_id) + cromper = get_cromper_client() + try: + compiler = cromper.get_compiler_by_id(compiler_id) + platform = cromper.get_platform_by_id(platform_id) + except Exception: + raise serializers.ValidationError("Unknown compiler or platform.") - if compiler.platform != platform: + if compiler.platform.id != platform.id: raise serializers.ValidationError( f"Compiler {compiler.id} is not compatible with platform {platform.id}" ) + return data @@ -178,14 +183,16 @@ class ScratchCreateSerializer(serializers.Serializer[None]): def validate_platform(self, platform: str) -> str: try: - platforms.from_id(platform) + cromper = get_cromper_client() + cromper.get_platform_by_id(platform) except Exception: raise serializers.ValidationError(f"Unknown platform: {platform}") return platform def validate_compiler(self, compiler: str) -> str: try: - compilers.from_id(compiler) + cromper = get_cromper_client() + cromper.get_compiler_by_id(compiler) except Exception: raise serializers.ValidationError(f"Unknown compiler: {compiler}") return compiler @@ -202,10 +209,12 @@ def validate_libraries( return libraries def validate(self, data: dict[str, Any]) -> dict[str, Any]: + cromper = get_cromper_client() + if "preset" in data: preset: Preset = data["preset"] # Preset dictates platform and compiler. - data["platform"] = platforms.from_id(preset.platform) + data["platform"] = cromper.get_platform_by_id(preset.platform) data["compiler"] = preset.compiler if "compiler_flags" not in data or not data["compiler_flags"]: @@ -223,8 +232,8 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: ) try: - compiler = compilers.from_id(data["compiler"]) - except APIException: + compiler = cromper.get_compiler_by_id(data["compiler"]) + except ValueError: raise serializers.ValidationError( f"Unknown compiler: {data['compiler']}" ) @@ -233,8 +242,8 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: data["platform"] = compiler.platform else: try: - platform = platforms.from_id(data["platform"]) - except APIException: + platform = cromper.get_platform_by_id(data["platform"]) + except ValueError: raise serializers.ValidationError( f"Unknown platform: {data['platform']}" ) @@ -263,7 +272,7 @@ class ScratchCompileSerializer(serializers.Serializer[None]): def validate_compiler(self, compiler: str) -> str: try: - compilers.from_id(compiler) + get_cromper_client().get_compiler_by_id(compiler) except Exception: raise serializers.ValidationError(f"Unknown compiler: {compiler}") return compiler @@ -274,9 +283,10 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: if scratch is None or compiler_id is None: return data - compiler = compilers.from_id(compiler_id) - platform = platforms.from_id(scratch.platform) - if compiler.platform != platform: + cromper = get_cromper_client() + compiler = cromper.get_compiler_by_id(compiler_id) + platform = cromper.get_platform_by_id(scratch.platform) + if compiler.platform.id != platform.id: raise serializers.ValidationError( f"Compiler {compiler.id} is not compatible with platform {platform.id}" ) @@ -289,7 +299,7 @@ class ScratchDecompileSerializer(serializers.Serializer[None]): def validate_compiler(self, compiler: str) -> str: try: - compilers.from_id(compiler) + get_cromper_client().get_compiler_by_id(compiler) except Exception: raise serializers.ValidationError(f"Unknown compiler: {compiler}") return compiler @@ -300,9 +310,10 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: if scratch is None or compiler_id is None: return data - compiler = compilers.from_id(compiler_id) - platform = platforms.from_id(scratch.platform) - if compiler.platform != platform: + cromper = get_cromper_client() + compiler = cromper.get_compiler_by_id(compiler_id) + platform = cromper.get_platform_by_id(scratch.platform) + if compiler.platform.id != platform.id: raise serializers.ValidationError( f"Compiler {compiler.id} is not compatible with platform {platform.id}" ) @@ -366,7 +377,7 @@ def update(self, instance: Scratch, validated_data: dict[str, Any]) -> Scratch: def validate_compiler(self, compiler: str) -> str: try: - compilers.from_id(compiler) + get_cromper_client().get_compiler_by_id(compiler) except Exception: raise serializers.ValidationError(f"Unknown compiler: {compiler}") return compiler @@ -376,26 +387,21 @@ def validate(self, data: dict[str, Any]) -> dict[str, Any]: if compiler_id is None: return data - compiler = compilers.from_id(compiler_id) + cromper = get_cromper_client() + compiler = cromper.get_compiler_by_id(compiler_id) platform_id = self.instance.platform if self.instance else data.get("platform") if platform_id is None: return data - platform = platforms.from_id(platform_id) - if compiler.platform != platform: + platform = cromper.get_platform_by_id(platform_id) + if compiler.platform.id != platform.id: raise serializers.ValidationError( f"Compiler {compiler.id} is not compatible with platform {platform.id}" ) return data def get_language(self, scratch: Scratch) -> str: - """ - Strategy for extracting a scratch's language: - - If the scratch's compiler has a LanguageFlagSet in its flags, attempt to match a language flag against that - - Otherwise, fallback to the compiler's default language - """ - compiler = compilers.from_id(scratch.compiler) - return compiler.get_language(scratch.compiler_flags).get_display_name() + return scratch.get_language().value class TerseScratchSerializer(ScratchSerializer): diff --git a/backend/coreapp/tests/common.py b/backend/coreapp/tests/common.py index f4580cb0a..c0ab0f299 100644 --- a/backend/coreapp/tests/common.py +++ b/backend/coreapp/tests/common.py @@ -1,20 +1,21 @@ from collections.abc import Callable from typing import Any -from unittest import skip, skipIf +from unittest import skipIf from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase -from coreapp import compilers, platforms -from coreapp.compilers import Compiler +from coreapp.compiler_utils import Compiler from coreapp.models.scratch import Scratch +from coreapp.tests import ( + mock_cromper_client as compilers, + mock_cromper_client as platforms, +) +from coreapp.tests.mock_cromper_client import patch_cromper def requiresCompiler(*compilers: Compiler) -> Callable[..., Any]: - for c in compilers: - if not c.available(): - return skip(f"Compiler {c.id} not available") return skipIf(False, "") @@ -22,6 +23,7 @@ class BaseTestCase(APITestCase): def setUp(self) -> None: super().setUp() self.claim_tokens: dict[str, str] = dict() # slug -> claim_token + self.enterContext(patch_cromper()) self.client.credentials(HTTP_USER_AGENT="Firefrogz 1.0") # Create a scratch and return it as a DB object diff --git a/backend/coreapp/tests/mock_cromper_client.py b/backend/coreapp/tests/mock_cromper_client.py new file mode 100644 index 000000000..47e1c744a --- /dev/null +++ b/backend/coreapp/tests/mock_cromper_client.py @@ -0,0 +1,169 @@ +from collections.abc import Callable, Iterator +from contextlib import ExitStack, contextmanager +from functools import wraps +from typing import Any +from unittest.mock import patch + +from coreapp.compiler_utils import Compiler, Language, Platform + +_DUMMY_PLATFORM = Platform( + id="dummy", + name="Dummy Platform", + description="A dummy platform for testing", + arch="mips", + compilers=["dummy", "dummy-longrunning"], + has_decompiler=True, +) +N64 = Platform( + id="n64", + name="Nintendo 64", + description="MIPS (big-endian)", + arch="mips", + compilers=["gcc2.8.1pm", "ido5.3", "ido7.1"], + has_decompiler=True, +) +PS1 = Platform( + id="ps1", + name="PlayStation", + description="MIPS (little-endian)", + arch="mipsel", + compilers=[], + has_decompiler=True, +) +PS2 = Platform( + id="ps2", + name="PlayStation 2", + description="MIPS (little-endian)", + arch="mipsee", + compilers=["ee-gcc2.9-991111"], + has_decompiler=True, +) +GC_WII = Platform( + id="gc_wii", + name="GameCube / Wii", + description="PowerPC", + arch="ppc", + compilers=["mwcc_242_81"], + has_decompiler=True, +) + +DUMMY = Compiler("dummy", _DUMMY_PLATFORM, "", "", Language.C) +DUMMY_LONGRUNNING = Compiler("dummy-longrunning", _DUMMY_PLATFORM, "", "", Language.C) +GCC281PM = Compiler("gcc2.8.1pm", N64, "", "", Language.C) +IDO53 = Compiler("ido5.3", N64, "", "", Language.C) +IDO71 = Compiler("ido7.1", N64, "", "", Language.C) +EE_GCC29_991111 = Compiler("ee-gcc2.9-991111", PS2, "", "", Language.C) +MWCC_242_81 = Compiler("mwcc_242_81", GC_WII, "", "", Language.C) + +_PLATFORMS = { + platform.id: platform for platform in (_DUMMY_PLATFORM, N64, PS1, PS2, GC_WII) +} +_COMPILERS = { + compiler.id: compiler + for compiler in ( + DUMMY, + DUMMY_LONGRUNNING, + GCC281PM, + IDO53, + IDO71, + EE_GCC29_991111, + MWCC_242_81, + ) +} +_PATCH_TARGETS = ( + "coreapp.cromper_client.get_cromper_client", + "coreapp.serializers.get_cromper_client", + "coreapp.views.scratch.get_cromper_client", +) + + +@contextmanager +def patch_cromper() -> Iterator["MockCromperClient"]: + mock_client = MockCromperClient() + with ExitStack() as stack: + for target in _PATCH_TARGETS: + stack.enter_context(patch(target, return_value=mock_client)) + yield mock_client + + +def mock_cromper(func: Callable[..., Any]) -> Callable[..., Any]: + """Decorator to mock cromper client in all locations where it's imported.""" + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + with patch_cromper(): + return func(*args, **kwargs) + + return wrapper + + +class MockCromperClient: + """Mock cromper client for testing.""" + + def get_compiler_by_id(self, compiler_id: str) -> Compiler: + try: + return _COMPILERS[compiler_id] + except KeyError: + raise ValueError(f"Unknown compiler: {compiler_id}") + + def get_platform_by_id(self, platform_id: str) -> Platform: + try: + return _PLATFORMS[platform_id] + except KeyError: + raise ValueError(f"Unknown platform: {platform_id}") + + def assemble_asm(self, platform_id: str, asm: Any) -> dict[str, Any]: + """Return mock assembly result.""" + # Create a simple mock ELF object + if asm.data.strip() == "": + return { + "hash": "empty_asm_hash", + "arch": "mips", + "elf_object": b"", + } + return { + "hash": "mock_hash_123", + "arch": "mips", + "elf_object": asm.data.encode(), + } + + def decompile( + self, + platform_id: str, + compiler_id: str, + asm: str, + default_source_code: str = "", + context: str = "", + ) -> str: + """Return mock decompiled code.""" + return "int func(void) {\n return 0;\n}\n" + + def compile_code( + self, + compiler_id: str, + compiler_flags: str, + code: str, + context: str, + function: str = "", + libraries: list[dict[str, str]] = [], + ) -> dict[str, Any]: + """Return mock compilation result.""" + return {"elf_object": code.encode(), "errors": ""} + + def diff( + self, + platform_id: str, + target_elf: bytes, + compiled_elf: bytes, + diff_label: str = "", + diff_flags: list[str] = [], + ) -> dict[str, Any]: + """Return mock diff result.""" + is_exact_match = b"li $v0,2" in target_elf and b"return 2" in compiled_elf + return { + "result": { + "current_score": 0 if is_exact_match else 200, + "max_score": 200, + }, + "errors": "", + } diff --git a/backend/coreapp/tests/test_best_fork.py b/backend/coreapp/tests/test_best_fork.py index 3fe00ad30..dedd0b7b1 100644 --- a/backend/coreapp/tests/test_best_fork.py +++ b/backend/coreapp/tests/test_best_fork.py @@ -1,11 +1,10 @@ from django.urls import reverse from rest_framework import status -from coreapp.compilers import IDO71 from coreapp.models.scratch import Scratch -from coreapp.platforms import N64 from coreapp.serializers import TerseScratchSerializer from coreapp.tests.common import BaseTestCase, requiresCompiler +from coreapp.tests.mock_cromper_client import IDO71, N64 from coreapp.views.scratch import update_scratch_score from coreapp.wrapper_result import DiffResult diff --git a/backend/coreapp/tests/test_compilation.py b/backend/coreapp/tests/test_compilation.py deleted file mode 100644 index eedad8da5..000000000 --- a/backend/coreapp/tests/test_compilation.py +++ /dev/null @@ -1,321 +0,0 @@ -from collections.abc import Callable -from typing import Any -from unittest.mock import patch - -from django.urls import reverse -from parameterized import param, parameterized -from rest_framework import status - -from coreapp import compilers -from coreapp.compiler_wrapper import CompilerWrapper -from coreapp.compilers import ( - GCC281PM, - GHS5322, - IDO53, - IDO71, - MSVC40, - MWCC_247_92, - PBX_GCC3, - WATCOM_105_C, - Compiler, - DummyCompiler, -) -from coreapp.diff_wrapper import DiffWrapper -from coreapp.flags import Language -from coreapp.models.scratch import Asm, Assembly -from coreapp.platforms import N64 -from coreapp.tests.common import BaseTestCase, requiresCompiler - - -def all_compilers_name_func( - testcase_func: Callable[[Any], None], param_num: int, param: param -) -> str: - compiler: Compiler = param.args[0] - return f"{testcase_func.__name__}_{parameterized.to_safe_name(compiler.platform.id + '_' + compiler.id)}" - - -class CompilationTests(BaseTestCase): - @requiresCompiler(GCC281PM) - def test_simple_compilation(self) -> None: - """ - Ensure that we can run a simple compilation via the api - """ - scratch_dict = { - "compiler": GCC281PM.id, - "platform": N64.id, - "context": "", - "target_asm": "glabel func_80929D04\njr $ra\nnop", - } - - # Test that we can create a scratch - scratch = self.create_scratch(scratch_dict) - - compile_dict = { - "slug": scratch.slug, - "compiler": GCC281PM.id, - "compiler_flags": "-mips2 -O2", - "source_code": "int add(int a, int b){\nreturn a + b;\n}\n", - } - - # Test that we can compile a scratch - response = self.client.post( - reverse("scratch-compile", kwargs={"pk": scratch.slug}), compile_dict - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - @requiresCompiler(GCC281PM) - def test_giant_compilation(self) -> None: - """ - Ensure that we can compile a giant file - """ - scratch_dict = { - "compiler": GCC281PM.id, - "platform": N64.id, - "context": "", - "target_asm": "glabel func_80929D04\njr $ra\nnop", - } - - # Test that we can create a scratch - scratch = self.create_scratch(scratch_dict) - - context = "" - for i in range(25000): - context += "extern int test_symbol_to_be_used_in_a_test;\n" - - compile_dict = { - "slug": scratch.slug, - "compiler": GCC281PM.id, - "compiler_flags": "-mips2 -O2", - "source_code": "int add(int a, int b){\nreturn a + b;\n}\n", - "context": context, - } - - # Test that we can compile a scratch - response = self.client.post( - reverse("scratch-compile", kwargs={"pk": scratch.slug}), compile_dict - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - self.assertTrue(response.json()["success"]) - - @requiresCompiler(IDO53) - def test_ido_line_endings(self) -> None: - """ - Ensure that compilations with \\r\\n line endings succeed - """ - result = CompilerWrapper.compile_code( - IDO53, - "-mips2 -O2", - "int dog = 5;", - "extern char libvar1;\r\nextern char libvar2;\r\n", - ) - self.assertGreater( - len(result.elf_object), 0, "The compilation result should be non-null" - ) - - @requiresCompiler(IDO53) - def test_ido_kpic(self) -> None: - """ - Ensure that ido compilations including -KPIC produce different code - """ - result_non_shared = CompilerWrapper.compile_code( - IDO53, "-mips2 -O2", "int dog = 5;", "" - ) - result_kpic = CompilerWrapper.compile_code( - IDO53, "-mips2 -O2 -KPIC", "int dog = 5;", "" - ) - self.assertNotEqual( - result_non_shared.elf_object, - result_kpic.elf_object, - "The compilation result should be different", - ) - - @requiresCompiler(IDO71) - def test_fpr_reg_names_output(self) -> None: - """ - Ensure that we can view fpr reg names by passing the appropriate diff flag - """ - scratch_dict = { - "platform": N64.id, - "compiler": IDO71.id, - "diff_flags": ["-Mreg-names=32"], - "context": "", - "target_asm": """ -glabel test -lui $at, 0x3ff0 -mtc1 $at, $fv1f -mtc1 $zero, $fv1 -beqz $a0, .L00400194 -move $v0, $a0 -andi $a1, $a0, 3 -negu $a1, $a1 -beqz $a1, .L004000EC -addu $v1, $a1, $a0 -mtc1 $v0, $ft0 -nop -""", - } - scratch = self.create_scratch(scratch_dict) - - # Test that we can compile a scratch - response = self.client.post( - reverse("scratch-compile", kwargs={"pk": scratch.slug}) - ) - - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertTrue(response.json()["success"]) - # Confirm the output contains the expected fpr reg names - self.assertTrue("fv1f" in str(response.json())) - - response = self.client.post( - reverse("scratch-compile", kwargs={"pk": scratch.slug}), - {"diff_flags": "[]"}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertTrue(response.json()["success"]) - # Confirm the output does not contain the expected fpr reg names - self.assertFalse("fv1f" in str(response.json())) - - @requiresCompiler(PBX_GCC3) - def test_pbx_gcc3(self) -> None: - """ - Ensure that we can invoke the PowerPC GCC3 cross-compiler - """ - result = CompilerWrapper.compile_code( - PBX_GCC3, - "-std=c99 -fPIC -O0 -g3", - "int func(void) { float f = 5.0; return f; }", # test if floats are handled correctly - "extern char libvar1;\r\nextern char libvar2;\r\n", - ) - self.assertGreater( - len(result.elf_object), 0, "The compilation result should be non-null" - ) - - @requiresCompiler(MWCC_247_92) - def test_mwcc(self) -> None: - """ - Ensure that we can invoke mwcc - """ - result = CompilerWrapper.compile_code( - MWCC_247_92, - "-str reuse -inline on -fp off -O0", - "int func(void) { return 5; }", - "extern char libvar1;\r\nextern char libvar2;\r\n", - ) - self.assertGreater( - len(result.elf_object), 0, "The compilation result should be non-null" - ) - - @requiresCompiler(WATCOM_105_C) - def test_watcom_cc(self) -> None: - """ - Ensure that we can invoke watcom cc - """ - result = CompilerWrapper.compile_code( - WATCOM_105_C, - "", - "int func(void) { return 5; }", - "extern char libvar1;\r\nextern char libvar2;\r\n", - ) - self.assertGreater( - len(result.elf_object), 0, "The compilation result should be non-null" - ) - - def test_dummy_compiler(self) -> None: - """ - Ensure basic functionality works for the dummy compiler - """ - - result = CompilerWrapper.compile_code( - compilers.DUMMY, "", "sample text 123", "" - ) - self.assertGreater( - len(result.elf_object), 0, "The compilation result should be non-null" - ) - - def test_language_flags(self) -> None: - """ - Ensure compiler flags that switch source language are reflected in metadata. - """ - self.assertEqual(GHS5322.get_language(""), Language.C) - self.assertEqual(MSVC40.get_language(""), Language.C) - self.assertEqual(MWCC_247_92.get_language("-lang=c++"), Language.CXX) - self.assertEqual(GHS5322.get_language("--g++"), Language.CXX) - self.assertEqual(MSVC40.get_language("/TP"), Language.CXX) - - @requiresCompiler(IDO71) - def test_diff_can_score_target_only(self) -> None: - """ - Ensure diffs can be generated and scored even when compilation failed. - """ - target_asm = Asm( - hash="target-only-diff", - data=".text\nglabel func\njr $ra\nnop", - ) - with patch("coreapp.models.scratch.Assembly.save", autospec=True): - target_assembly = CompilerWrapper.assemble_asm(N64, target_asm) - - diff = DiffWrapper.diff( - target_assembly, - N64, - "func", - b"", - diff_flags=[], - ) - - diff_result: dict[str, Any] = diff.result # type: ignore - self.assertTrue(diff_result is not None and "rows" in diff_result) - self.assertGreater(len(diff_result["rows"]), 0) - self.assertEqual(200, diff_result.get("max_score")) - self.assertEqual(200, diff_result.get("current_score")) - self.assertEqual(None, diff.errors) - - @parameterized.expand( - input=[ - (c,) - for c in compilers.available_compilers() - if not isinstance(c, DummyCompiler) - ], - name_func=all_compilers_name_func, - skip_on_empty=True, - ) # type: ignore - def test_all_compilers(self, compiler: Compiler) -> None: - """ - Ensure that we can run a simple compilation/diff for all available compilers - """ - code = "int func(void) { return 5; }" - language = compiler.get_language() - if language in (Language.CXX, Language.OLD_CXX): - code = 'extern "C" int func(void) { return 5; }' - - if language == Language.PASCAL: - code = "function func(): integer; begin func := 5; end;" - - if language == Language.ASSEMBLY: - code = "nada" - - result = CompilerWrapper.compile_code( - compiler, - "", - code, - "", - "func", - ) - self.assertGreater( - len(result.elf_object), - 0, - "The compilation result should be non-null", - ) - - diff = DiffWrapper.diff( - Assembly(elf_object=result.elf_object), - compiler.platform, - "", - result.elf_object, - diff_flags=[], - ) - - diff_result: dict[str, Any] = diff.result # type: ignore - self.assertTrue(diff_result is not None and "rows" in diff_result) - self.assertGreater(len(diff_result["rows"]), 0) - self.assertEqual(None, diff.errors) diff --git a/backend/coreapp/tests/test_decompilation.py b/backend/coreapp/tests/test_decompilation.py deleted file mode 100644 index 64b72ee93..000000000 --- a/backend/coreapp/tests/test_decompilation.py +++ /dev/null @@ -1,145 +0,0 @@ -from django.test.testcases import TestCase -from django.urls import reverse -from rest_framework import status - -from coreapp.compilers import GCC281PM, IDO53, MWCC_247_92 -from coreapp.decompiler_wrapper import DECOMP_WITH_CONTEXT_FAILED_PREAMBLE -from coreapp.m2c_wrapper import M2CWrapper -from coreapp.platforms import N64 -from coreapp.tests.common import BaseTestCase, requiresCompiler - - -class DecompilationTests(BaseTestCase): - @requiresCompiler(GCC281PM) - def test_default_decompilation(self) -> None: - """ - Ensure that a scratch's initial decompilation makes sense - """ - scratch_dict = { - "compiler": GCC281PM.id, - "platform": N64.id, - "context": "", - "target_asm": "glabel return_2\njr $ra\nli $v0,2", - } - scratch = self.create_scratch(scratch_dict) - self.assertEqual( - scratch.source_code, "s32 return_2(void) {\n return 2;\n}\n" - ) - - @requiresCompiler(GCC281PM) - def test_decompile_endpoint(self) -> None: - """ - Ensure that the decompile endpoint works - """ - scratch_dict = { - "compiler": GCC281PM.id, - "platform": N64.id, - "context": "typedef int s32;", - "target_asm": "glabel return_2\njr $ra\nli $v0,2", - } - scratch = self.create_scratch(scratch_dict) - - response = self.client.post( - reverse("scratch-decompile", kwargs={"pk": scratch.slug}) - ) - self.assertEqual( - response.json()["decompilation"], "s32 return_2(void) {\n return 2;\n}\n" - ) - - # Provide context and see that the decompilation changes - response = self.client.post( - reverse("scratch-decompile", kwargs={"pk": scratch.slug}), - data={"context": "s32 return_2(void);"}, - ) - self.assertEqual( - response.json()["decompilation"], "s32 return_2(void) {\n return 2;\n}\n" - ) - - @requiresCompiler(GCC281PM) - def test_decompile_endpoint_with_broken_context(self) -> None: - """ - Ensure that the decompile endpoint works even if the context is broken - """ - scratch_dict = { - "compiler": GCC281PM.id, - "platform": N64.id, - "context": "typedeff jeff;", - "target_asm": "glabel return_2\njr $ra\nli $v0,2", - } - scratch = self.create_scratch(scratch_dict) - - response = self.client.post( - reverse("scratch-decompile", kwargs={"pk": scratch.slug}), - ) - self.assertEqual( - response.json()["decompilation"], - "/*\nDecompilation failure:\n\nSyntax error when parsing C context.\nbefore: jeff at line 1, column 10\n\ntypedeff jeff;\n*/\n\n" - + DECOMP_WITH_CONTEXT_FAILED_PREAMBLE - + "\ns32 return_2(void) {\n return 2;\n}\n", - ) - - @requiresCompiler(GCC281PM) - def test_decompile_endpoint_rejects_invalid_compiler(self) -> None: - scratch = self.create_scratch( - { - "compiler": GCC281PM.id, - "platform": N64.id, - "context": "", - "target_asm": "glabel return_2\njr $ra\nli $v0,2", - } - ) - - response = self.client.post( - reverse("scratch-decompile", kwargs={"pk": scratch.slug}), - {"compiler": "if(now()=sysdate(),sleep(15),0)"}, - ) - - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - - -class M2CTests(TestCase): - """ - Ensure that pointers are next to types (left style) - """ - - def test_left_pointer_style(self) -> None: - c_code = M2CWrapper.decompile( - """ - glabel func - li $t6,1 - jr $ra - sw $t6,0($a0) - """, - "", - "n64", - IDO53, - ) - - self.assertTrue( - "s32*" in c_code, - "The decompiled c code should have a left-style pointer, was instead:\n" - + c_code, - ) - - """ - Ensure that we can decompile ppc code - """ - - def test_ppc(self) -> None: - c_code = M2CWrapper.decompile( - """ - .global func_800B43A8 - func_800B43A8: - xor r0, r3, r3 - subf r3, r4, r0 - blr - """, - "", - "gc_wii", - MWCC_247_92, - ) - - self.assertEqual( - "s32 func_800B43A8(s32 arg0, s32 arg1) {\n return (arg0 ^ arg0) - arg1;\n}\n", - c_code, - ) diff --git a/backend/coreapp/tests/test_platform.py b/backend/coreapp/tests/test_platform.py deleted file mode 100644 index 266e666ca..000000000 --- a/backend/coreapp/tests/test_platform.py +++ /dev/null @@ -1,136 +0,0 @@ -import unittest -from unittest.mock import patch - -from coreapp.compiler_wrapper import CompilerWrapper -from coreapp.diff_wrapper import DiffWrapper -from coreapp.models.scratch import Asm, Assembly -from coreapp.platforms import ( - ANDROID_X86, - DREAMCAST, - GBA, - GC_WII, - IRIX, - MACOSX, - MSDOS, - N3DS, - N64, - NDS_ARM9, - PS1, - PS2, - PSP, - SATURN, - SWITCH, - WIIU, - WIN32, - XBOX360, - Platform, - _platforms, -) - -MIPS_ASM = """\ -jr $ra -li $v0,1 -""" - -PPC_ASM = """\ -blr -li 0,1 -""" - -X86_ASM = """\ -mov $1, %eax -ret -""" - -MSDOS_ASM = """\ -.386 -.model flat, stdcall -.code - -_return_one PROC - mov eax, 1 - ret -_return_one ENDP - -END -""" - -ARM_ASM = """\ -mov r0, #0 -bx lr -""" - -AARCH64_ASM = """\ -mov x0, #0 -ret -""" - -SH2_ASM = """\ -rts -nop -""" - - -def assemble_asm(platform: Platform, asm_code: str) -> Assembly: - asm = Asm(hash="fakehash", data=asm_code) - with patch("coreapp.models.scratch.Assembly.save", autospec=True): - asm_obj = CompilerWrapper.assemble_asm(platform, asm) - return asm_obj - - -def disassemble_obj(platform: Platform, asm_obj_bytes: bytes) -> str: - objdump = DiffWrapper.run_objdump(asm_obj_bytes, platform, (), "", ()) - return objdump - - -class PlatformAssembleRoundtripTests(unittest.TestCase): - PLATFORM_ASM_MAP = { - # MIPS - IRIX: MIPS_ASM, - N64: MIPS_ASM, - PS1: MIPS_ASM, - PS2: MIPS_ASM, - PSP: MIPS_ASM, - # PowerPC - MACOSX: PPC_ASM, - GC_WII: PPC_ASM, - WIIU: PPC_ASM, - XBOX360: PPC_ASM, - # x86 family - MSDOS: MSDOS_ASM, - WIN32: X86_ASM, - ANDROID_X86: X86_ASM, - # ARM / AArch64 - GBA: ARM_ASM, - NDS_ARM9: ARM_ASM, - N3DS: ARM_ASM, - SWITCH: AARCH64_ASM, - # SH2 - SATURN: SH2_ASM, - DREAMCAST: SH2_ASM, - } - - @classmethod - def setUpClass(cls) -> None: - missing = [ - p.id - for p in _platforms.values() - if p.id != "dummy" and p not in cls.PLATFORM_ASM_MAP - ] - if missing: - raise AssertionError(f"Missing ASM definitions for: {missing}") - - def test_all_platforms_roundtrip(self) -> None: - for platform, asm_code in self.PLATFORM_ASM_MAP.items(): - with self.subTest(platform=platform.id): - try: - asm_obj = assemble_asm(platform, asm_code) - dump = disassemble_obj(platform, asm_obj.elf_object) - self.assertIsNotNone( - dump, f"Disassembly for {platform.id} was None" - ) - self.assertTrue( - dump.strip(), f"Empty disassembly for {platform.id}" - ) - except Exception as e: - self.fail(f"Failed for {platform.id}: {e}") diff --git a/backend/coreapp/tests/test_preset.py b/backend/coreapp/tests/test_preset.py index 349170850..b7b50cab2 100644 --- a/backend/coreapp/tests/test_preset.py +++ b/backend/coreapp/tests/test_preset.py @@ -4,11 +4,10 @@ from django.urls import reverse from rest_framework import status -from coreapp.compilers import GCC281PM, IDO53 from coreapp.models.preset import Preset from coreapp.models.profile import Profile -from coreapp.platforms import DUMMY, N64, PS1 from coreapp.tests.common import BaseTestCase, requiresCompiler +from coreapp.tests.mock_cromper_client import DUMMY, GCC281PM, IDO53, N64, PS1 SAMPLE_PRESET_DICT = { "name": "Kitty's Adventure", diff --git a/backend/coreapp/tests/test_request.py b/backend/coreapp/tests/test_request.py index cda3be32e..cd5ec6ec1 100644 --- a/backend/coreapp/tests/test_request.py +++ b/backend/coreapp/tests/test_request.py @@ -1,17 +1,15 @@ -import subprocess - from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase -from coreapp import compilers, platforms -from coreapp.error import AssemblyError, ObjdumpError, custom_exception_handler from coreapp.models.profile import Profile -from coreapp.sandbox import Sandbox -from coreapp.tests.common import BaseTestCase, requiresCompiler +from coreapp.tests import ( + mock_cromper_client as compilers, + mock_cromper_client as platforms, +) +from coreapp.tests.common import BaseTestCase -class RequestTests(APITestCase): +class RequestTests(BaseTestCase): def test_health_check_is_stateless(self) -> None: response = self.client.get(reverse("healthz"), HTTP_USER_AGENT="browser") @@ -71,71 +69,3 @@ def test_node_fetch_request(self) -> None: self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(Profile.objects.count(), 0) - - def test_assembly_errors_are_reported_as_assembler_errors(self) -> None: - response = custom_exception_handler(AssemblyError("bad asm"), {}) - - self.assertIsNotNone(response) - assert response is not None - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertEqual(response.data["code"], "Assembler") - self.assertEqual(response.data["kind"], "AssemblyError") - self.assertEqual(response.data["detail"], "Assembler error: bad asm") - - -class TimeoutTests(BaseTestCase): - @requiresCompiler(compilers.DUMMY_LONGRUNNING) - def test_compiler_timeout(self) -> None: - # Test that a hanging compilation will fail with a timeout error - with self.settings(COMPILATION_TIMEOUT_SECONDS=3): - scratch_dict = { - "compiler": compilers.DUMMY_LONGRUNNING.id, - "platform": platforms.DUMMY.id, - "context": "", - "target_asm": "asm(AAAAAAAA)", - } - - scratch = self.create_scratch(scratch_dict) - - compile_dict = { - "slug": scratch.slug, - "compiler": compilers.DUMMY_LONGRUNNING.id, - "compiler_flags": "", - "source_code": "source(AAAAAAAA)", - } - - response = self.client.post( - reverse("scratch-compile", kwargs={"pk": scratch.slug}), compile_dict - ) - - self.assertFalse(response.json()["success"]) - self.assertIn("timeout expired", response.json()["compiler_output"].lower()) - - # if we don't have DUMMY_LONGRUNNING, it means we'll be unable to use sandbox.run_subprocess - @requiresCompiler(compilers.DUMMY_LONGRUNNING) - def test_zero_timeout(self) -> None: - # Tests that passing a timeout of zero to sandbox.run_subprocess will equate - # to disabling the timeout entirely - expected_output = "AAAAAAAA" - - with Sandbox() as sandbox: - sandboxed_proc = sandbox.run_subprocess( - f"sleep 3 && echo {expected_output}", timeout=0, shell=True - ) - - self.assertEqual(sandboxed_proc.returncode, 0) - self.assertIn(expected_output, sandboxed_proc.stdout) - - def test_sandbox_subprocess_error_preserves_output(self) -> None: - missing_command = "definitely-not-a-real-command" - - with self.settings(DEBUG=False): - with Sandbox() as sandbox: - with self.assertRaises(subprocess.CalledProcessError) as cm: - sandbox.run_subprocess([missing_command], shell=True) - - error = ObjdumpError.from_process_error(cm.exception) - - self.assertIsInstance(error, ObjdumpError) - self.assertIn(missing_command, str(error)) - self.assertIn("command not found", str(error)) diff --git a/backend/coreapp/tests/test_scratch.py b/backend/coreapp/tests/test_scratch.py index 5c6041213..fba0c6e32 100644 --- a/backend/coreapp/tests/test_scratch.py +++ b/backend/coreapp/tests/test_scratch.py @@ -11,12 +11,27 @@ from django.urls import reverse from rest_framework import status -from coreapp import compilers, platforms -from coreapp.compilers import EE_GCC29_991111, GCC281PM, IDO53, IDO71, MWCC_242_81 -from coreapp.libraries import Library -from coreapp.models.scratch import Assembly, Context, LibrariesField, Scratch -from coreapp.platforms import GC_WII, N64 +from coreapp.models.scratch import ( + Assembly, + Context, + LibrariesField, + Library, + Scratch, +) +from coreapp.tests import ( + mock_cromper_client as compilers, + mock_cromper_client as platforms, +) from coreapp.tests.common import BaseTestCase, requiresCompiler +from coreapp.tests.mock_cromper_client import ( + EE_GCC29_991111, + GC_WII, + GCC281PM, + IDO53, + IDO71, + MWCC_242_81, + N64, +) from coreapp.views.scratch import compile_scratch_update_score diff --git a/backend/coreapp/tests/test_user.py b/backend/coreapp/tests/test_user.py index eba1860cd..20c652889 100644 --- a/backend/coreapp/tests/test_user.py +++ b/backend/coreapp/tests/test_user.py @@ -3,9 +3,12 @@ from django.urls import reverse from rest_framework import status -from coreapp import compilers, platforms from coreapp.models.github import GitHubUser from coreapp.models.profile import Profile +from coreapp.tests import ( + mock_cromper_client as compilers, + mock_cromper_client as platforms, +) from coreapp.tests.common import BaseTestCase GITHUB_USER = { diff --git a/backend/coreapp/urls.py b/backend/coreapp/urls.py index dd81d34ab..18825f83d 100644 --- a/backend/coreapp/urls.py +++ b/backend/coreapp/urls.py @@ -2,10 +2,7 @@ from rest_framework.routers import DefaultRouter from coreapp.views import ( - compiler, health, - library, - platform, preset, project, scratch, @@ -22,29 +19,11 @@ urlpatterns = [ *router.urls, - path("compiler", compiler.CompilerDetail.as_view(), name="compiler"), path("healthz", health.HealthCheck.as_view(), name="healthz"), - path( - "compiler//", - compiler.SingleCompilerDetail.as_view(), - name="available-compiler", - ), - path( - "compiler/", - compiler.SingleCompilerDetail.as_view(), - name="available-compilers", - ), - path("library", library.LibraryDetail.as_view(), name="library"), - path("platform", platform.PlatformDetail.as_view(), name="platform"), - path( - "platform/", - platform.single_platform, - name="platform-detail", - ), + path("stats", stats.StatsDetail.as_view(), name="stats"), path( "scratch-count", scratch_count.ScratchCountView.as_view(), name="scratch-count" ), - path("stats", stats.StatsDetail.as_view(), name="stats"), path("user", user.CurrentUser.as_view(), name="current-user"), path( "user/scratches", @@ -68,7 +47,4 @@ name="user-scratch-stats", ), path("search", search.SearchViewSet.as_view(), name="search"), - # TODO: remove (decomp-permuter still uses /compilers) - path("compilers", compiler.CompilerDetail.as_view(), name="compilers"), - path("libraries", library.LibraryDetail.as_view(), name="libraries"), ] diff --git a/backend/coreapp/util.py b/backend/coreapp/util.py deleted file mode 100644 index 1b7843251..000000000 --- a/backend/coreapp/util.py +++ /dev/null @@ -1,12 +0,0 @@ -import hashlib -import logging -import time - -logger = logging.getLogger(__name__) - -_startup_time = int(time.time()) -logger.info("Startup time: %s", _startup_time) - - -def gen_hash(key: tuple[str, ...]) -> str: - return hashlib.sha256(str(key + (_startup_time,)).encode("utf-8")).hexdigest() diff --git a/backend/coreapp/views/compiler.py b/backend/coreapp/views/compiler.py deleted file mode 100644 index dafd60b87..000000000 --- a/backend/coreapp/views/compiler.py +++ /dev/null @@ -1,93 +0,0 @@ -import typing -from datetime import datetime - -from django.utils.decorators import method_decorator -from django.utils.timezone import now -from rest_framework.exceptions import NotFound -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from coreapp import compilers -from coreapp.models.preset import Preset - -from ..decorators.cache import globally_cacheable -from ..decorators.django import condition - -boot_time = now() - - -def endpoint_updated(request: Request, **_: typing.Any) -> datetime: - return max(Preset.most_recent_updated(request), boot_time) - - -@method_decorator( - globally_cacheable(max_age=300, stale_while_revalidate=30), name="dispatch" -) -class SingleCompilerDetail(APIView): - @condition(last_modified_func=lambda r, **_: boot_time) - def get( - self, - request: Request, - platform: str | None = "", - compiler: str | None = "", - ) -> Response: - filtered = [ - c for c in compilers.available_compilers() if c.platform.id == platform - ] - if len(filtered) == 0: - raise NotFound(detail="No compilers found for specified platform") - - if compiler: - filtered = [c for c in filtered if c.id == compiler] - if len(filtered) == 0: - raise NotFound(detail="Compiler not found") - - return Response( - { - c.id: { - "platform": c.platform.id, - "flags": [f.to_json() for f in c.flags], - "diff_flags": [f.to_json() for f in c.platform.diff_flags], - } - for c in filtered - } - ) - - -@method_decorator( - globally_cacheable(max_age=300, stale_while_revalidate=30), name="dispatch" -) -class CompilerDetail(APIView): - @staticmethod - def compilers_json() -> dict[str, dict[str, object]]: - return { - c.id: { - "platform": c.platform.id, - "flags": [f.to_json() for f in c.flags], - "diff_flags": [f.to_json() for f in c.platform.diff_flags], - } - for c in compilers.available_compilers() - } - - @staticmethod - def platforms_json() -> dict[str, dict[str, object]]: - ret: dict[str, dict[str, object]] = {} - - for platform in compilers.available_platforms(): - ret[platform.id] = platform.to_json() - - return ret - - @condition(last_modified_func=endpoint_updated) - def head(self, request: Request) -> Response: - return Response() - - @condition(last_modified_func=endpoint_updated) - def get(self, request: Request) -> Response: - return Response( - { - "compilers": CompilerDetail.compilers_json(), - "platforms": CompilerDetail.platforms_json(), - } - ) diff --git a/backend/coreapp/views/library.py b/backend/coreapp/views/library.py deleted file mode 100644 index a450b7bdb..000000000 --- a/backend/coreapp/views/library.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.utils.decorators import method_decorator -from django.utils.timezone import now -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from coreapp import libraries - -from ..decorators.cache import globally_cacheable -from ..decorators.django import condition - -boot_time = now() - - -@method_decorator( - globally_cacheable(max_age=300, stale_while_revalidate=30), name="dispatch" -) -class LibraryDetail(APIView): - @staticmethod - def libraries_json(platform: str = "") -> list[dict[str, object]]: - return [ - { - "name": lib.name, - "supported_versions": lib.supported_versions, - "platform": lib.platform, - } - for lib in libraries.available_libraries() - if platform == "" or lib.platform == platform - ] - - @condition(last_modified_func=lambda request: boot_time) - def head(self, request: Request) -> Response: - return Response() - - @condition(last_modified_func=lambda request: boot_time) - def get(self, request: Request) -> Response: - platform = request.query_params.get("platform", "") - return Response( - { - "libraries": LibraryDetail.libraries_json(platform=platform), - } - ) diff --git a/backend/coreapp/views/platform.py b/backend/coreapp/views/platform.py deleted file mode 100644 index f056f3777..000000000 --- a/backend/coreapp/views/platform.py +++ /dev/null @@ -1,53 +0,0 @@ -from datetime import datetime - -from django.utils.decorators import method_decorator -from django.utils.timezone import now -from rest_framework.decorators import api_view -from rest_framework.request import Request -from rest_framework.response import Response -from rest_framework.views import APIView - -from coreapp import compilers -from coreapp.models.preset import Preset -from coreapp.views.compiler import CompilerDetail - -from ..decorators.cache import globally_cacheable -from ..decorators.django import condition - -boot_time = now() - - -def endpoint_updated(request: Request) -> datetime: - return max(Preset.most_recent_updated(request), boot_time) - - -@method_decorator( - globally_cacheable(max_age=300, stale_while_revalidate=30), name="dispatch" -) -class PlatformDetail(APIView): - @condition(last_modified_func=endpoint_updated) - def head(self, request: Request) -> Response: - return Response() - - @condition(last_modified_func=endpoint_updated) - def get(self, request: Request) -> Response: - return Response(CompilerDetail.platforms_json()) - - -@api_view(["GET"]) -@globally_cacheable(max_age=300, stale_while_revalidate=30) -def single_platform(request: Request, id: str) -> Response: - """ - Gets a platform's basic details including available compilers - """ - platforms = compilers.available_platforms() - - for platform in platforms: - if platform.id == id: - return Response( - platform.to_json( - include_compilers=True, - ) - ) - - return Response(status=404) diff --git a/backend/coreapp/views/scratch.py b/backend/coreapp/views/scratch.py index 1aafec33b..12c682f5e 100644 --- a/backend/coreapp/views/scratch.py +++ b/backend/coreapp/views/scratch.py @@ -21,23 +21,17 @@ from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet -from coreapp import compilers, platforms - -from ..compiler_wrapper import CompilerWrapper -from ..decompiler_wrapper import DecompilerWrapper +from ..compiler_utils import filter_compiler_flags +from ..cromper_client import CromperError, get_cromper_client from ..decorators.cache import globally_cacheable from ..decorators.django import condition -from ..diff_wrapper import DiffWrapper -from ..error import CompilationError, DiffError from ..filters.scratch import ScratchFilter from ..filters.search import NonEmptySearchFilter -from ..libraries import Library from ..middleware import Request from ..models.best_fork import update_best_forks_for_scratch from ..models.preset import Preset -from ..models.scratch import Asm, Assembly, Scratch +from ..models.scratch import Asm, Assembly, Library, Scratch from ..pagination import SafeCursorPagination -from ..platforms import Platform from ..serializers import ( ClaimableScratchSerializer, ScratchCompileSerializer, @@ -71,7 +65,7 @@ def get_db_asm(request_asm: str) -> Asm: MAX_FILE_SIZE = 1000 * 1024 -def cache_object(platform: Platform, file: File[Any]) -> Assembly: +def cache_object(platform_arch: str, file: File[Any]) -> Assembly: # Validate file size if file.size > MAX_FILE_SIZE: raise serializers.ValidationError( @@ -89,7 +83,7 @@ def cache_object(platform: Platform, file: File[Any]) -> Assembly: assembly, _ = Assembly.objects.get_or_create( hash=hashlib.sha256(obj_bytes).hexdigest(), defaults={ - "arch": platform.arch, + "arch": platform_arch, "elf_object": obj_bytes, }, ) @@ -98,20 +92,25 @@ def cache_object(platform: Platform, file: File[Any]) -> Assembly: def compile_scratch(scratch: Scratch, context: str | None = None) -> CompilationResult: try: - if context is None: - scratch_context = scratch.context_fk.text if scratch.context_fk else "" - else: - scratch_context = context - - return CompilerWrapper.compile_code( - compilers.from_id(scratch.compiler), - scratch.compiler_flags, - scratch.source_code, - scratch_context, - scratch.diff_label, - tuple(scratch.libraries), + scratch_context = ( + scratch.context_fk.text + if context is None and scratch.context_fk + else context + ) or "" + libraries = [ + lib.to_json() if isinstance(lib, Library) else lib + for lib in scratch.libraries + ] + cromper_client = get_cromper_client() + result = cromper_client.compile_code( + compiler_id=scratch.compiler, + compiler_flags=scratch.compiler_flags, + code=scratch.source_code, + context=scratch_context, + libraries=libraries, ) - except (CompilationError, APIException) as e: + return CompilationResult(result["elf_object"], result["errors"]) + except (CromperError, APIException) as e: return CompilationResult(b"", str(e)) @@ -120,14 +119,16 @@ def diff_compilation( compilation: CompilationResult, ) -> DiffResult: try: - return DiffWrapper.diff( - scratch.target_assembly, - platforms.from_id(scratch.platform), - scratch.diff_label, - bytes(compilation.elf_object), + cromper_client = get_cromper_client() + result = cromper_client.diff( + platform_id=scratch.platform, + target_elf=scratch.target_assembly.elf_object, + compiled_elf=compilation.elf_object, + diff_label=scratch.diff_label, diff_flags=scratch.diff_flags, ) - except DiffError as e: + return DiffResult(result["result"], result["errors"]) + except CromperError as e: return DiffResult(None, str(e)) @@ -217,11 +218,13 @@ def create_scratch( create_ser.is_valid(raise_exception=True) data = create_ser.validated_data - platform: Platform | None = data.get("platform") - compiler = compilers.from_id(data["compiler"]) + cromper_client = get_cromper_client() + try: + compiler = cromper_client.get_compiler_by_id(data["compiler"]) + except ValueError as e: + raise APIException(str(e)) - if not platform: - platform = compiler.platform + platform = data.get("platform", compiler.platform) target_asm: str = data.get("target_asm", "") target_obj: File[Any] | None = data.get("target_obj") @@ -230,20 +233,34 @@ def create_scratch( if target_obj: asm = None - assembly = cache_object(platform, target_obj) + assembly = cache_object(platform.arch, target_obj) else: asm = get_db_asm(target_asm) - assembly = CompilerWrapper.assemble_asm(platform, asm) + asm_result = cromper_client.assemble_asm(platform.id, asm) + + # Create Assembly object from cromper response + assembly, _ = Assembly.objects.get_or_create( + hash=asm_result["hash"], + defaults={ + "arch": asm_result["arch"], + "elf_object": asm_result["elf_object"], + "source_asm": asm, + }, + ) source_code = data.get("source_code") if asm and not source_code: default_source_code = f"void {diff_label or 'func'}(void) {{\n // ...\n}}\n" - source_code = DecompilerWrapper.decompile( - default_source_code, platform, asm.data, context, compiler + source_code = cromper_client.decompile( + platform_id=platform.id, + compiler_id=compiler.id, + asm=asm.data, + default_source_code=default_source_code, + context=context, ) compiler_flags = data.get("compiler_flags", "") - compiler_flags = CompilerWrapper.filter_compiler_flags(compiler_flags) + compiler_flags = filter_compiler_flags(compiler_flags) diff_flags = data.get("diff_flags", []) @@ -463,16 +480,15 @@ def decompile(self, request: Request, pk: str) -> Response: context = partial.get( "context", scratch.context_fk.text if scratch.context_fk else "" ) - compiler = compilers.from_id(partial.get("compiler", scratch.compiler)) - - platform = platforms.from_id(scratch.platform) - - decompilation = DecompilerWrapper.decompile( - "", - platform, - scratch.target_assembly.source_asm.data, - context, - compiler, + compiler_id = partial.get("compiler", scratch.compiler) + + cromper_client = get_cromper_client() + decompilation = cromper_client.decompile( + platform_id=scratch.platform, + compiler_id=compiler_id, + asm=scratch.target_assembly.source_asm.data, + default_source_code="", + context=context, ) return Response({"decompilation": decompilation}) @@ -523,7 +539,7 @@ def fork(self, request: Request, pk: str) -> Response: ser = ScratchSerializer(data=fork_data, context={"request": request}) ser.is_valid(raise_exception=True) - libraries = [Library(**lib) for lib in ser.validated_data["libraries"]] + libraries = ser.validated_data["libraries"] new_scratch = ser.save( parent=parent, owner=request.profile, @@ -557,9 +573,7 @@ def export(self, request: Request, pk: str) -> HttpResponse: zip_f.writestr("target.s", scratch.target_assembly.source_asm.data) zip_f.writestr("target.o", scratch.target_assembly.elf_object) - compiler = compilers.from_id(scratch.compiler) - language = compiler.get_language(scratch.compiler_flags) - src_ext = language.get_file_extension() + src_ext = scratch.get_language().get_file_extension() zip_f.writestr(f"code.{src_ext}", scratch.source_code) if scratch.context_fk and scratch.context_fk.text: zip_f.writestr(f"ctx.{src_ext}", scratch.context_fk.text) diff --git a/backend/housekeeping.py b/backend/db_housekeeping.py similarity index 100% rename from backend/housekeeping.py rename to backend/db_housekeeping.py diff --git a/backend/decompme/settings.py b/backend/decompme/settings.py index 5f2b85723..9bb5c5b16 100644 --- a/backend/decompme/settings.py +++ b/backend/decompme/settings.py @@ -15,10 +15,7 @@ env = environ.Env( DEBUG=(bool, False), DJANGO_LOG_LEVEL=(str, "INFO"), - DUMMY_COMPILER=(bool, False), ALLOWED_HOSTS=(list, []), - SANDBOX_NSJAIL_BIN_PATH=(str, "/bin/nsjail"), - SANDBOX_DISABLE_PROC=(bool, False), SECURE_SSL_REDIRECT=(bool, False), SECURE_PROXY_SSL_HEADER=(bool, False), SECURE_HSTS_SECONDS=(int, 0), @@ -29,18 +26,9 @@ MEDIA_URL=(str, "/media/"), MEDIA_ROOT=(str, BASE_DIR / "media"), LOCAL_FILE_DIR=(str, BASE_DIR / "local_files"), - USE_SANDBOX_JAIL=(bool, False), SESSION_COOKIE_SECURE=(bool, True), GITHUB_CLIENT_ID=(str, ""), GITHUB_CLIENT_SECRET=(str, ""), - COMPILER_BASE_PATH=(str, BASE_DIR / "compilers"), - LIBRARY_BASE_PATH=(str, BASE_DIR / "libraries"), - COMPILATION_CACHE_SIZE=(int, 100), - OBJDUMP_CACHE_SIZE=(int, 100), - COMPILATION_TIMEOUT_SECONDS=(int, 10), - ASSEMBLY_TIMEOUT_SECONDS=(int, 3), - OBJDUMP_TIMEOUT_SECONDS=(int, 3), - TIMEOUT_SCALE_FACTOR=(int, 1), SENTRY_DSN=(str, ""), SENTRY_SAMPLE_RATE=(float, 0.0), SENTRY_TIMEOUT=(int, 3), @@ -49,6 +37,7 @@ SESSION_TIMEOUT_REDIRECT=(str, "/"), CONN_MAX_AGE=(int, 0), # default: a new connection for each request CONN_HEALTH_CHECKS=(bool, False), + CROMPER_URL=(str, "http://localhost:8888"), # cromper service URL ) for stem in [".env.local", ".env"]: @@ -60,9 +49,9 @@ SECRET_KEY = env("SECRET_KEY") DEBUG = env("DEBUG") DJANGO_LOG_LEVEL = env("DJANGO_LOG_LEVEL") -DUMMY_COMPILER = env("DUMMY_COMPILER") ALLOWED_HOSTS = env("ALLOWED_HOSTS") LOCAL_FILE_DIR = env("LOCAL_FILE_DIR") +CROMPER_URL = env("CROMPER_URL") # Application definition @@ -216,28 +205,9 @@ else: SESSION_COOKIE_SAMESITE = "Lax" -COMPILER_BASE_PATH = Path(env("COMPILER_BASE_PATH")) -LIBRARY_BASE_PATH = Path(env("LIBRARY_BASE_PATH")) - -USE_SANDBOX_JAIL = env("USE_SANDBOX_JAIL") -SANDBOX_NSJAIL_BIN_PATH = Path(env("SANDBOX_NSJAIL_BIN_PATH")) -SANDBOX_CHROOT_PATH = BASE_DIR.parent / "sandbox" / "root" -SANDBOX_TMP_PATH = BASE_DIR.parent / "sandbox" / "tmp" -SANDBOX_DISABLE_PROC = env("SANDBOX_DISABLE_PROC") - GITHUB_CLIENT_ID = env("GITHUB_CLIENT_ID", str) GITHUB_CLIENT_SECRET = env("GITHUB_CLIENT_SECRET", str) -COMPILATION_CACHE_SIZE = env("COMPILATION_CACHE_SIZE", int) -OBJDUMP_CACHE_SIZE = env("OBJDUMP_CACHE_SIZE", int) - -TIMEOUT_SCALE_FACTOR = env("TIMEOUT_SCALE_FACTOR", int) -COMPILATION_TIMEOUT_SECONDS = ( - env("COMPILATION_TIMEOUT_SECONDS", int) * TIMEOUT_SCALE_FACTOR -) -ASSEMBLY_TIMEOUT_SECONDS = env("ASSEMBLY_TIMEOUT_SECONDS", int) * TIMEOUT_SCALE_FACTOR -OBJDUMP_TIMEOUT_SECONDS = env("OBJDUMP_TIMEOUT_SECONDS", int) * TIMEOUT_SCALE_FACTOR - SENTRY_DSN = env("SENTRY_DSN", str) SENTRY_SAMPLE_RATE = env("SENTRY_SAMPLE_RATE", float) SENTRY_TIMEOUT = env("SENTRY_TIMEOUT", int) diff --git a/backend/docker.dev.env b/backend/docker.dev.env index cacbc1e0d..e46baaebe 100644 --- a/backend/docker.dev.env +++ b/backend/docker.dev.env @@ -2,6 +2,7 @@ DATABASE_URL=psql://decompme:decompme@postgres:5432/decompme SECRET_KEY="django-insecure-nm#!8%zhc0wwi#m_*l9l)=m*6gs4&o_^-e5b5vj*k05&yaqc1" DEBUG="on" ALLOWED_HOSTS="backend,localhost,127.0.0.1" -USE_SANDBOX_JAIL="on" -SANDBOX_DISABLE_PROC="true" MEDIA_URL=http://localhost/media/ + +# Cromper settings +CROMPER_URL=http://cromper:8888 diff --git a/backend/docker_entrypoint.sh b/backend/docker_entrypoint.sh index 6017ae084..9850c071a 100755 --- a/backend/docker_entrypoint.sh +++ b/backend/docker_entrypoint.sh @@ -6,12 +6,8 @@ DB_PORT=${DATABASE_PORT:-5432} BE_HOST=${BACKEND_HOST:-0.0.0.0} BE_PORT=${BACKEND_PORT:-8000} - uv sync -uv run /backend/compilers/download.py -uv run /backend/libraries/download.py - until nc -z ${DB_HOST} ${DB_PORT} > /dev/null; do echo "Waiting for database to become available on ${DB_HOST}:${DB_PORT}..." sleep 1 diff --git a/backend/docker_prod_entrypoint.sh b/backend/docker_prod_entrypoint.sh index 23be21800..1d6b8bbfb 100755 --- a/backend/docker_prod_entrypoint.sh +++ b/backend/docker_prod_entrypoint.sh @@ -15,7 +15,7 @@ done if [ -z "$CI" ]; then uv run manage.py clearsessions - uv run /backend/housekeeping.py + uv run /backend/db_housekeeping.py else echo "Skipping housekeeping: running in CI environment" fi diff --git a/backend/manage.py b/backend/manage.py index 2689f8a65..998917e78 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -5,7 +5,7 @@ import sys -def main(): +def main() -> None: """Run administrative tasks.""" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "decompme.settings") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7ce1a2dd5..3cc1ea12f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -8,8 +8,6 @@ authors = [ license = {text = "MIT"} requires-python = ">=3.12" dependencies = [ - "asm-differ @ git+https://github.com/simonlindholm/asm-differ.git", - "m2c @ git+https://github.com/matt-kempster/m2c.git", "django-cleanup>=9.0.0", "django-cors-headers>=4.6.0", "django-environ>=0.13.0", diff --git a/backend/uv.lock b/backend/uv.lock index bf9868c50..f57b6754d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -11,23 +11,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, ] -[[package]] -name = "asm-differ" -version = "0.1.0" -source = { git = "https://github.com/simonlindholm/asm-differ.git#5074289a13ced3a4ca4359c94d3f337d760a93ca" } -dependencies = [ - { name = "colorama" }, - { name = "cxxfilt" }, - { name = "levenshtein" }, - { name = "watchdog" }, -] - [[package]] name = "backend" version = "0.1.0" source = { editable = "." } dependencies = [ - { name = "asm-differ" }, { name = "django" }, { name = "django-cleanup" }, { name = "django-cors-headers" }, @@ -41,7 +29,6 @@ dependencies = [ { name = "gunicorn" }, { name = "html-json-forms" }, { name = "itsdangerous" }, - { name = "m2c" }, { name = "parameterized" }, { name = "pillow" }, { name = "psycopg2-binary" }, @@ -64,7 +51,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "asm-differ", git = "https://github.com/simonlindholm/asm-differ.git" }, { name = "django", specifier = "==5.2.11" }, { name = "django-cleanup", specifier = ">=9.0.0" }, { name = "django-cors-headers", specifier = ">=4.6.0" }, @@ -78,7 +64,6 @@ requires-dist = [ { name = "gunicorn", specifier = ">=25.1.0" }, { name = "html-json-forms", specifier = ">=1.1.1" }, { name = "itsdangerous", specifier = "==2.2.0" }, - { name = "m2c", git = "https://github.com/matt-kempster/m2c.git" }, { name = "parameterized", specifier = ">=0.9.0" }, { name = "pillow", specifier = "==12.1.1" }, { name = "psycopg2-binary", specifier = ">=2.9.10" }, @@ -222,15 +207,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, ] -[[package]] -name = "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 = "cryptography" version = "46.0.3" @@ -287,15 +263,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, ] -[[package]] -name = "cxxfilt" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/dc/71ac606f7dfa71d49e3dc126b49b18daefaf6bd953078858af30fde40702/cxxfilt-0.3.0.tar.gz", hash = "sha256:7df6464ba5e8efbf0d8974c0b2c78b32546676f06059a83515dbdfa559b34214", size = 4806, upload-time = "2021-08-21T16:48:04.038Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/29/23572dc59bf4a3984fe3c5fc242f73be916785ee93387dd95c972dbf584e/cxxfilt-0.3.0-py2.py3-none-any.whl", hash = "sha256:774e85a8d0157775ed43276d89397d924b104135762d86b3a95f81f203094e07", size = 4649, upload-time = "2021-08-21T16:48:02.953Z" }, -] - [[package]] name = "debugpy" version = "1.8.20" @@ -464,15 +431,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/c1/b9721c9239b465c0ceac32b33e732c08f6ea0afd8156700e417416a47733/drf_extensions-0.8.0-py2.py3-none-any.whl", hash = "sha256:ab7bd854c9061c27ab55233b66d758001e5c2d81aaa9d117cbbe1c9ea49c77ab", size = 21589, upload-time = "2025-04-10T07:50:02.573Z" }, ] -[[package]] -name = "graphviz" -version = "0.20.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fa/83/5a40d19b8347f017e417710907f824915fba411a9befd092e52746b63e9f/graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d", size = 256455, upload-time = "2024-03-21T07:50:45.772Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/be/d59db2d1d52697c6adc9eacaf50e8965b6345cc143f671e1ed068818d5cf/graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5", size = 47126, upload-time = "2024-03-21T07:50:43.091Z" }, -] - [[package]] name = "gunicorn" version = "25.1.0" @@ -512,72 +470,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, ] -[[package]] -name = "levenshtein" -version = "0.27.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "rapidfuzz" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/82/56/dcf68853b062e3b94bdc3d011cc4198779abc5b9dc134146a062920ce2e2/levenshtein-0.27.3.tar.gz", hash = "sha256:1ac326b2c84215795163d8a5af471188918b8797b4953ec87aaba22c9c1f9fc0", size = 393269, upload-time = "2025-11-01T12:14:31.04Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/8e/3be9d8e0245704e3af5258fb6cb157c3d59902e1351e95edf6ed8a8c0434/levenshtein-0.27.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2de7f095b0ca8e44de9de986ccba661cd0dec3511c751b499e76b60da46805e9", size = 169622, upload-time = "2025-11-01T12:13:10.026Z" }, - { url = "https://files.pythonhosted.org/packages/a6/42/a2b2fda5e8caf6ecd5aac142f946a77574a3961e65da62c12fd7e48e5cb1/levenshtein-0.27.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9b8b29e5d5145a3c958664c85151b1bb4b26e4ca764380b947e6a96a321217c", size = 159183, upload-time = "2025-11-01T12:13:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/eb/c4/f083fabbd61c449752df1746533538f4a8629e8811931b52f66e6c4290ad/levenshtein-0.27.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc975465a51b1c5889eadee1a583b81fba46372b4b22df28973e49e8ddb8f54a", size = 133120, upload-time = "2025-11-01T12:13:12.363Z" }, - { url = "https://files.pythonhosted.org/packages/4e/e5/b6421e04cb0629615b8efd6d4d167dd2b1afb5097b87bb83cd992004dcca/levenshtein-0.27.3-cp312-cp312-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:57573ed885118554770979fdee584071b66103f6d50beddeabb54607a1213d81", size = 114988, upload-time = "2025-11-01T12:13:13.486Z" }, - { url = "https://files.pythonhosted.org/packages/e5/77/39ee0e8d3028e90178e1031530ccc98563f8f2f0d905ec784669dcf0fa90/levenshtein-0.27.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23aff800a6dd5d91bb3754a6092085aa7ad46b28e497682c155c74f681cfaa2d", size = 153346, upload-time = "2025-11-01T12:13:14.744Z" }, - { url = "https://files.pythonhosted.org/packages/3c/0d/c0f367bbd260dbd7a4e134fd21f459e0f5eac43deac507952b46a1d8a93a/levenshtein-0.27.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c08a952432b8ad9dccb145f812176db94c52cda732311ddc08d29fd3bf185b0a", size = 1114538, upload-time = "2025-11-01T12:13:15.851Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ef/ae71433f7b4db0bd2af7974785e36cdec899919203fb82e647c5a6109c07/levenshtein-0.27.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3bfcb2d78ab9cc06a1e75da8fcfb7a430fe513d66cfe54c07e50f32805e5e6db", size = 1009734, upload-time = "2025-11-01T12:13:17.212Z" }, - { url = "https://files.pythonhosted.org/packages/27/dc/62c28b812dcb0953fc32ab7adf3d0e814e43c8560bb28d9269a44d874adf/levenshtein-0.27.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba7235f6dcb31a217247468295e2dd4c6c1d3ac81629dc5d355d93e1a5f4c185", size = 1185581, upload-time = "2025-11-01T12:13:18.661Z" }, - { url = "https://files.pythonhosted.org/packages/56/e8/2e7ab9c565793220edb8e5432f9a846386a157075bdd032a90e9585bce38/levenshtein-0.27.3-cp312-cp312-win32.whl", hash = "sha256:ea80d70f1d18c161a209be556b9094968627cbaae620e102459ef9c320a98cbb", size = 84660, upload-time = "2025-11-01T12:13:19.87Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a6/907a1fc8587dc91c40156973e09d106ab064c06eb28dc4700ba0fe54d654/levenshtein-0.27.3-cp312-cp312-win_amd64.whl", hash = "sha256:fbaa1219d9b2d955339a37e684256a861e9274a3fe3a6ee1b8ea8724c3231ed9", size = 94909, upload-time = "2025-11-01T12:13:21.323Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d6/e04f0ddf6a71df3cdd1817b71703490ac874601ed460b2af172d3752c321/levenshtein-0.27.3-cp312-cp312-win_arm64.whl", hash = "sha256:2edbaa84f887ea1d9d8e4440af3fdda44769a7855d581c6248d7ee51518402a8", size = 87358, upload-time = "2025-11-01T12:13:22.393Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f2/162e9ea7490b36bbf05776c8e3a8114c75aa78546ddda8e8f36731db3da6/levenshtein-0.27.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e55aa9f9453fd89d4a9ff1f3c4a650b307d5f61a7eed0568a52fbd2ff2eba107", size = 169230, upload-time = "2025-11-01T12:13:23.735Z" }, - { url = "https://files.pythonhosted.org/packages/01/2d/7316ba7f94e3d60e89bd120526bc71e4812866bb7162767a2a10f73f72c5/levenshtein-0.27.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae4d484453c48939ecd01c5c213530c68dd5cd6e5090f0091ef69799ec7a8a9f", size = 158643, upload-time = "2025-11-01T12:13:25.549Z" }, - { url = "https://files.pythonhosted.org/packages/5e/87/85433cb1e51c45016f061d96fea3106b6969f700e2cbb56c15de82d0deeb/levenshtein-0.27.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d18659832567ee387b266be390da0de356a3aa6cf0e8bc009b6042d8188e131f", size = 132881, upload-time = "2025-11-01T12:13:26.822Z" }, - { url = "https://files.pythonhosted.org/packages/40/1c/3ce66c9a7da169a43dd89146d69df9dec935e6f86c70c6404f48d1291d2c/levenshtein-0.27.3-cp313-cp313-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027b3d142cc8ea2ab4e60444d7175f65a94dde22a54382b2f7b47cc24936eb53", size = 114650, upload-time = "2025-11-01T12:13:28.382Z" }, - { url = "https://files.pythonhosted.org/packages/73/60/7138e98884ca105c76ef192f5b43165d6eac6f32b432853ebe9f09ee50c9/levenshtein-0.27.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdca6989368cc64f347f0423c528520f12775b812e170a0eb0c10e4c9b0f3ff", size = 153127, upload-time = "2025-11-01T12:13:29.781Z" }, - { url = "https://files.pythonhosted.org/packages/df/8f/664ac8b83026d7d1382866b68babae17e92b7b6ff8dc3c6205c0066b8ce1/levenshtein-0.27.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fa00ab389386032b02a1c9050ec3c6aa824d2bbcc692548fdc44a46b71c058c6", size = 1114602, upload-time = "2025-11-01T12:13:31.651Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c8/8905d96cf2d7ed6af7eb39a8be0925ef335729473c1e9d1f56230ecaffc5/levenshtein-0.27.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:691c9003c6c481b899a5c2f72e8ce05a6d956a9668dc75f2a3ce9f4381a76dc6", size = 1008036, upload-time = "2025-11-01T12:13:33.006Z" }, - { url = "https://files.pythonhosted.org/packages/c7/57/01c37608121380a6357a297625562adad1c1fc8058d4f62279b735108927/levenshtein-0.27.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:12f7fc8bf0c24492fe97905348e020b55b9fc6dbaab7cd452566d1a466cb5e15", size = 1185338, upload-time = "2025-11-01T12:13:34.452Z" }, - { url = "https://files.pythonhosted.org/packages/dd/57/bceab41d40b58dee7927a8d1d18ed3bff7c95c5e530fb60093ce741a8c26/levenshtein-0.27.3-cp313-cp313-win32.whl", hash = "sha256:9f4872e4e19ee48eed39f214eea4eca42e5ef303f8a4a488d8312370674dbf3a", size = 84562, upload-time = "2025-11-01T12:13:35.858Z" }, - { url = "https://files.pythonhosted.org/packages/42/1d/74f1ff589bb687d0cad2bbdceef208dc070f56d1e38a3831da8c00bf13bb/levenshtein-0.27.3-cp313-cp313-win_amd64.whl", hash = "sha256:83aa2422e9a9af2c9d3e56a53e3e8de6bae58d1793628cae48c4282577c5c2c6", size = 94658, upload-time = "2025-11-01T12:13:36.963Z" }, - { url = "https://files.pythonhosted.org/packages/21/3c/22c86d3c8f254141096fd6089d2e9fdf98b1472c7a5d79d36d3557ec2d83/levenshtein-0.27.3-cp313-cp313-win_arm64.whl", hash = "sha256:d4adaf1edbcf38c3f2e290b52f4dcb5c6deff20308c26ef1127a106bc2d23e9f", size = 86929, upload-time = "2025-11-01T12:13:37.997Z" }, - { url = "https://files.pythonhosted.org/packages/0e/bc/9b7cf1b5fa098b86844d42de22549304699deff309c5c9e28b9a3fc4076a/levenshtein-0.27.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:272e24764b8210337b65a1cfd69ce40df5d2de1a3baf1234e7f06d2826ba2e7a", size = 170360, upload-time = "2025-11-01T12:13:39.019Z" }, - { url = "https://files.pythonhosted.org/packages/dc/95/997f2c83bd4712426bf0de8143b5e4403c7ebbafb5d1271983e774de3ae7/levenshtein-0.27.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:329a8e748a4e14d56daaa11f07bce3fde53385d05bad6b3f6dd9ee7802cdc915", size = 159098, upload-time = "2025-11-01T12:13:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/fc/96/123c3316ae2f72c73be4fba9756924af015da4c0e5b12804f5753c0ee511/levenshtein-0.27.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5fea1a9c6b9cc8729e467e2174b4359ff6bac27356bb5f31898e596b4ce133a", size = 136655, upload-time = "2025-11-01T12:13:41.262Z" }, - { url = "https://files.pythonhosted.org/packages/45/72/a3180d437736b1b9eacc3100be655a756deafb91de47c762d40eb45a9d91/levenshtein-0.27.3-cp313-cp313t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3a61aa825819b6356555091d8a575d1235bd9c3753a68316a261af4856c3b487", size = 117511, upload-time = "2025-11-01T12:13:42.647Z" }, - { url = "https://files.pythonhosted.org/packages/61/f9/ba7c546a4b99347938e6661104064ab6a3651c601d59f241ffdc37510ecc/levenshtein-0.27.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51de7a514e8183f0a82f2947d01b014d2391426543b1c076bf5a26328cec4e4", size = 155656, upload-time = "2025-11-01T12:13:44.208Z" }, - { url = "https://files.pythonhosted.org/packages/42/cd/5edd6e1e02c3e47c8121761756dd0f85f816b636f25509118b687e6b0f96/levenshtein-0.27.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53cbf726d6e92040c9be7e594d959d496bd62597ea48eba9d96105898acbeafe", size = 1116689, upload-time = "2025-11-01T12:13:45.485Z" }, - { url = "https://files.pythonhosted.org/packages/95/67/25ca0119e0c6ec17226c72638f48ef8887124597ac48ad5da111c0b3a825/levenshtein-0.27.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:191b358afead8561c4fcfed22f83c13bb6c8da5f5789e277f0c5aa1c45ca612f", size = 1003166, upload-time = "2025-11-01T12:13:47.126Z" }, - { url = "https://files.pythonhosted.org/packages/45/64/ab216f3fb3cef1ee7e222665537f9340d828ef84c99409ba31f2ef2a3947/levenshtein-0.27.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ba1318d0635b834b8f0397014a7c43f007e65fce396a47614780c881bdff828b", size = 1189362, upload-time = "2025-11-01T12:13:48.627Z" }, - { url = "https://files.pythonhosted.org/packages/31/58/b150034858de0899a5a222974b6710618ebc0779a0695df070f7ab559a0b/levenshtein-0.27.3-cp313-cp313t-win32.whl", hash = "sha256:8dd9e1db6c3b35567043e155a686e4827c4aa28a594bd81e3eea84d3a1bd5875", size = 86149, upload-time = "2025-11-01T12:13:50.588Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c4/bbe46a11073641450200e6a604b3b62d311166e8061c492612a40e560e85/levenshtein-0.27.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7813ecdac7a6223264ebfea0c8d69959c43d21a99694ef28018d22c4265c2af6", size = 96685, upload-time = "2025-11-01T12:13:51.641Z" }, - { url = "https://files.pythonhosted.org/packages/23/65/30b362ad9bfc1085741776a08b6ddee3f434e9daac2920daaee2e26271bf/levenshtein-0.27.3-cp313-cp313t-win_arm64.whl", hash = "sha256:8f05a0d23d13a6f802c7af595d0e43f5b9b98b6ed390cec7a35cb5d6693b882b", size = 88538, upload-time = "2025-11-01T12:13:52.757Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e1/2f705da403f865a5fa3449b155738dc9c53021698fd6926253a9af03180b/levenshtein-0.27.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a6728bfae9a86002f0223576675fc7e2a6e7735da47185a1d13d1eaaa73dd4be", size = 169457, upload-time = "2025-11-01T12:13:53.778Z" }, - { url = "https://files.pythonhosted.org/packages/76/2c/bb6ef359e007fe7b6b3195b68a94f4dd3ecd1885ee337ee8fbd4df55996f/levenshtein-0.27.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8e5037c4a6f97a238e24aad6f98a1e984348b7931b1b04b6bd02bd4f8238150d", size = 158680, upload-time = "2025-11-01T12:13:55.005Z" }, - { url = "https://files.pythonhosted.org/packages/51/7b/de1999f4cf1cfebc3fbbf03a6d58498952d6560d9798af4b0a566e6b6f30/levenshtein-0.27.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6cf5ecf9026bf24cf66ad019c6583f50058fae3e1b3c20e8812455b55d597f1", size = 133167, upload-time = "2025-11-01T12:13:56.426Z" }, - { url = "https://files.pythonhosted.org/packages/c7/da/aaa7f3a0a8ae8744b284043653652db3d7d93595517f9ed8158c03287692/levenshtein-0.27.3-cp314-cp314-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9285084bd2fc19adb47dab54ed4a71f57f78fe0d754e4a01e3c75409a25aed24", size = 114530, upload-time = "2025-11-01T12:13:57.883Z" }, - { url = "https://files.pythonhosted.org/packages/29/ce/ed422816fb30ffa3bc11597b30d5deca06b4a1388707a04215da73c65b53/levenshtein-0.27.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce3bbbe92172a08b599d79956182c6b7ab6ec8d4adbe7237417a363b968ad87b", size = 153325, upload-time = "2025-11-01T12:13:59.318Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5a/a225477a0bda154f19f1c07a5e35500d631ae25dfd620b479027d79f0d4c/levenshtein-0.27.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9dac48fab9d166ca90e12fb6cf6c7c8eb9c41aacf7136584411e20f7f136f745", size = 1114956, upload-time = "2025-11-01T12:14:00.543Z" }, - { url = "https://files.pythonhosted.org/packages/ca/c4/a1be1040f3cce516a5e2be68453fd0c32ac63b2e9d31f476723fd8002c09/levenshtein-0.27.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d37a83722dc5326c93d17078e926c4732dc4f3488dc017c6839e34cd16af92b7", size = 1007610, upload-time = "2025-11-01T12:14:02.036Z" }, - { url = "https://files.pythonhosted.org/packages/86/d7/6f50e8a307e0c2befd819b481eb3a4c2eacab3dd8101982423003fac8ea3/levenshtein-0.27.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3466cb8294ce586e49dd467560a153ab8d296015c538223f149f9aefd3d9f955", size = 1185379, upload-time = "2025-11-01T12:14:03.385Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e5/5d8fb1b3ebd5735f53221bf95c923066bcfc132234925820128f7eee5b47/levenshtein-0.27.3-cp314-cp314-win32.whl", hash = "sha256:c848bf2457b268672b7e9e73b44f18f49856420ac50b2564cf115a6e4ef82688", size = 86328, upload-time = "2025-11-01T12:14:04.74Z" }, - { url = "https://files.pythonhosted.org/packages/30/82/8a9ccbdb4e38bd4d516f2804999dccb8cb4bcb4e33f52851735da0c73ea7/levenshtein-0.27.3-cp314-cp314-win_amd64.whl", hash = "sha256:742633f024362a4ed6ef9d7e75d68f74b041ae738985fcf55a0e6d1d4cade438", size = 96640, upload-time = "2025-11-01T12:14:06.24Z" }, - { url = "https://files.pythonhosted.org/packages/14/86/f9d15919f59f5d92c6baa500315e1fa0143a39d811427b83c54f038267ca/levenshtein-0.27.3-cp314-cp314-win_arm64.whl", hash = "sha256:9eed6851224b19e8d588ddb8eb8a4ae3c2dcabf3d1213985f0b94a67e517b1df", size = 89689, upload-time = "2025-11-01T12:14:07.379Z" }, - { url = "https://files.pythonhosted.org/packages/ed/f6/10f44975ae6dc3047b2cd260e3d4c3a5258b8d10690a42904115de24fc51/levenshtein-0.27.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:77de69a345c76227b51a4521cd85442eb3da54c7eb6a06663a20c058fc49e683", size = 170518, upload-time = "2025-11-01T12:14:09.196Z" }, - { url = "https://files.pythonhosted.org/packages/08/07/fa294a145a0c99a814a9a807614962c1ee0f5749ca691645980462027d5d/levenshtein-0.27.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:eba2756dc1f5b962b0ff80e49abb2153d5e809cc5e7fa5e85be9410ce474795d", size = 159097, upload-time = "2025-11-01T12:14:10.404Z" }, - { url = "https://files.pythonhosted.org/packages/ae/50/24bdf37813fc30f293e53b46022b091144f4737a6a66663d2235b311bb98/levenshtein-0.27.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c8fcb498287e971d84260f67808ff1a06b3f6212d80fea75cf5155db80606ff", size = 136650, upload-time = "2025-11-01T12:14:11.579Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a9/0399c7a190b277cdea3acc801129d9d30da57c3fa79519e7b8c3f080d86c/levenshtein-0.27.3-cp314-cp314t-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f067092c67464faab13e00a5c1a80da93baca8955d4d49579861400762e35591", size = 117515, upload-time = "2025-11-01T12:14:12.877Z" }, - { url = "https://files.pythonhosted.org/packages/bf/a4/1c27533e97578b385a4b8079abe8d1ce2e514717c761efbe4bf7bbd0ac2e/levenshtein-0.27.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92415f32c68491203f2855d05eef3277d376182d014cf0859c013c89f277fbbf", size = 155711, upload-time = "2025-11-01T12:14:13.985Z" }, - { url = "https://files.pythonhosted.org/packages/50/35/bbc26638394a72b1e31a685ec251c995ee66a630c7e5c86f98770928b632/levenshtein-0.27.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ef61eeaf1e0a42d7d947978d981fe4b9426b98b3dd8c1582c535f10dee044c3f", size = 1116692, upload-time = "2025-11-01T12:14:15.359Z" }, - { url = "https://files.pythonhosted.org/packages/cd/83/32fcf28b388f8dc6c36b54552b9bae289dab07d43df104893158c834cbcc/levenshtein-0.27.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:103bb2e9049d1aa0d1216dd09c1c9106ecfe7541bbdc1a0490b9357d42eec8f2", size = 1003167, upload-time = "2025-11-01T12:14:17.469Z" }, - { url = "https://files.pythonhosted.org/packages/d1/79/1fbf2877ec4b819f373a32ebe3c48a61ee810693593a6015108b0be97b78/levenshtein-0.27.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6a64ddd1986b2a4c468b09544382287315c53585eb067f6e200c337741e057ee", size = 1189417, upload-time = "2025-11-01T12:14:19.081Z" }, - { url = "https://files.pythonhosted.org/packages/d3/ac/dad4e09f1f7459c64172e48e40ed2baf3aa92d38205bcbd1b4ff00853701/levenshtein-0.27.3-cp314-cp314t-win32.whl", hash = "sha256:957244f27dc284ccb030a8b77b8a00deb7eefdcd70052a4b1d96f375780ae9dc", size = 88144, upload-time = "2025-11-01T12:14:20.667Z" }, - { url = "https://files.pythonhosted.org/packages/c0/61/cd51dc8b8a382e17c559a9812734c3a9afc2dab7d36253516335ee16ae50/levenshtein-0.27.3-cp314-cp314t-win_amd64.whl", hash = "sha256:ccd7eaa6d8048c3ec07c93cfbcdefd4a3ae8c6aca3a370f2023ee69341e5f076", size = 98516, upload-time = "2025-11-01T12:14:21.786Z" }, - { url = "https://files.pythonhosted.org/packages/27/5e/3fb67e882c1fee01ebb7abc1c0a6669e5ff8acd060e93bfe7229e9ce6e4f/levenshtein-0.27.3-cp314-cp314t-win_arm64.whl", hash = "sha256:1d8520b89b7a27bb5aadbcc156715619bcbf556a8ac46ad932470945dca6e1bd", size = 91020, upload-time = "2025-11-01T12:14:22.944Z" }, -] - [[package]] name = "librt" version = "0.8.1" @@ -638,14 +530,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] -[[package]] -name = "m2c" -version = "0.1.0" -source = { git = "https://github.com/matt-kempster/m2c.git#2853e096eb2368f601cd0f05119e98d74c2edca6" } -dependencies = [ - { name = "graphviz" }, -] - [[package]] name = "mypy" version = "1.19.1" @@ -947,69 +831,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "rapidfuzz" -version = "3.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d3/28/9d808fe62375b9aab5ba92fa9b29371297b067c2790b2d7cda648b1e2f8d/rapidfuzz-3.14.3.tar.gz", hash = "sha256:2491937177868bc4b1e469087601d53f925e8d270ccc21e07404b4b5814b7b5f", size = 57863900, upload-time = "2025-11-01T11:54:52.321Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/8e/3c215e860b458cfbedb3ed73bc72e98eb7e0ed72f6b48099604a7a3260c2/rapidfuzz-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:685c93ea961d135893b5984a5a9851637d23767feabe414ec974f43babbd8226", size = 1945306, upload-time = "2025-11-01T11:53:06.452Z" }, - { url = "https://files.pythonhosted.org/packages/36/d9/31b33512015c899f4a6e6af64df8dfe8acddf4c8b40a4b3e0e6e1bcd00e5/rapidfuzz-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fa7c8f26f009f8c673fbfb443792f0cf8cf50c4e18121ff1e285b5e08a94fbdb", size = 1390788, upload-time = "2025-11-01T11:53:08.721Z" }, - { url = "https://files.pythonhosted.org/packages/a9/67/2ee6f8de6e2081ccd560a571d9c9063184fe467f484a17fa90311a7f4a2e/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57f878330c8d361b2ce76cebb8e3e1dc827293b6abf404e67d53260d27b5d941", size = 1374580, upload-time = "2025-11-01T11:53:10.164Z" }, - { url = "https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c5f545f454871e6af05753a0172849c82feaf0f521c5ca62ba09e1b382d6382", size = 3154947, upload-time = "2025-11-01T11:53:12.093Z" }, - { url = "https://files.pythonhosted.org/packages/5b/cf/9f49831085a16384695f9fb096b99662f589e30b89b4a589a1ebc1a19d34/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:07aa0b5d8863e3151e05026a28e0d924accf0a7a3b605da978f0359bb804df43", size = 1223872, upload-time = "2025-11-01T11:53:13.664Z" }, - { url = "https://files.pythonhosted.org/packages/c8/0f/41ee8034e744b871c2e071ef0d360686f5ccfe5659f4fd96c3ec406b3c8b/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73b07566bc7e010e7b5bd490fb04bb312e820970180df6b5655e9e6224c137db", size = 2392512, upload-time = "2025-11-01T11:53:15.109Z" }, - { url = "https://files.pythonhosted.org/packages/da/86/280038b6b0c2ccec54fb957c732ad6b41cc1fd03b288d76545b9cf98343f/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6de00eb84c71476af7d3110cf25d8fe7c792d7f5fa86764ef0b4ca97e78ca3ed", size = 2521398, upload-time = "2025-11-01T11:53:17.146Z" }, - { url = "https://files.pythonhosted.org/packages/fa/7b/05c26f939607dca0006505e3216248ae2de631e39ef94dd63dbbf0860021/rapidfuzz-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d7843a1abf0091773a530636fdd2a49a41bcae22f9910b86b4f903e76ddc82dc", size = 4259416, upload-time = "2025-11-01T11:53:19.34Z" }, - { url = "https://files.pythonhosted.org/packages/40/eb/9e3af4103d91788f81111af1b54a28de347cdbed8eaa6c91d5e98a889aab/rapidfuzz-3.14.3-cp312-cp312-win32.whl", hash = "sha256:dea97ac3ca18cd3ba8f3d04b5c1fe4aa60e58e8d9b7793d3bd595fdb04128d7a", size = 1709527, upload-time = "2025-11-01T11:53:20.949Z" }, - { url = "https://files.pythonhosted.org/packages/b8/63/d06ecce90e2cf1747e29aeab9f823d21e5877a4c51b79720b2d3be7848f8/rapidfuzz-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:b5100fd6bcee4d27f28f4e0a1c6b5127bc8ba7c2a9959cad9eab0bf4a7ab3329", size = 1538989, upload-time = "2025-11-01T11:53:22.428Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6d/beee32dcda64af8128aab3ace2ccb33d797ed58c434c6419eea015fec779/rapidfuzz-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:4e49c9e992bc5fc873bd0fff7ef16a4405130ec42f2ce3d2b735ba5d3d4eb70f", size = 811161, upload-time = "2025-11-01T11:53:23.811Z" }, - { url = "https://files.pythonhosted.org/packages/e4/4f/0d94d09646853bd26978cb3a7541b6233c5760687777fa97da8de0d9a6ac/rapidfuzz-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dbcb726064b12f356bf10fffdb6db4b6dce5390b23627c08652b3f6e49aa56ae", size = 1939646, upload-time = "2025-11-01T11:53:25.292Z" }, - { url = "https://files.pythonhosted.org/packages/b6/eb/f96aefc00f3bbdbab9c0657363ea8437a207d7545ac1c3789673e05d80bd/rapidfuzz-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1704fc70d214294e554a2421b473779bcdeef715881c5e927dc0f11e1692a0ff", size = 1385512, upload-time = "2025-11-01T11:53:27.594Z" }, - { url = "https://files.pythonhosted.org/packages/26/34/71c4f7749c12ee223dba90017a5947e8f03731a7cc9f489b662a8e9e643d/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc65e72790ddfd310c2c8912b45106e3800fefe160b0c2ef4d6b6fec4e826457", size = 1373571, upload-time = "2025-11-01T11:53:29.096Z" }, - { url = "https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e38c1305cffae8472572a0584d4ffc2f130865586a81038ca3965301f7c97c", size = 3156759, upload-time = "2025-11-01T11:53:30.777Z" }, - { url = "https://files.pythonhosted.org/packages/61/d5/b41eeb4930501cc899d5a9a7b5c9a33d85a670200d7e81658626dcc0ecc0/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:e195a77d06c03c98b3fc06b8a28576ba824392ce40de8c708f96ce04849a052e", size = 1222067, upload-time = "2025-11-01T11:53:32.334Z" }, - { url = "https://files.pythonhosted.org/packages/2a/7d/6d9abb4ffd1027c6ed837b425834f3bed8344472eb3a503ab55b3407c721/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b7ef2f4b8583a744338a18f12c69693c194fb6777c0e9ada98cd4d9e8f09d10", size = 2394775, upload-time = "2025-11-01T11:53:34.24Z" }, - { url = "https://files.pythonhosted.org/packages/15/ce/4f3ab4c401c5a55364da1ffff8cc879fc97b4e5f4fa96033827da491a973/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a2135b138bcdcb4c3742d417f215ac2d8c2b87bde15b0feede231ae95f09ec41", size = 2526123, upload-time = "2025-11-01T11:53:35.779Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4b/54f804975376a328f57293bd817c12c9036171d15cf7292032e3f5820b2d/rapidfuzz-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33a325ed0e8e1aa20c3e75f8ab057a7b248fdea7843c2a19ade0008906c14af0", size = 4262874, upload-time = "2025-11-01T11:53:37.866Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b6/958db27d8a29a50ee6edd45d33debd3ce732e7209183a72f57544cd5fe22/rapidfuzz-3.14.3-cp313-cp313-win32.whl", hash = "sha256:8383b6d0d92f6cd008f3c9216535be215a064b2cc890398a678b56e6d280cb63", size = 1707972, upload-time = "2025-11-01T11:53:39.442Z" }, - { url = "https://files.pythonhosted.org/packages/07/75/fde1f334b0cec15b5946d9f84d73250fbfcc73c236b4bc1b25129d90876b/rapidfuzz-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:e6b5e3036976f0fde888687d91be86d81f9ac5f7b02e218913c38285b756be6c", size = 1537011, upload-time = "2025-11-01T11:53:40.92Z" }, - { url = "https://files.pythonhosted.org/packages/2e/d7/d83fe001ce599dc7ead57ba1debf923dc961b6bdce522b741e6b8c82f55c/rapidfuzz-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:7ba009977601d8b0828bfac9a110b195b3e4e79b350dcfa48c11269a9f1918a0", size = 810744, upload-time = "2025-11-01T11:53:42.723Z" }, - { url = "https://files.pythonhosted.org/packages/92/13/a486369e63ff3c1a58444d16b15c5feb943edd0e6c28a1d7d67cb8946b8f/rapidfuzz-3.14.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0a28add871425c2fe94358c6300bbeb0bc2ed828ca003420ac6825408f5a424", size = 1967702, upload-time = "2025-11-01T11:53:44.554Z" }, - { url = "https://files.pythonhosted.org/packages/f1/82/efad25e260b7810f01d6b69122685e355bed78c94a12784bac4e0beb2afb/rapidfuzz-3.14.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:010e12e2411a4854b0434f920e72b717c43f8ec48d57e7affe5c42ecfa05dd0e", size = 1410702, upload-time = "2025-11-01T11:53:46.066Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1a/34c977b860cde91082eae4a97ae503f43e0d84d4af301d857679b66f9869/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cfc3d57abd83c734d1714ec39c88a34dd69c85474918ebc21296f1e61eb5ca8", size = 1382337, upload-time = "2025-11-01T11:53:47.62Z" }, - { url = "https://files.pythonhosted.org/packages/88/74/f50ea0e24a5880a9159e8fd256b84d8f4634c2f6b4f98028bdd31891d907/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89acb8cbb52904f763e5ac238083b9fc193bed8d1f03c80568b20e4cef43a519", size = 3165563, upload-time = "2025-11-01T11:53:49.216Z" }, - { url = "https://files.pythonhosted.org/packages/e8/7a/e744359404d7737049c26099423fc54bcbf303de5d870d07d2fb1410f567/rapidfuzz-3.14.3-cp313-cp313t-manylinux_2_31_armv7l.whl", hash = "sha256:7d9af908c2f371bfb9c985bd134e295038e3031e666e4b2ade1e7cb7f5af2f1a", size = 1214727, upload-time = "2025-11-01T11:53:50.883Z" }, - { url = "https://files.pythonhosted.org/packages/d3/2e/87adfe14ce75768ec6c2b8acd0e05e85e84be4be5e3d283cdae360afc4fe/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1f1925619627f8798f8c3a391d81071336942e5fe8467bc3c567f982e7ce2897", size = 2403349, upload-time = "2025-11-01T11:53:52.322Z" }, - { url = "https://files.pythonhosted.org/packages/70/17/6c0b2b2bff9c8b12e12624c07aa22e922b0c72a490f180fa9183d1ef2c75/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:152555187360978119e98ce3e8263d70dd0c40c7541193fc302e9b7125cf8f58", size = 2507596, upload-time = "2025-11-01T11:53:53.835Z" }, - { url = "https://files.pythonhosted.org/packages/c3/d1/87852a7cbe4da7b962174c749a47433881a63a817d04f3e385ea9babcd9e/rapidfuzz-3.14.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:52619d25a09546b8db078981ca88939d72caa6b8701edd8b22e16482a38e799f", size = 4273595, upload-time = "2025-11-01T11:53:55.961Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ab/1d0354b7d1771a28fa7fe089bc23acec2bdd3756efa2419f463e3ed80e16/rapidfuzz-3.14.3-cp313-cp313t-win32.whl", hash = "sha256:489ce98a895c98cad284f0a47960c3e264c724cb4cfd47a1430fa091c0c25204", size = 1757773, upload-time = "2025-11-01T11:53:57.628Z" }, - { url = "https://files.pythonhosted.org/packages/0b/0c/71ef356adc29e2bdf74cd284317b34a16b80258fa0e7e242dd92cc1e6d10/rapidfuzz-3.14.3-cp313-cp313t-win_amd64.whl", hash = "sha256:656e52b054d5b5c2524169240e50cfa080b04b1c613c5f90a2465e84888d6f15", size = 1576797, upload-time = "2025-11-01T11:53:59.455Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d2/0e64fc27bb08d4304aa3d11154eb5480bcf5d62d60140a7ee984dc07468a/rapidfuzz-3.14.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c7e40c0a0af02ad6e57e89f62bef8604f55a04ecae90b0ceeda591bbf5923317", size = 829940, upload-time = "2025-11-01T11:54:01.1Z" }, - { url = "https://files.pythonhosted.org/packages/32/6f/1b88aaeade83abc5418788f9e6b01efefcd1a69d65ded37d89cd1662be41/rapidfuzz-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:442125473b247227d3f2de807a11da6c08ccf536572d1be943f8e262bae7e4ea", size = 1942086, upload-time = "2025-11-01T11:54:02.592Z" }, - { url = "https://files.pythonhosted.org/packages/a0/2c/b23861347436cb10f46c2bd425489ec462790faaa360a54a7ede5f78de88/rapidfuzz-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1ec0c8c0c3d4f97ced46b2e191e883f8c82dbbf6d5ebc1842366d7eff13cd5a6", size = 1386993, upload-time = "2025-11-01T11:54:04.12Z" }, - { url = "https://files.pythonhosted.org/packages/83/86/5d72e2c060aa1fbdc1f7362d938f6b237dff91f5b9fc5dd7cc297e112250/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2dc37bc20272f388b8c3a4eba4febc6e77e50a8f450c472def4751e7678f55e4", size = 1379126, upload-time = "2025-11-01T11:54:05.777Z" }, - { url = "https://files.pythonhosted.org/packages/c9/bc/ef2cee3e4d8b3fc22705ff519f0d487eecc756abdc7c25d53686689d6cf2/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dee362e7e79bae940a5e2b3f6d09c6554db6a4e301cc68343886c08be99844f1", size = 3159304, upload-time = "2025-11-01T11:54:07.351Z" }, - { url = "https://files.pythonhosted.org/packages/a0/36/dc5f2f62bbc7bc90be1f75eeaf49ed9502094bb19290dfb4747317b17f12/rapidfuzz-3.14.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:4b39921df948388a863f0e267edf2c36302983459b021ab928d4b801cbe6a421", size = 1218207, upload-time = "2025-11-01T11:54:09.641Z" }, - { url = "https://files.pythonhosted.org/packages/df/7e/8f4be75c1bc62f47edf2bbbe2370ee482fae655ebcc4718ac3827ead3904/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:beda6aa9bc44d1d81242e7b291b446be352d3451f8217fcb068fc2933927d53b", size = 2401245, upload-time = "2025-11-01T11:54:11.543Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/f7c92759e1bb188dd05b80d11c630ba59b8d7856657baf454ff56059c2ab/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6a014ba09657abfcfeed64b7d09407acb29af436d7fc075b23a298a7e4a6b41c", size = 2518308, upload-time = "2025-11-01T11:54:13.134Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ac/85820f70fed5ecb5f1d9a55f1e1e2090ef62985ef41db289b5ac5ec56e28/rapidfuzz-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:32eeafa3abce138bb725550c0e228fc7eaeec7059aa8093d9cbbec2b58c2371a", size = 4265011, upload-time = "2025-11-01T11:54:15.087Z" }, - { url = "https://files.pythonhosted.org/packages/46/a9/616930721ea9835c918af7cde22bff17f9db3639b0c1a7f96684be7f5630/rapidfuzz-3.14.3-cp314-cp314-win32.whl", hash = "sha256:adb44d996fc610c7da8c5048775b21db60dd63b1548f078e95858c05c86876a3", size = 1742245, upload-time = "2025-11-01T11:54:17.19Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/f2fa5e9635b1ccafda4accf0e38246003f69982d7c81f2faa150014525a4/rapidfuzz-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:f3d15d8527e2b293e38ce6e437631af0708df29eafd7c9fc48210854c94472f9", size = 1584856, upload-time = "2025-11-01T11:54:18.764Z" }, - { url = "https://files.pythonhosted.org/packages/ef/97/09e20663917678a6d60d8e0e29796db175b1165e2079830430342d5298be/rapidfuzz-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:576e4b9012a67e0bf54fccb69a7b6c94d4e86a9540a62f1a5144977359133583", size = 833490, upload-time = "2025-11-01T11:54:20.753Z" }, - { url = "https://files.pythonhosted.org/packages/03/1b/6b6084576ba87bf21877c77218a0c97ba98cb285b0c02eaaee3acd7c4513/rapidfuzz-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:cec3c0da88562727dd5a5a364bd9efeb535400ff0bfb1443156dd139a1dd7b50", size = 1968658, upload-time = "2025-11-01T11:54:22.25Z" }, - { url = "https://files.pythonhosted.org/packages/38/c0/fb02a0db80d95704b0a6469cc394e8c38501abf7e1c0b2afe3261d1510c2/rapidfuzz-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d1fa009f8b1100e4880868137e7bf0501422898f7674f2adcd85d5a67f041296", size = 1410742, upload-time = "2025-11-01T11:54:23.863Z" }, - { url = "https://files.pythonhosted.org/packages/a4/72/3fbf12819fc6afc8ec75a45204013b40979d068971e535a7f3512b05e765/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b86daa7419b5e8b180690efd1fdbac43ff19230803282521c5b5a9c83977655", size = 1382810, upload-time = "2025-11-01T11:54:25.571Z" }, - { url = "https://files.pythonhosted.org/packages/0f/18/0f1991d59bb7eee28922a00f79d83eafa8c7bfb4e8edebf4af2a160e7196/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7bd1816db05d6c5ffb3a4df0a2b7b56fb8c81ef584d08e37058afa217da91b1", size = 3166349, upload-time = "2025-11-01T11:54:27.195Z" }, - { url = "https://files.pythonhosted.org/packages/0d/f0/baa958b1989c8f88c78bbb329e969440cf330b5a01a982669986495bb980/rapidfuzz-3.14.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:33da4bbaf44e9755b0ce192597f3bde7372fe2e381ab305f41b707a95ac57aa7", size = 1214994, upload-time = "2025-11-01T11:54:28.821Z" }, - { url = "https://files.pythonhosted.org/packages/e4/a0/cd12ec71f9b2519a3954febc5740291cceabc64c87bc6433afcb36259f3b/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3fecce764cf5a991ee2195a844196da840aba72029b2612f95ac68a8b74946bf", size = 2403919, upload-time = "2025-11-01T11:54:30.393Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ce/019bd2176c1644098eced4f0595cb4b3ef52e4941ac9a5854f209d0a6e16/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ecd7453e02cf072258c3a6b8e930230d789d5d46cc849503729f9ce475d0e785", size = 2508346, upload-time = "2025-11-01T11:54:32.048Z" }, - { url = "https://files.pythonhosted.org/packages/23/f8/be16c68e2c9e6c4f23e8f4adbb7bccc9483200087ed28ff76c5312da9b14/rapidfuzz-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea188aa00e9bcae8c8411f006a5f2f06c4607a02f24eab0d8dc58566aa911f35", size = 4274105, upload-time = "2025-11-01T11:54:33.701Z" }, - { url = "https://files.pythonhosted.org/packages/a1/d1/5ab148e03f7e6ec8cd220ccf7af74d3aaa4de26dd96df58936beb7cba820/rapidfuzz-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:7ccbf68100c170e9a0581accbe9291850936711548c6688ce3bfb897b8c589ad", size = 1793465, upload-time = "2025-11-01T11:54:35.331Z" }, - { url = "https://files.pythonhosted.org/packages/cd/97/433b2d98e97abd9fff1c470a109b311669f44cdec8d0d5aa250aceaed1fb/rapidfuzz-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9ec02e62ae765a318d6de38df609c57fc6dacc65c0ed1fd489036834fd8a620c", size = 1623491, upload-time = "2025-11-01T11:54:38.085Z" }, - { url = "https://files.pythonhosted.org/packages/e2/f6/e2176eb94f94892441bce3ddc514c179facb65db245e7ce3356965595b19/rapidfuzz-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:e805e52322ae29aa945baf7168b6c898120fbc16d2b8f940b658a5e9e3999253", size = 851487, upload-time = "2025-11-01T11:54:40.176Z" }, -] - [[package]] name = "requests" version = "2.32.5" @@ -1142,27 +963,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599 wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] - -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, -] diff --git a/cromper/.dockerignore b/cromper/.dockerignore new file mode 100644 index 000000000..14d9ee3c1 --- /dev/null +++ b/cromper/.dockerignore @@ -0,0 +1,16 @@ +# python files +virtualenvs/ +__pycache__/ +.mypy_cache/ + +# avoid cache-busting when testing Docker +Dockerfile* +docker.*.env + +# compilers +compilers/** +!compilers/download.py + +# libraries +libraries/** +!libraries/download.py diff --git a/cromper/.env b/cromper/.env new file mode 100644 index 000000000..1ec24934b --- /dev/null +++ b/cromper/.env @@ -0,0 +1,13 @@ +# cromper Configuration +CROMPER_PORT=8888 +CROMPER_DEBUG=true + +# Copy relevant settings from Django backend +USE_SANDBOX_JAIL=true +SANDBOX_DISABLE_PROC=true +COMPILATION_TIMEOUT_SECONDS=10 +ASSEMBLY_TIMEOUT_SECONDS=3 +WINEPREFIX=/tmp/wine +SANDBOX_NSJAIL_BIN_PATH=nsjail +SANDBOX_TMP_PATH=/tmp/sandbox +SANDBOX_CHROOT_PATH=/tmp/sandbox/root diff --git a/cromper/Dockerfile b/cromper/Dockerfile new file mode 100644 index 000000000..1573b8789 --- /dev/null +++ b/cromper/Dockerfile @@ -0,0 +1,261 @@ +# This image intentionally defaults to amd64 because the bundled +# compiler/binutils artifacts are x86 Linux binaries. +ARG CROMPER_PLATFORM=linux/amd64 +FROM --platform=$CROMPER_PLATFORM ubuntu:26.04 AS base + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + software-properties-common \ + && rm -rf /var/lib/apt/lists/* + + +FROM base AS python314 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + python-is-python3 \ + python3 \ + python3.14-dev \ + python3.14-venv \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:0.9.11 /uv /bin/uv + + +FROM base AS nsjail + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + autoconf \ + bison \ + flex \ + g++ \ + gcc \ + git \ + libnl-route-3-dev \ + libprotobuf-dev \ + libtool \ + make \ + pkg-config \ + protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* + +RUN git clone "https://github.com/google/nsjail" \ + --recursive --branch 3.6 /nsjail \ + && cd /nsjail \ + && make + + +FROM base AS binutils + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + binutils-aarch64-linux-gnu \ + binutils-arm-none-eabi \ + binutils-djgpp \ + binutils-mingw-w64-i686 \ + binutils-mips-linux-gnu \ + binutils-mipsel-linux-gnu \ + binutils-powerpc-linux-gnu \ + binutils-sh-elf \ + binutils-x86-64-linux-gnu \ + bzip2 \ + libarchive-tools \ + wget \ + && rm -rf /var/lib/apt/lists/* + +RUN mkdir /binutils /binutils-libs + +RUN cp /usr/bin/*-as /binutils/ \ + && cp /usr/bin/*-objcopy /binutils/ \ + && cp /usr/bin/*-objdump /binutils/ \ + && cp /usr/bin/*-nm /binutils/ + +RUN cp /lib/x86_64-linux-gnu/libbfd*.so* /binutils-libs/ \ + && cp /lib/x86_64-linux-gnu/libctf*.so* /binutils-libs/ \ + && cp /lib/x86_64-linux-gnu/libdebuginfod*.so* /binutils-libs/ \ + && cp /lib/x86_64-linux-gnu/libopcodes*.so* /binutils-libs/ \ + && cp /lib/x86_64-linux-gnu/libsframe*.so* /binutils-libs/ + +WORKDIR /binutils + +# Patched MIPS binutils +RUN ARCHIVE=binutils-mips-ps2-decompals-linux-x86-64.tar.gz; \ + URL=https://github.com/decompals/binutils-mips-ps2-decompals/releases/download/v0.8/$ARCHIVE; \ + SHA256=40f8dda9caae9ec2cd88811d7c4a322d5dd0fa9fe3b65ef6c9280c96b58415ea; \ + wget -O "$ARCHIVE" "$URL" \ + && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ + && tar xvzf "$ARCHIVE" mips-ps2-decompals-as mips-ps2-decompals-nm mips-ps2-decompals-objdump \ + && rm "$ARCHIVE" \ + && chmod +x mips-ps2-decompals-* + +# Patched PowerPC binutils +RUN ARCHIVE=linux-x86_64.zip; \ + URL=https://github.com/encounter/gc-wii-binutils/releases/download/2.42-2/$ARCHIVE; \ + SHA256=64e4bf7aee5c06075d8fa818ebd2a0a3a72e0eeef3896bdec2f198b0e950fe63; \ + wget -O "$ARCHIVE" "$URL" \ + && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ + && bsdtar -xvf "$ARCHIVE" powerpc-eabi-as powerpc-eabi-nm powerpc-eabi-objdump \ + && rm "$ARCHIVE" \ + && chmod +x powerpc-eabi-* + +# Xbox 360 (Xenon) PowerPC binutils +RUN ARCHIVE=linux-x86_64.zip; \ + URL=https://github.com/encounter/xbox360-binutils/releases/download/2.45.1-1/$ARCHIVE; \ + SHA256=33e01b228787ed507ebd08755370d5dac2053c2a37b0647a48c6c19cc528218c; \ + wget -O "$ARCHIVE" "$URL" \ + && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ + && bsdtar -xvf "$ARCHIVE" powerpc-xenon-pe-as powerpc-xenon-pe-nm powerpc-xenon-pe-objdump \ + && rm "$ARCHIVE" \ + && chmod +x powerpc-xenon-pe-* + +# MS-DOS specific +RUN ARCHIVE=omftools.tar.gz; \ + URL=https://github.com/OmniBlade/binutils-gdb/releases/download/omf-build/$ARCHIVE; \ + SHA256=b17dbbd3ee257bb870731df141ca44dde7eedee44d79294ab2acd1bccfed7834; \ + wget -O "$ARCHIVE" "$URL" \ + && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ + && tar xvzf "$ARCHIVE" jwasm \ + && rm "$ARCHIVE" +RUN ARCHIVE=omftools-linux-x86_64.tar.gz; \ + URL=https://github.com/decompals/binutils-omf/releases/download/v0.4/$ARCHIVE; \ + SHA256=d3a2e95e2b6079bb444f4f8f4d2d798954c58b2e7e59fb10523538696133b0c7; \ + wget -O "$ARCHIVE" "$URL" \ + && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ + && tar xvzf "$ARCHIVE" omf-nm omf-objdump \ + && rm "$ARCHIVE" + +# Android binutils +RUN ARCHIVE=android-ndk-r8e-linux-x86_64.tar.bz2; \ + URL=https://github.com/decompme/compilers/releases/download/compilers/$ARCHIVE; \ + SHA256=126dce67022049dca1be063ea0f8a6c07070f9f0b139339fc9e6a8ff617ed7fc; \ + wget -O "$ARCHIVE" "$URL" \ + && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ + && bash -c 'tar xjvf "$0" --strip-components=5 toolchains/x86-4.7/prebuilt/linux-x86_64/bin/i686-linux-android-{as,nm,objdump}' "$ARCHIVE" \ + && rm "$ARCHIVE" + + +FROM python314 AS dependencies + +RUN add-apt-repository -y ppa:dosemu2/ppa \ + && add-apt-repository -y ppa:stsp-0/dj64 \ + && apt-get update \ + && apt-get install -y -o APT::Immediate-Configure=false --no-install-recommends \ + cpp \ + curl \ + dj64 \ + dos2unix \ + dosemu2 \ + g++ \ + iptables \ + libc6-dev-i386 \ + libnl-route-3-200 \ + libprotobuf32t64 \ + libtinfo6 \ + wget \ + && rm -rf /var/lib/apt/lists/* + +# libtinfo5 is required for older Clang releases. +RUN ARCHIVE=libtinfo5_6.3-2ubuntu0.2_amd64.deb; \ + URL=https://security.ubuntu.com/ubuntu/pool/universe/n/ncurses/$ARCHIVE; \ + SHA256=b9bb64e716a7d9de05b1b33992763142ca81bcae3a7f8ce7e29fa3c6fd32f1e8; \ + wget -O "$ARCHIVE" "$URL" \ + && echo "$SHA256 $ARCHIVE" | sha256sum -c - \ + && apt install "./$ARCHIVE" \ + && rm "$ARCHIVE" + +RUN mkdir -p /etc/fonts /sandbox \ + && chown -R ubuntu:ubuntu /sandbox + +COPY --from=nsjail /nsjail/nsjail /bin/nsjail +COPY --from=binutils /binutils/ /usr/bin/ +COPY --from=binutils /binutils-libs/ /usr/lib/x86_64-linux-gnu/ +COPY --from=ghcr.io/decompals/wibo:1.2.0 /usr/local/bin/wibo /usr/bin/ + +USER ubuntu +WORKDIR /cromper + + +FROM dependencies AS dev + +ARG ENABLE_ANDROID_X86_SUPPORT +ARG ENABLE_DREAMCAST_SUPPORT +ARG ENABLE_GBA_SUPPORT +ARG ENABLE_GC_WII_SUPPORT +ARG ENABLE_MACOSX_SUPPORT +ARG ENABLE_MSDOS_SUPPORT +ARG ENABLE_N3DS_SUPPORT +ARG ENABLE_N64_SUPPORT +ARG ENABLE_NDS_ARM9_SUPPORT +ARG ENABLE_PS1_SUPPORT +ARG ENABLE_PS2_SUPPORT +ARG ENABLE_PSP_SUPPORT +ARG ENABLE_SATURN_SUPPORT +ARG ENABLE_SWITCH_SUPPORT +ARG ENABLE_WIIU_SUPPORT +ARG ENABLE_WIN32_SUPPORT +ARG ENABLE_XBOX360_SUPPORT + +ENV ENABLE_ANDROID_X86_SUPPORT=$ENABLE_ANDROID_X86_SUPPORT \ + ENABLE_DREAMCAST_SUPPORT=$ENABLE_DREAMCAST_SUPPORT \ + ENABLE_GBA_SUPPORT=$ENABLE_GBA_SUPPORT \ + ENABLE_GC_WII_SUPPORT=$ENABLE_GC_WII_SUPPORT \ + ENABLE_MACOSX_SUPPORT=$ENABLE_MACOSX_SUPPORT \ + ENABLE_MSDOS_SUPPORT=$ENABLE_MSDOS_SUPPORT \ + ENABLE_N3DS_SUPPORT=$ENABLE_N3DS_SUPPORT \ + ENABLE_N64_SUPPORT=$ENABLE_N64_SUPPORT \ + ENABLE_NDS_ARM9_SUPPORT=$ENABLE_NDS_ARM9_SUPPORT \ + ENABLE_PS1_SUPPORT=$ENABLE_PS1_SUPPORT \ + ENABLE_PS2_SUPPORT=$ENABLE_PS2_SUPPORT \ + ENABLE_PSP_SUPPORT=$ENABLE_PSP_SUPPORT \ + ENABLE_SATURN_SUPPORT=$ENABLE_SATURN_SUPPORT \ + ENABLE_SWITCH_SUPPORT=$ENABLE_SWITCH_SUPPORT \ + ENABLE_WIIU_SUPPORT=$ENABLE_WIIU_SUPPORT \ + ENABLE_WIN32_SUPPORT=$ENABLE_WIN32_SUPPORT \ + ENABLE_XBOX360_SUPPORT=$ENABLE_XBOX360_SUPPORT + +USER root + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +USER ubuntu + +ENTRYPOINT ["/cromper/docker_entrypoint.sh"] + + +FROM python314 AS uv-install + +USER root + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + git \ + && rm -rf /var/lib/apt/lists/* + +USER ubuntu +WORKDIR /cromper + +COPY pyproject.toml uv.lock /cromper/ +COPY cromper/ /cromper/cromper/ + +RUN uv sync --locked --no-dev + + +FROM dependencies AS prod + +COPY --from=uv-install /cromper/.venv /cromper/.venv + +COPY --chown=ubuntu:ubuntu pyproject.toml uv.lock /cromper/ +COPY --chown=ubuntu:ubuntu compilers/ /cromper/compilers/ +COPY --chown=ubuntu:ubuntu libraries/ /cromper/libraries/ +COPY --chown=ubuntu:ubuntu cromper/ /cromper/cromper/ +COPY --chown=ubuntu:ubuntu docker_entrypoint.sh /cromper/ + +ENTRYPOINT ["/cromper/docker_entrypoint.sh"] diff --git a/backend/coreapp/asm_preludes/gba.s b/cromper/asm_preludes/gba.s similarity index 100% rename from backend/coreapp/asm_preludes/gba.s rename to cromper/asm_preludes/gba.s diff --git a/backend/coreapp/asm_preludes/gc_wii.s b/cromper/asm_preludes/gc_wii.s similarity index 100% rename from backend/coreapp/asm_preludes/gc_wii.s rename to cromper/asm_preludes/gc_wii.s diff --git a/backend/coreapp/asm_preludes/irix.s b/cromper/asm_preludes/irix.s similarity index 100% rename from backend/coreapp/asm_preludes/irix.s rename to cromper/asm_preludes/irix.s diff --git a/backend/coreapp/asm_preludes/macosx.s b/cromper/asm_preludes/macosx.s similarity index 100% rename from backend/coreapp/asm_preludes/macosx.s rename to cromper/asm_preludes/macosx.s diff --git a/backend/coreapp/asm_preludes/msdos.s b/cromper/asm_preludes/msdos.s similarity index 100% rename from backend/coreapp/asm_preludes/msdos.s rename to cromper/asm_preludes/msdos.s diff --git a/backend/coreapp/asm_preludes/n64.s b/cromper/asm_preludes/n64.s similarity index 100% rename from backend/coreapp/asm_preludes/n64.s rename to cromper/asm_preludes/n64.s diff --git a/backend/coreapp/asm_preludes/nds_arm9.s b/cromper/asm_preludes/nds_arm9.s similarity index 100% rename from backend/coreapp/asm_preludes/nds_arm9.s rename to cromper/asm_preludes/nds_arm9.s diff --git a/backend/coreapp/asm_preludes/ps1.s b/cromper/asm_preludes/ps1.s similarity index 100% rename from backend/coreapp/asm_preludes/ps1.s rename to cromper/asm_preludes/ps1.s diff --git a/backend/coreapp/asm_preludes/ps2.s b/cromper/asm_preludes/ps2.s similarity index 100% rename from backend/coreapp/asm_preludes/ps2.s rename to cromper/asm_preludes/ps2.s diff --git a/backend/coreapp/asm_preludes/psp.s b/cromper/asm_preludes/psp.s similarity index 100% rename from backend/coreapp/asm_preludes/psp.s rename to cromper/asm_preludes/psp.s diff --git a/backend/coreapp/asm_preludes/saturn.s b/cromper/asm_preludes/saturn.s similarity index 100% rename from backend/coreapp/asm_preludes/saturn.s rename to cromper/asm_preludes/saturn.s diff --git a/backend/compilers/.gitignore b/cromper/compilers/.gitignore similarity index 100% rename from backend/compilers/.gitignore rename to cromper/compilers/.gitignore diff --git a/backend/compilers/compilers.darwin.yaml b/cromper/compilers/compilers.darwin.yaml similarity index 100% rename from backend/compilers/compilers.darwin.yaml rename to cromper/compilers/compilers.darwin.yaml diff --git a/backend/compilers/compilers.linux.yaml b/cromper/compilers/compilers.linux.yaml similarity index 100% rename from backend/compilers/compilers.linux.yaml rename to cromper/compilers/compilers.linux.yaml diff --git a/backend/compilers/download.py b/cromper/compilers/download.py similarity index 100% rename from backend/compilers/download.py rename to cromper/compilers/download.py diff --git a/cromper/conftest.py b/cromper/conftest.py new file mode 100644 index 000000000..dade5a71a --- /dev/null +++ b/cromper/conftest.py @@ -0,0 +1,24 @@ +import os +from pathlib import Path + +from cromper import libraries + +# Load environment variables for tests +from dotenv import load_dotenv + +load_dotenv() + +# Set up compiler base path for tests +compiler_base_path = Path(os.getenv("COMPILER_BASE_PATH", "./compilers")) + +# Set up library base path +library_base_path = Path(os.getenv("LIBRARY_BASE_PATH", "./libraries")) + +# Import cromper modules after environment setup +# Set up library base path +libraries.set_library_base_path(library_base_path) + +collect_ignore = [ + "compilers", + "libraries", +] diff --git a/cromper/cromper/__init__.py b/cromper/cromper/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/coreapp/compilers.py b/cromper/cromper/compilers.py similarity index 95% rename from backend/coreapp/compilers.py rename to cromper/cromper/compilers.py index 36b66befa..574eca692 100644 --- a/backend/coreapp/compilers.py +++ b/cromper/cromper/compilers.py @@ -1,17 +1,11 @@ import enum import logging -import platform as platform_stdlib from collections import OrderedDict from dataclasses import dataclass -from functools import cache from pathlib import Path from typing import ClassVar -from django.conf import settings -from rest_framework.exceptions import ValidationError - -from coreapp import platforms -from coreapp.flags import ( +from .flags import ( COMMON_ARMCC_FLAGS, COMMON_BORLAND_FLAGS, COMMON_CLANG_FLAGS, @@ -34,7 +28,7 @@ Language, LanguageFlagSet, ) -from coreapp.platforms import ( +from .platforms import ( ANDROID_X86, DREAMCAST, GBA, @@ -58,7 +52,35 @@ logger = logging.getLogger(__name__) -CONFIG_PY = "config.py" + +class Compilers: + def __init__(self, base_path: Path) -> None: + self.base_path = base_path + self._available_compilers = OrderedDict( + (c.id, c) for c in _all_compilers if c.available(base_path) + ) + + logger.info( + "Enabled %d compiler(s): %s", + len(self._available_compilers), + ", ".join(self._available_compilers), + ) + + def all_compilers(self) -> list["Compiler"]: + return list(_all_compilers) + + def available_compilers(self) -> list["Compiler"]: + return list(self._available_compilers.values()) + + def is_compiler_available(self, compiler: "Compiler") -> bool: + return compiler.available(self.base_path) + + @staticmethod + def from_id(compiler_id: str) -> "Compiler": + for compiler in _all_compilers: + if compiler.id == compiler_id: + return compiler + raise ValueError(f"Unknown compiler: {compiler_id}") class CompilerType(enum.Enum): @@ -81,19 +103,18 @@ class Compiler: @property def path(self) -> Path: + raise NotImplementedError("Use get_path(base) in the Cromper service") + + def get_path(self, base: Path) -> Path: if self.base_compiler is not None: - return ( - settings.COMPILER_BASE_PATH - / self.base_compiler.platform.id - / self.base_compiler.id - ) - return settings.COMPILER_BASE_PATH / self.platform.id / self.id - - def available(self) -> bool: + return base / self.base_compiler.platform.id / self.base_compiler.id + return base / self.platform.id / self.id + + def available(self, base: Path) -> bool: # consider compiler binaries present if the compiler's directory is found - if not self.path.exists(): - print(f"Compiler {self.id} not found at {self.path}") - return self.path.exists() + if not self.get_path(base).exists(): + print(f"Compiler {self.id} not found at {self.get_path(base)}") + return self.get_path(base).exists() def get_language(self, compiler_flags: str = "") -> Language: language_flag_set = next( @@ -111,23 +132,16 @@ def get_language(self, compiler_flags: str = "") -> Language: if not matches: return self.language - # Taking the longest avoids detecting C++ as C. return max(matches, key=lambda match: len(match[0]))[1] - -@dataclass(frozen=True) -class DummyCompiler(Compiler): - flags: ClassVar[Flags] = [] - library_include_flag: str = "" - - def available(self) -> bool: - return settings.DUMMY_COMPILER - - -@dataclass(frozen=True) -class DummyLongRunningCompiler(DummyCompiler): - def available(self) -> bool: - return settings.DUMMY_COMPILER and platform_stdlib.system() != "Windows" + def to_json(self) -> dict[str, object]: + return { + "id": self.id, + "platform": self.platform.id, + "flags": [f.to_json() for f in self.flags], + "diff_flags": [f.to_json() for f in self.platform.diff_flags], + "language": self.language.value, + } @dataclass(frozen=True) @@ -257,30 +271,6 @@ class MicrosoftCCompiler(Compiler): library_include_flag: str = "" -def from_id(compiler_id: str) -> Compiler: - if compiler_id not in _compilers: - raise ValidationError(f"Unknown compiler: {compiler_id}") - return _compilers[compiler_id] - - -@cache -def available_compilers() -> list[Compiler]: - return list(_compilers.values()) - - -@cache -def available_platforms() -> list[Platform]: - pset = set(compiler.platform for compiler in available_compilers()) - - return sorted(pset, key=lambda p: p.name) - - -DUMMY = DummyCompiler(id="dummy", platform=platforms.DUMMY, cc="") - -DUMMY_LONGRUNNING = DummyLongRunningCompiler( - id="dummy_longrunning", platform=platforms.DUMMY, cc="sleep 3600" -) - # GBA AGBCC = GCCCompiler( id="agbcc", @@ -1700,8 +1690,6 @@ def available_platforms() -> list[Platform]: ) _all_compilers: list[Compiler] = [ - DUMMY, - DUMMY_LONGRUNNING, # GBA AGBCC, OLD_AGBCC, @@ -1957,10 +1945,3 @@ def available_platforms() -> list[Platform]: ANDROID_R8E_443_C, ANDROID_R8E_47_C, ] - -_compilers = OrderedDict({c.id: c for c in _all_compilers if c.available()}) - -logger.info(f"Enabled {len(_compilers)} compiler(s): {', '.join(_compilers.keys())}") -logger.info( - f"Available platform(s): {', '.join([platform.id for platform in available_platforms()])}" -) diff --git a/cromper/cromper/config.py b/cromper/cromper/config.py new file mode 100644 index 000000000..a23b0f520 --- /dev/null +++ b/cromper/cromper/config.py @@ -0,0 +1,53 @@ +import os + +from pathlib import Path + +from cromper import compilers, libraries, platforms + + +class CromperConfig: + """Configuration for the cromper service.""" + + def __init__(self): + # Server settings + self.port = int(os.getenv("CROMPER_PORT", "8888")) + self.debug = os.getenv("CROMPER_DEBUG", "false").lower() == "true" + + # CPU settings + self.num_processes = int(os.getenv("CROMPER_NUM_PROCESSES", "4")) + self.num_threads = int(os.getenv("CROMPER_NUM_THREADS", "8")) + + # Sandbox settings + self.use_sandbox_jail = os.getenv("USE_SANDBOX_JAIL", "false").lower() in ( + "true", + "on", + "1", + ) + self.sandbox_disable_proc = os.getenv( + "SANDBOX_DISABLE_PROC", "false" + ).lower() in ("true", "on", "1") + + # Paths + self.compiler_base_path = Path( + os.getenv("COMPILER_BASE_PATH", "/cromper/compilers") + ) + self.library_base_path = Path( + os.getenv("LIBRARY_BASE_PATH", "/cromper/libraries") + ) + self.sandbox_tmp_path = Path(os.getenv("SANDBOX_TMP_PATH", "/tmp/sandbox")) + self.sandbox_chroot_path = Path( + os.getenv("SANDBOX_CHROOT_PATH", "/tmp/sandbox/root") + ) + self.nsjail_bin_path = Path(os.getenv("SANDBOX_NSJAIL_BIN_PATH", "/bin/nsjail")) + + # Timeouts + self.compilation_timeout_seconds = int( + os.getenv("COMPILATION_TIMEOUT_SECONDS", "10") + ) + self.assembly_timeout_seconds = int(os.getenv("ASSEMBLY_TIMEOUT_SECONDS", "3")) + self.objdump_timeout_seconds = int(os.getenv("OBJDUMP_TIMEOUT_SECONDS", "3")) + + # Set up the compiler and library base paths in the shared modules + self.compilers_instance = compilers.Compilers(self.compiler_base_path) + libraries.set_library_base_path(self.library_base_path) + self.platforms_instance = platforms.Platforms() diff --git a/cromper/cromper/error.py b/cromper/cromper/error.py new file mode 100644 index 000000000..cfe26d243 --- /dev/null +++ b/cromper/cromper/error.py @@ -0,0 +1,52 @@ +import subprocess + + +class CompilationError(Exception): + """Exception raised when compilation fails.""" + + pass + + +class AssemblyError(Exception): + """Exception raised when assembly fails.""" + + @staticmethod + def from_process_error(e: subprocess.CalledProcessError) -> "AssemblyError": + """Create an AssemblyError from a subprocess.CalledProcessError.""" + return AssemblyError(f"Assembly failed: {e.stdout}") + + +class SandboxError(Exception): + """Exception raised when sandbox execution fails.""" + + pass + + +class DiffError(Exception): + """Exception raised when diff generation fails.""" + + pass + + +class NmError(Exception): + """Exception raised when nm command fails.""" + + @staticmethod + def from_process_error(e: subprocess.CalledProcessError) -> "NmError": + """Create an NmError from a subprocess.CalledProcessError.""" + return NmError(f"nm failed: {e.stdout}") + + +class ObjdumpError(Exception): + """Exception raised when objdump command fails.""" + + @staticmethod + def from_process_error(e: subprocess.CalledProcessError) -> "ObjdumpError": + """Create an ObjdumpError from a subprocess.CalledProcessError.""" + return ObjdumpError(f"objdump failed: {e.stdout}") + + +class M2CError(Exception): + """Exception raised when m2c decompilation fails.""" + + pass diff --git a/backend/coreapp/flags.py b/cromper/cromper/flags.py similarity index 100% rename from backend/coreapp/flags.py rename to cromper/cromper/flags.py diff --git a/cromper/cromper/handlers/__init__.py b/cromper/cromper/handlers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cromper/cromper/handlers/assemble.py b/cromper/cromper/handlers/assemble.py new file mode 100644 index 000000000..602998553 --- /dev/null +++ b/cromper/cromper/handlers/assemble.py @@ -0,0 +1,71 @@ +import base64 + +from typing import Any, Dict + +import tornado.web + +from .handlers import BaseHandler +from ..config import CromperConfig +from ..wrappers.compiler_wrapper import CompilerWrapper, AssemblyData +from ..error import AssemblyError + + +def assemble_asm(data: Dict[str, Any], config: CromperConfig) -> Dict[str, Any]: + """Synchronous assembly that runs in process pool.""" + platform_id = data.get("platform_id") + if not platform_id: + raise tornado.web.HTTPError(400, "platform_id is required") + + try: + platform = config.platforms_instance.from_id(platform_id) + except ValueError: + raise tornado.web.HTTPError(400, "invalid platform_id") + + asm_data = data.get("asm_data", "") + asm_hash = data.get("asm_hash", "") + + if not asm_data: + raise tornado.web.HTTPError(400, "asm_data is required") + + # Create assembly data object + asm = AssemblyData(data=asm_data, hash=asm_hash) + + wrapper = CompilerWrapper( + use_sandbox_jail=config.use_sandbox_jail, + assembly_timeout_seconds=config.assembly_timeout_seconds, + sandbox_tmp_path=config.sandbox_tmp_path, + sandbox_chroot_path=config.sandbox_chroot_path, + compiler_base_path=config.compiler_base_path, + library_base_path=config.library_base_path, + nsjail_bin_path=config.nsjail_bin_path, + sandbox_disable_proc=config.sandbox_disable_proc, + debug=config.debug, + ) + + try: + result = wrapper.assemble_asm(platform=platform, asm=asm) + + elf_object_b64 = base64.b64encode(result.elf_object).decode("utf-8") + + return { + "success": True, + "hash": result.hash, + "arch": result.arch, + "elf_object": elf_object_b64, + } + + except AssemblyError as e: + return {"success": False, "error": str(e)} + + +class AssembleHandler(BaseHandler): + """Assembly endpoint.""" + + async def post(self): + """Handle assembly request.""" + data = self.get_json_body() + ioloop = tornado.ioloop.IOLoop.current() + result = await ioloop.run_in_executor( + self.executor, assemble_asm, data, self.config + ) + self.write(result) diff --git a/cromper/cromper/handlers/compile.py b/cromper/cromper/handlers/compile.py new file mode 100644 index 000000000..4efdb2e41 --- /dev/null +++ b/cromper/cromper/handlers/compile.py @@ -0,0 +1,71 @@ +import base64 + +from typing import Any, Dict + +import tornado.web + +from .handlers import BaseHandler +from ..config import CromperConfig +from ..error import CompilationError +from ..libraries import Library +from ..wrappers.compiler_wrapper import CompilerWrapper + + +def compile(data: Dict[str, Any], config: CromperConfig) -> Dict[str, Any]: + """Synchronous compilation that runs in process pool.""" + compiler_id = data.get("compiler_id") + if not compiler_id: + raise tornado.web.HTTPError(400, "compiler_id is required") + + try: + compiler = config.compilers_instance.from_id(compiler_id) + except ValueError: + raise tornado.web.HTTPError(400, "invalid compiler_id") + + code = data.get("code", "") + context = data.get("context", "") + compiler_flags = data.get("compiler_flags", "") + libraries = [Library(**lib) for lib in data.get("libraries", [])] + + wrapper = CompilerWrapper( + use_sandbox_jail=config.use_sandbox_jail, + compilation_timeout_seconds=config.compilation_timeout_seconds, + sandbox_tmp_path=config.sandbox_tmp_path, + sandbox_chroot_path=config.sandbox_chroot_path, + compiler_base_path=config.compiler_base_path, + library_base_path=config.library_base_path, + nsjail_bin_path=config.nsjail_bin_path, + sandbox_disable_proc=config.sandbox_disable_proc, + debug=config.debug, + ) + + try: + result = wrapper.compile_code( + compiler=compiler, + compiler_flags=compiler_flags, + code=code, + context=context, + libraries=libraries, + ) + + elf_object_b64 = base64.b64encode(result.elf_object).decode("utf-8") + + return { + "success": True, + "elf_object": elf_object_b64, + "errors": result.errors, + } + + except CompilationError as e: + return {"success": False, "error": str(e)} + + +class CompileHandler(BaseHandler): + """Compilation endpoint.""" + + async def post(self): + """Handle compilation request.""" + data = self.get_json_body() + ioloop = tornado.ioloop.IOLoop.current() + result = await ioloop.run_in_executor(self.executor, compile, data, self.config) + self.write(result) diff --git a/cromper/cromper/handlers/decompile.py b/cromper/cromper/handlers/decompile.py new file mode 100644 index 000000000..d4533d7ce --- /dev/null +++ b/cromper/cromper/handlers/decompile.py @@ -0,0 +1,69 @@ +from typing import Any, Dict + +import tornado + +from .handlers import BaseHandler +from ..config import CromperConfig +from ..wrappers.decompiler_wrapper import DecompilerWrapper + + +def decompile(data: Dict[str, Any], config: CromperConfig) -> Dict[str, Any]: + """Synchronous decompilation that runs in process pool.""" + platform_id = data.get("platform_id") + compiler_id = data.get("compiler_id") + if not platform_id or not compiler_id: + raise tornado.web.HTTPError(400, "platform_id and compiler_id are required") + + try: + platform = config.platforms_instance.from_id(platform_id) + compiler = config.compilers_instance.from_id(compiler_id) + except ValueError: + raise tornado.web.HTTPError(400, "invalid platform_id or compiler_id") + + default_source_code = data.get("default_source_code", "") + asm = data.get("asm", "") + context = data.get("context", "") + + if not asm: + raise tornado.web.HTTPError(400, "asm is required") + + wrapper = DecompilerWrapper( + use_jail=config.use_sandbox_jail, + sandbox_tmp_path=config.sandbox_tmp_path, + sandbox_chroot_path=config.sandbox_chroot_path, + compiler_base_path=config.compiler_base_path, + library_base_path=config.library_base_path, + nsjail_bin_path=config.nsjail_bin_path, + sandbox_disable_proc=config.sandbox_disable_proc, + debug=config.debug, + ) + + try: + result = wrapper.decompile( + default_source_code=default_source_code, + platform=platform, + asm=asm, + context=context, + compiler=compiler, + ) + + return { + "success": True, + "decompiled_code": result, + } + + except Exception as e: + return {"success": False, "error": str(e)} + + +class DecompileHandler(BaseHandler): + """Decompilation endpoint.""" + + async def post(self): + """Handle decompile request.""" + data = self.get_json_body() + ioloop = tornado.ioloop.IOLoop.current() + result = await ioloop.run_in_executor( + self.executor, decompile, data, self.config + ) + self.write(result) diff --git a/cromper/cromper/handlers/diff.py b/cromper/cromper/handlers/diff.py new file mode 100644 index 000000000..941ffb839 --- /dev/null +++ b/cromper/cromper/handlers/diff.py @@ -0,0 +1,84 @@ +import base64 + +from typing import Any, Dict + +import tornado.web + +from .handlers import BaseHandler +from ..config import CromperConfig +from ..wrappers.diff_wrapper import DiffWrapper + + +def generate_diff(data: Dict[str, Any], config: CromperConfig) -> Dict[str, Any]: + """Synchronous diff generation that runs in process pool.""" + platform_id = data.get("platform_id") + if not platform_id: + raise tornado.web.HTTPError(400, "platform_id is required") + + try: + platform = config.platforms_instance.from_id(platform_id) + except ValueError: + raise tornado.web.HTTPError(400, "invalid platform_id") + + target_elf = data.get("target_elf") + compiled_elf = data.get("compiled_elf") + diff_label = data.get("diff_label", "") + diff_flags = data.get("diff_flags", []) + + if target_elf is None or compiled_elf is None: + raise tornado.web.HTTPError(400, "target_elf and compiled_elf are required") + + try: + target_elf = base64.b64decode(target_elf) + except Exception as e: + raise tornado.web.HTTPError(400, f"Invalid base64 target_elf: {e}") + + try: + compiled_elf = base64.b64decode(compiled_elf) + except Exception as e: + raise tornado.web.HTTPError(400, f"Invalid base64 compiled_elf: {e}") + + # Create assembly data object + + wrapper = DiffWrapper( + objdump_timeout_seconds=config.objdump_timeout_seconds, + use_jail=config.use_sandbox_jail, + sandbox_tmp_path=config.sandbox_tmp_path, + sandbox_chroot_path=config.sandbox_chroot_path, + compiler_base_path=config.compiler_base_path, + library_base_path=config.library_base_path, + nsjail_bin_path=config.nsjail_bin_path, + sandbox_disable_proc=config.sandbox_disable_proc, + debug=config.debug, + ) + + try: + result = wrapper.diff( + target_elf=target_elf, + platform=platform, + diff_label=diff_label, + compiled_elf=compiled_elf, + diff_flags=diff_flags, + ) + + return { + "success": True, + "result": result.result, + "errors": result.errors, + } + + except Exception as e: + return {"success": False, "error": str(e)} + + +class DiffHandler(BaseHandler): + """Diff generation endpoint.""" + + async def post(self): + """Handle diff request.""" + data = self.get_json_body() + ioloop = tornado.ioloop.IOLoop.current() + result = await ioloop.run_in_executor( + self.executor, generate_diff, data, self.config + ) + self.write(result) diff --git a/cromper/cromper/handlers/handlers.py b/cromper/cromper/handlers/handlers.py new file mode 100644 index 000000000..75b8977d3 --- /dev/null +++ b/cromper/cromper/handlers/handlers.py @@ -0,0 +1,152 @@ +import json +import traceback +from typing import Any, Dict + +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor + +import tornado.web + +from ..config import CromperConfig +from cromper import libraries + + +class BaseHandler(tornado.web.RequestHandler): + """Base handler with common functionality.""" + + def initialize( + self, config: CromperConfig, executor: ProcessPoolExecutor | ThreadPoolExecutor + ): + self.config = config + self.executor = executor + + def set_default_headers(self): + self.set_header("Content-Type", "application/json") + self.set_header("Access-Control-Allow-Origin", "*") + self.set_header("Access-Control-Allow-Headers", "Content-Type") + self.set_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + + def options(self): + """Handle preflight requests.""" + self.set_status(204) + self.finish() + + def write_error(self, status_code: int, **kwargs): + """Custom error response.""" + error_message = "Internal server error" + if "exc_info" in kwargs: + exc_info = kwargs["exc_info"] + if exc_info[1]: + error_message = str(exc_info[1]) + if self.settings.get("debug"): + # Include traceback in debug mode + error_message += "\n" + "".join( + traceback.format_exception(*exc_info) + ) + self.set_status(status_code) + self.write({"error": error_message}) + + def get_json_body(self) -> Dict[str, Any]: + """Parse JSON body.""" + try: + return json.loads(self.request.body) + except json.JSONDecodeError as e: + raise tornado.web.HTTPError(400, f"Invalid JSON: {e}") + + +class HealthHandler(BaseHandler): + """Health check endpoint.""" + + def get(self): + self.write({"status": "healthy", "service": "cromper"}) + + +class PlatformHandler(BaseHandler): + """Platforms information endpoint.""" + + def get(self, id=None): + """ + /platform + returns a dictionary of available platforms keyed on Plaform.id + /platform/ + returns a dictionary containing a single Platform + """ + compilers_instance = self.config.compilers_instance + available_platforms = self.config.platforms_instance.available_platforms() + if id is not None: + if id in available_platforms: + return self.write( + available_platforms[id].to_json( + compilers=compilers_instance, + include_compilers=True, + ) + ) + + self.set_status(404) + return self.write({"error": "Unknown platform"}) + + platforms_data = { + p.id: p.to_json(compilers=compilers_instance, include_compilers=True) + for p in sorted(available_platforms.values(), key=lambda x: x.id) + } + self.write(platforms_data) + + +class CompilerHandler(BaseHandler): + """Compilers information endpoint.""" + + def get(self, platform_id=None, compiler_id=None): + """ + /compiler (used by decomp-permuter) + returns a dictionary of available compilers keyed on Compiler.id + /compiler/ + returns a dictionary of available compilers keyed on Compiler.id for the target Platform ID + /compiler// + returns a dictionary containing the single Compiler with id compiler_id + + """ + available_compilers = self.config.compilers_instance.available_compilers() + + compilers_data = { + c.id: c.to_json() + for c in sorted(available_compilers, key=lambda x: x.id) + if (platform_id is None or c.platform.id == platform_id) + and (compiler_id is None or c.id == compiler_id) + } + if len(compilers_data) == 0: + self.set_status(404) + return self.write({"error": "Unknown platform/compiler"}) + + if platform_id or compiler_id: + return self.write(compilers_data) + + # TODO: Remove the 'compilers' key one day + return self.write({"compilers": compilers_data}) + + +class LibrariesHandler(BaseHandler): + """Libraries information endpoint.""" + + def get(self): + """Get all available libraries, optionally filtered by platform.""" + platform = self.get_query_argument("platform", default="") + + if platform: + libraries_data = [ + { + "name": lib.name, + "supported_versions": lib.supported_versions, + "platform": lib.platform, + } + for lib in libraries.libraries_for_platform(platform) + ] + else: + libraries_data = [ + { + "name": lib.name, + "supported_versions": lib.supported_versions, + "platform": lib.platform, + } + for lib in libraries.available_libraries() + ] + + self.write({"libraries": libraries_data}) diff --git a/backend/coreapp/libraries.py b/cromper/cromper/libraries.py similarity index 52% rename from backend/coreapp/libraries.py rename to cromper/cromper/libraries.py index d60e7ff4f..ac49f1a2e 100644 --- a/backend/coreapp/libraries.py +++ b/cromper/cromper/libraries.py @@ -1,14 +1,19 @@ +import logging from dataclasses import dataclass -from functools import cache from pathlib import Path -from typing import TYPE_CHECKING +from typing import List -from django.conf import settings +logger = logging.getLogger(__name__) -if TYPE_CHECKING: - LIBRARY_BASE_PATH: Path -else: - LIBRARY_BASE_PATH: Path = settings.LIBRARY_BASE_PATH +# Global library base path - will be set by main.py config +LIBRARY_BASE_PATH: Path = Path("/opt/libraries") + + +def set_library_base_path(path: Path) -> None: + """Set the library base path.""" + global LIBRARY_BASE_PATH + LIBRARY_BASE_PATH = path + logger.info(f"Library base path set to: {LIBRARY_BASE_PATH}") @dataclass(frozen=True) @@ -22,14 +27,16 @@ def get_include_path(self, platform: str) -> Path: def available(self, platform: str) -> bool: include_path = self.get_include_path(platform) if not include_path.exists(): - print(f"Library {self.name} {self.version} not found at {include_path}") + logger.debug( + f"Library {self.name} {self.version} not found at {include_path}" + ) return include_path.exists() @dataclass(frozen=True) class LibraryVersions: name: str - supported_versions: list[str] + supported_versions: List[str] platform: str @property @@ -37,9 +44,13 @@ def path(self) -> Path: return LIBRARY_BASE_PATH / self.platform / self.name -@cache -def available_libraries() -> list[LibraryVersions]: - results = [] +def available_libraries() -> List[LibraryVersions]: + """Get all available libraries across all platforms.""" + results: List[LibraryVersions] = [] + + if not LIBRARY_BASE_PATH.exists(): + logger.warning(f"Library base path does not exist: {LIBRARY_BASE_PATH}") + return results for platform_dir in LIBRARY_BASE_PATH.iterdir(): if not platform_dir.is_dir(): @@ -60,9 +71,17 @@ def available_libraries() -> list[LibraryVersions]: results.append( LibraryVersions( name=lib_dir.name, - supported_versions=versions, + supported_versions=sorted( + versions + ), # Sort versions for consistency platform=platform_dir.name, ) ) + logger.info(f"Found {len(results)} library collections") return results + + +def libraries_for_platform(platform: str) -> List[LibraryVersions]: + """Get available libraries for a specific platform.""" + return [lib for lib in available_libraries() if lib.platform == platform] diff --git a/cromper/cromper/main.py b/cromper/cromper/main.py new file mode 100644 index 000000000..c8dfa7c4d --- /dev/null +++ b/cromper/cromper/main.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 + +import asyncio +import logging + +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor + +# Load environment variables from .env file before importing other modules +from dotenv import load_dotenv + +import tornado.web + +from .handlers.assemble import AssembleHandler +from .handlers.compile import CompileHandler +from .handlers.decompile import DecompileHandler +from .handlers.diff import DiffHandler + +from .handlers.handlers import ( + CompilerHandler, + LibrariesHandler, + PlatformHandler, + HealthHandler, +) + +from .config import CromperConfig + + +def make_app(config: CromperConfig) -> tornado.web.Application: + """Create and configure the Tornado application.""" + + process_executor = ProcessPoolExecutor(max_workers=config.num_processes) + thread_executor = ThreadPoolExecutor(max_workers=config.num_processes) + + return tornado.web.Application( + [ + (r"/health", HealthHandler, dict(config=config, executor=thread_executor)), + ( + r"/platform(?:/([^/]+))?", + PlatformHandler, + dict(config=config, executor=thread_executor), + ), + ( + r"/compiler(?:/([^/]+))?(?:/([^/]+))?", + CompilerHandler, + dict(config=config, executor=thread_executor), + ), + ( + r"/library", + LibrariesHandler, + dict(config=config, executor=thread_executor), + ), + # cpu-bound handlers + ( + r"/compile", + CompileHandler, + dict(config=config, executor=process_executor), + ), + ( + r"/assemble", + AssembleHandler, + dict(config=config, executor=process_executor), + ), + (r"/diff", DiffHandler, dict(config=config, executor=process_executor)), + ( + r"/decompile", + DecompileHandler, + dict(config=config, executor=process_executor), + ), + ], + debug=config.debug, + ) + + +def main(): + """Main application entry point.""" + + load_dotenv() + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + logger = logging.getLogger(__name__) + + config = CromperConfig() + + logger.info(f"Starting cromper service on port {config.port}") + logger.info(f"Debug mode: {config.debug}") + logger.info(f"Use sandbox jail: {config.use_sandbox_jail}") + logger.info(f"Compiler base path: {config.compiler_base_path}") + + app = make_app(config) + app.listen(config.port) + + logger.info("cromper service started successfully") + + ioloop = tornado.ioloop.IOLoop.current() + ioloop.start() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/coreapp/platforms.py b/cromper/cromper/platforms.py similarity index 89% rename from backend/coreapp/platforms.py rename to cromper/cromper/platforms.py index 592674417..c3ebbf868 100644 --- a/backend/coreapp/platforms.py +++ b/cromper/cromper/platforms.py @@ -3,18 +3,17 @@ from collections import OrderedDict from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any -from rest_framework.exceptions import ValidationError +if TYPE_CHECKING: + from .compilers import Compilers -from coreapp import compilers -from coreapp.flags import ( +from .flags import ( COMMON_DIFF_FLAGS, COMMON_MIPS_DIFF_FLAGS, COMMON_MSDOS_DIFF_FLAGS, Flags, ) -from coreapp.models.scratch import Scratch logger = logging.getLogger(__name__) @@ -35,18 +34,17 @@ class Platform: @property @functools.lru_cache def asm_prelude(self) -> str: - asm_prelude_path: Path = Path(__file__).parent / "asm_preludes" / f"{self.id}.s" + asm_prelude_path: Path = ( + Path(__file__).parent.parent / "asm_preludes" / f"{self.id}.s" + ) if asm_prelude_path.is_file(): return asm_prelude_path.read_text() return "" - def get_num_scratches(self) -> int: - return Scratch.objects.filter(platform=self.id).count() - def to_json( self, + compilers: "Compilers", include_compilers: bool = False, - include_num_scratches: bool = False, ) -> dict[str, Any]: ret: dict[str, Any] = { "id": self.id, @@ -61,26 +59,22 @@ def to_json( for x in compilers.available_compilers() if x.platform.id == self.id ] - if include_num_scratches: - ret["num_scratches"] = self.get_num_scratches() return ret -def from_id(platform_id: str) -> Platform: - if platform_id not in _platforms: - raise ValidationError(f"Unknown platform: {platform_id}") - return _platforms[platform_id] +class Platforms: + def __init__(self) -> None: + pass + def available_platforms(self): + return _platforms + + @staticmethod + def from_id(platform_id: str) -> Platform: + if platform_id not in _platforms: + raise ValueError(f"Unknown platform: {platform_id}") + return _platforms[platform_id] -DUMMY = Platform( - id="dummy", - name="Dummy System", - description="DMY", - arch="dummy", - assemble_cmd='echo "assembled("$INPUT")" > "$OUTPUT"', - objdump_cmd="echo", - nm_cmd="echo", -) MSDOS = Platform( id="msdos", @@ -285,7 +279,6 @@ def from_id(platform_id: str) -> Platform: _platforms: OrderedDict[str, Platform] = OrderedDict( { - DUMMY.id: DUMMY, IRIX.id: IRIX, N64.id: N64, GC_WII.id: GC_WII, diff --git a/backend/coreapp/sandbox.py b/cromper/cromper/sandbox.py similarity index 69% rename from backend/coreapp/sandbox.py rename to cromper/cromper/sandbox.py index d806d3291..7b3cf201f 100644 --- a/backend/coreapp/sandbox.py +++ b/cromper/cromper/sandbox.py @@ -7,29 +7,41 @@ from tempfile import TemporaryDirectory from typing import Any, Self -from django.conf import settings - -from coreapp.error import SandboxError - logger = logging.getLogger(__name__) -PATH: str -if settings.USE_SANDBOX_JAIL: - PATH = "/bin:/usr/bin" -else: - PATH = os.environ["PATH"] + +class SandboxError(Exception): + """Sandbox execution error.""" class Sandbox(contextlib.AbstractContextManager["Sandbox"]): - def __enter__(self) -> Self: - self.use_jail = settings.USE_SANDBOX_JAIL + def __init__( + self, + use_jail: bool = False, + sandbox_tmp_path: Path | None = None, + sandbox_chroot_path: Path | None = None, + compiler_base_path: Path | None = None, + library_base_path: Path | None = None, + nsjail_bin_path: Path | None = None, + sandbox_disable_proc: bool = False, + debug: bool = True, + ): + self.use_jail = use_jail + self.sandbox_tmp_path = sandbox_tmp_path or Path("/tmp/sandbox") + self.sandbox_chroot_path = sandbox_chroot_path or Path("/tmp/sandbox/root") + self.compiler_base_path = compiler_base_path or Path("compilers") + self.library_base_path = library_base_path or Path("libraries") + self.nsjail_bin_path = nsjail_bin_path or Path("/bin/nsjail") + self.sandbox_disable_proc = sandbox_disable_proc + self.debug = debug - tmpdir: Path | None = None + def __enter__(self) -> Self: + tmpdir: str | None = None if self.use_jail: - # Only use SANDBOX_TMP_PATH if USE_SANDBOX_JAIL is enabled, + # Only use sandbox_tmp_path if USE_SANDBOX_JAIL is enabled, # otherwise use the system default - tmpdir = settings.SANDBOX_TMP_PATH - tmpdir.mkdir(parents=True, exist_ok=True) + self.sandbox_tmp_path.mkdir(parents=True, exist_ok=True) + tmpdir = str(self.sandbox_tmp_path) self.temp_dir = TemporaryDirectory(dir=tmpdir, ignore_cleanup_errors=True) self.path = Path(self.temp_dir.name) @@ -51,15 +63,17 @@ def sandbox_command(self, mounts: list[Path], env: dict[str, str]) -> list[str]: if not self.use_jail: return [] - settings.SANDBOX_CHROOT_PATH.mkdir(parents=True, exist_ok=True) + self.sandbox_chroot_path.mkdir(parents=True, exist_ok=True) assert ":" not in str(self.path) # fmt: off wrapper = [ - str(settings.SANDBOX_NSJAIL_BIN_PATH), + str(self.nsjail_bin_path), "--mode", "o", - "--chroot", str(settings.SANDBOX_CHROOT_PATH), + "--chroot", str(self.sandbox_chroot_path), + # Docker user namespaces cannot remount the jail root read-only. + "--rw", "--bindmount", f"{self.path}:/tmp", "--bindmount", f"{self.path}:/run/user/{os.getuid()}", "--bindmount", f"{self.path}:/var/tmp", @@ -74,9 +88,9 @@ def sandbox_command(self, mounts: list[Path], env: dict[str, str]) -> list[str]: "--bindmount_ro", "/usr", "--bindmount_ro", "/proc", "--bindmount_ro", "/sys", - "--bindmount_ro", str(settings.COMPILER_BASE_PATH), - "--bindmount_ro", str(settings.LIBRARY_BASE_PATH), - "--env", f"PATH={PATH}", + "--bindmount_ro", str(self.compiler_base_path), + "--bindmount_ro", str(self.library_base_path), + "--env", "PATH=/usr/bin:/bin", "--cwd", "/tmp", # NOTE: "soft" resolves to a near-infinite RLIMIT_FSIZE in nsjail >=3.6, # which causes some of the compilers/tooling to fail with "File too large" (SIGXFSZ). @@ -85,10 +99,10 @@ def sandbox_command(self, mounts: list[Path], env: dict[str, str]) -> list[str]: "--rlimit_nofile", "soft", ] # fmt: on - if settings.SANDBOX_DISABLE_PROC: + if self.sandbox_disable_proc: wrapper.append("--disable_proc") # needed for running inside Docker - if not settings.DEBUG: + if not self.debug: wrapper.append("--really_quiet") for mount in mounts: wrapper.extend(["--bindmount_ro", str(mount)]) @@ -111,6 +125,9 @@ def run_subprocess( env = env if env is not None else {} timeout = None if timeout == 0 else timeout + print("Running ", args) + print("With env:", env) + try: wrapper = self.sandbox_command(mounts, env) except Exception as e: diff --git a/cromper/cromper/util.py b/cromper/cromper/util.py new file mode 100644 index 000000000..8162ffc98 --- /dev/null +++ b/cromper/cromper/util.py @@ -0,0 +1,13 @@ +import hashlib +import random +import string +from typing import Any + + +def gen_hash(*args: Any) -> str: + """Generate a hash from the given arguments.""" + return hashlib.sha256("".join(str(arg) for arg in args).encode()).hexdigest() + + +def random_string(size=6, chars=string.ascii_lowercase + string.digits): + return "".join(random.choice(chars) for _ in range(size)) diff --git a/cromper/cromper/wine/README.md b/cromper/cromper/wine/README.md new file mode 100644 index 000000000..66bb5d4ba --- /dev/null +++ b/cromper/cromper/wine/README.md @@ -0,0 +1 @@ +This directory contains registry hacks required to get compilers to behave under Wine. diff --git a/cromper/cromper/wine/msvc80.reg b/cromper/cromper/wine/msvc80.reg new file mode 100644 index 000000000..fdde5786c --- /dev/null +++ b/cromper/cromper/wine/msvc80.reg @@ -0,0 +1,11 @@ +Windows Registry Editor Version 5.00 + + +[HKEY_LOCAL_MACHINE\Software\Microsoft\Cryptography\Defaults\Provider Types\Type 001] +"Name"="Microsoft Strong Cryptographic Provider" +"TypeName"="RSA Full (Signature and Key Exchange)" + +[HKEY_LOCAL_MACHINE\Software\Microsoft\Cryptography\Defaults\Provider\Microsoft Strong Cryptographic Provider] +"Image Path"="rsaenh.dll" +"SigInFile"=dword:0 +"Type"=dword:1 diff --git a/cromper/cromper/wrappers/__init__.py b/cromper/cromper/wrappers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/coreapp/compiler_wrapper.py b/cromper/cromper/wrappers/compiler_wrapper.py similarity index 73% rename from backend/coreapp/compiler_wrapper.py rename to cromper/cromper/wrappers/compiler_wrapper.py index a3df5e224..627d47c73 100644 --- a/backend/coreapp/compiler_wrapper.py +++ b/cromper/cromper/wrappers/compiler_wrapper.py @@ -2,51 +2,60 @@ import re import subprocess import time -from collections.abc import Callable, Sequence -from typing import ( - TYPE_CHECKING, - TypeVar, -) +from dataclasses import dataclass -from django.conf import settings +from cromper import util -import coreapp.util as util -from coreapp import compilers, platforms -from coreapp.compilers import Compiler, CompilerType -from coreapp.flags import Language -from coreapp.platforms import Platform +from ..compilers import Compiler, CompilerType +from ..error import AssemblyError, CompilationError +from ..flags import Language +from ..libraries import Library +from ..platforms import Platform +from ..sandbox import Sandbox -from .error import AssemblyError, CompilationError -from .libraries import Library -from .models.scratch import Asm, Assembly -from .sandbox import Sandbox -from .wrapper_result import CompilationResult +logger = logging.getLogger(__name__) -# Thanks to Guido van Rossum for the following fix -# https://github.com/python/mypy/issues/5107#issuecomment-529372406 -if TYPE_CHECKING: - F = TypeVar("F") - def lru_cache(maxsize: int = 128, typed: bool = False) -> Callable[[F], F]: - pass +@dataclass +class CompilationResult: + elf_object: bytes + errors: str -else: - from functools import lru_cache -logger = logging.getLogger(__name__) +@dataclass +class AssemblyData: + """Simplified representation of assembly data.""" + + data: str + hash: str + +@dataclass +class AssemblyResult: + """Result of assembly operation.""" -def _check_assembly_cache(*args: str) -> tuple[Assembly | None, str]: - hash = util.gen_hash(args) - return Assembly.objects.filter(hash=hash).first(), hash + hash: str + arch: str + elf_object: bytes class CompilerWrapper: + def __init__( + self, + use_sandbox_jail: bool = False, + compilation_timeout_seconds: int = 10, + assembly_timeout_seconds: int = 3, + **sandbox_kwargs, + ): + self.use_sandbox_jail = use_sandbox_jail + self.compilation_timeout_seconds = compilation_timeout_seconds + self.assembly_timeout_seconds = assembly_timeout_seconds + self.sandbox_kwargs = sandbox_kwargs + @staticmethod def filter_compiler_flags(compiler_flags: str) -> str: # Remove irrelevant flags that are part of the base compiler configs or # don't affect matching, but clutter the compiler settings field. - # TODO: use cfg for this? skip_flags_with_args = { "-B", "-I", @@ -89,23 +98,18 @@ def filter_compile_errors(input: str) -> str: return input.strip() - @staticmethod - @lru_cache(maxsize=settings.COMPILATION_CACHE_SIZE) def compile_code( + self, compiler: Compiler, compiler_flags: str, code: str, context: str, - function: str = "", - libraries: Sequence[Library] = (), + libraries: list[Library] = [], # Library type would be defined separately ) -> CompilationResult: - if compiler == compilers.DUMMY: - return CompilationResult(f"compiled({context}\n{code}".encode(), "") - code = code.replace("\r\n", "\n") context = context.replace("\r\n", "\n") - with Sandbox() as sandbox: + with Sandbox(use_jail=self.use_sandbox_jail, **self.sandbox_kwargs) as sandbox: ext = compiler.get_language(compiler_flags).get_file_extension() code_file = f"code.{ext}" src_file = f"src.{ext}" @@ -149,8 +153,8 @@ def compile_code( if compiler.type == CompilerType.IDO and "-KPIC" in compiler_flags: cc_cmd = cc_cmd.replace("-non_shared", "") - if compiler.platform != platforms.DUMMY and not compiler.path.exists(): - raise CompilationError(f"Compiler {compiler.id} is not installed") + # Generate a function label when the request does not provide one. + fname = util.random_string() # Run compiler try: @@ -160,11 +164,13 @@ def compile_code( + str(lib.get_include_path(compiler.platform.id)) for lib in libraries ) - wibo_path = settings.COMPILER_BASE_PATH / "common" / "wibo_dlls" + wibo_path = ( + self.sandbox_kwargs["compiler_base_path"] / "common" / "wibo_dlls" + ) compile_proc = sandbox.run_subprocess( cc_cmd, mounts=( - [compiler.path] if compiler.platform != platforms.DUMMY else [] + [compiler.get_path(self.sandbox_kwargs["compiler_base_path"])] ), shell=True, env={ @@ -172,15 +178,17 @@ def compile_code( "WIBO_PATH": sandbox.rewrite_path(wibo_path), "INPUT": sandbox.rewrite_path(code_path), "OUTPUT": sandbox.rewrite_path(object_path), - "COMPILER_DIR": sandbox.rewrite_path(compiler.path), + "COMPILER_DIR": sandbox.rewrite_path( + compiler.get_path(self.sandbox_kwargs["compiler_base_path"]) + ), "COMPILER_FLAGS": sandbox.quote_options( compiler_flags + " " + libraries_compiler_flags ), - "FUNCTION": function, + "FUNCTION": fname, "MWCIncludes": "/tmp", "TMPDIR": "/tmp", }, - timeout=settings.COMPILATION_TIMEOUT_SECONDS, + timeout=self.compilation_timeout_seconds, ) et = round(time.time() * 1000) logger.debug(f"Compilation finished in: {et - st} ms") @@ -213,28 +221,13 @@ def compile_code( return CompilationResult(object_bytes, compile_errors) - @staticmethod - def assemble_asm(platform: Platform, asm: Asm) -> Assembly: + def assemble_asm(self, platform: Platform, asm: AssemblyData) -> AssemblyResult: if not platform.assemble_cmd: raise AssemblyError( f"Assemble command for platform {platform.id} not found" ) - cached_assembly, hash = _check_assembly_cache(platform.id, asm.hash) - if cached_assembly: - logger.debug(f"Assembly cache hit! hash: {hash}") - return cached_assembly - - if platform == platforms.DUMMY: - assembly = Assembly( - hash=hash, - arch=platform.arch, - source_asm=asm, - ) - assembly.save() - return assembly - - with Sandbox() as sandbox: + with Sandbox(use_jail=self.use_sandbox_jail, **self.sandbox_kwargs) as sandbox: asm_prelude_path = sandbox.path / "prelude.s" asm_prelude_path.write_text(platform.asm_prelude) @@ -254,8 +247,11 @@ def assemble_asm(platform: Platform, asm: Asm) -> Assembly: "PRELUDE": sandbox.rewrite_path(asm_prelude_path), "INPUT": sandbox.rewrite_path(asm_path), "OUTPUT": sandbox.rewrite_path(object_path), + "COMPILER_BASE_PATH": sandbox.rewrite_path( + sandbox.compiler_base_path + ), }, - timeout=settings.ASSEMBLY_TIMEOUT_SECONDS, + timeout=self.assembly_timeout_seconds, ) except subprocess.CalledProcessError as e: raise AssemblyError.from_process_error(e) @@ -271,11 +267,8 @@ def assemble_asm(platform: Platform, asm: Asm) -> Assembly: if not object_path.exists(): raise AssemblyError("Assembler did not create an object file") - assembly = Assembly( - hash=hash, + return AssemblyResult( + hash=asm.hash, arch=platform.arch, - source_asm=asm, elf_object=object_path.read_bytes(), ) - assembly.save() - return assembly diff --git a/cromper/cromper/wrappers/decompiler_wrapper.py b/cromper/cromper/wrappers/decompiler_wrapper.py new file mode 100644 index 000000000..26621dc30 --- /dev/null +++ b/cromper/cromper/wrappers/decompiler_wrapper.py @@ -0,0 +1,50 @@ +import logging + +from .m2c_wrapper import M2CError, M2CWrapper +from ..compilers import Compiler +from ..platforms import Platform + +logger = logging.getLogger(__name__) + +MAX_M2C_ASM_LINES = 15000 + +DECOMP_WITH_CONTEXT_FAILED_PREAMBLE = "/* Decompilation with context failed; here's the decompilation without context: */\n" + + +class DecompilerWrapper: + def __init__(self, **sandbox_kwargs): + self.m2c_wrapper = M2CWrapper(**sandbox_kwargs) + + def decompile( + self, + default_source_code: str, + platform: Platform, + asm: str, + context: str, + compiler: Compiler, + ) -> str: + if not M2CWrapper.is_platform_supported(platform.id): + return f"/* No decompiler yet implemented for {platform.arch} */\n{default_source_code}" + + ret = default_source_code + if len(asm.splitlines()) > MAX_M2C_ASM_LINES: + return "/* Too many lines to decompile; please run m2c manually */" + + try: + ret = self.m2c_wrapper.decompile(asm, context, platform.id, compiler) + except M2CError: + # Attempt to decompile the source without context as a last-ditch effort + try: + ret = self.m2c_wrapper.decompile(asm, context, platform.id, compiler) + except M2CError as e: + # Attempt to decompile the source without context as a last-ditch effort + try: + ret = self.m2c_wrapper.decompile(asm, "", platform.id, compiler) + ret = f"{e}\n{DECOMP_WITH_CONTEXT_FAILED_PREAMBLE}\n{ret}" + except M2CError as e: + ret = f"{e}\n{default_source_code}" + except Exception: + logger.exception("Error running m2c") + ret = f"/* Internal error while running m2c */\n{default_source_code}" + + return ret diff --git a/backend/coreapp/diff_wrapper.py b/cromper/cromper/wrappers/diff_wrapper.py similarity index 87% rename from backend/coreapp/diff_wrapper.py rename to cromper/cromper/wrappers/diff_wrapper.py index fc873efb1..b3eb4ef1c 100644 --- a/backend/coreapp/diff_wrapper.py +++ b/cromper/cromper/wrappers/diff_wrapper.py @@ -2,36 +2,22 @@ import re import shlex import subprocess -from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any, TypeVar +from functools import lru_cache +from typing import Any import diff as asm_differ -from django.conf import settings -from coreapp.flags import ASMDIFF_FLAG_PREFIX -from coreapp.platforms import DUMMY, Platform - -from .error import AssemblyError, DiffError, NmError, ObjdumpError -from .models.scratch import Assembly -from .sandbox import Sandbox -from .wrapper_result import DiffResult +from ..error import AssemblyError, DiffError, NmError, ObjdumpError +from ..flags import ASMDIFF_FLAG_PREFIX +from ..platforms import Platform +from ..sandbox import Sandbox logger = logging.getLogger(__name__) MAX_FUNC_SIZE_LINES = 25000 -if TYPE_CHECKING: - F = TypeVar("F") - - def lru_cache(maxsize: int = 128, typed: bool = False) -> Callable[[F], F]: - pass - -else: - from functools import lru_cache - - PARAM_FLAGS = { "--adjust-vma": re.compile(r"^(0x[0-9a-fA-F]+|\d+)$"), "-Mreg-names": re.compile(r"^([a-zA-Z0-9]+)$"), @@ -52,11 +38,27 @@ def parse_flag(flag: str) -> ParsedFlag: return ParsedFlag(name=flag) +DEFAULT_OBJDUMP_TIMEOUT_SECONDS = 3 + + +@dataclass +class DiffResult: + result: dict[str, Any] | None = None + errors: str | None = None + + class DiffWrapper: + def __init__( + self, + objdump_timeout_seconds: int = DEFAULT_OBJDUMP_TIMEOUT_SECONDS, + **sandbox_kwargs, + ): + self.objdump_timeout_seconds = objdump_timeout_seconds + self.sandbox_kwargs = sandbox_kwargs + @staticmethod def filter_objdump_flags(compiler_flags: str) -> str: # Remove irrelevant flags that are part of the base objdump configs, but clutter the compiler settings field. - # TODO: use cfg for this? skip_flags_with_args: set[str] = set() skip_flags = { "--disassemble", @@ -120,9 +122,8 @@ def create_config( ref_file=None, ) - @staticmethod def get_objdump_target_function_flags( - sandbox: Sandbox, target_path: Path, platform: Platform, label: str + self, sandbox: Sandbox, target_path: Path, platform: Platform, label: str ) -> list[str]: if not label: return ["--start-address=0"] @@ -137,7 +138,7 @@ def get_objdump_target_function_flags( nm_proc = sandbox.run_subprocess( [platform.nm_cmd] + [sandbox.rewrite_path(target_path)], shell=True, - timeout=settings.OBJDUMP_TIMEOUT_SECONDS, + timeout=self.objdump_timeout_seconds, ) except subprocess.TimeoutExpired: raise NmError("Timeout expired") @@ -180,9 +181,9 @@ def parse_objdump_flags(diff_flags: list[str]) -> list[str]: return ret - @lru_cache(maxsize=settings.OBJDUMP_CACHE_SIZE) - @staticmethod + @lru_cache(maxsize=100) def run_objdump( + self, target_data: bytes, platform: Platform, arch_flags: tuple[str, ...], @@ -201,7 +202,7 @@ def run_objdump( if platform.id != "msdos": flags += ["--reloc"] - with Sandbox() as sandbox: + with Sandbox(**self.sandbox_kwargs) as sandbox: target_path = sandbox.path / "out.s" target_path.write_bytes(target_data) @@ -213,7 +214,7 @@ def run_objdump( has_symbol = True if not has_symbol: flags.append("--disassemble") - flags += DiffWrapper.get_objdump_target_function_flags( + flags += self.get_objdump_target_function_flags( sandbox, target_path, platform, label ) @@ -226,7 +227,7 @@ def run_objdump( + list(map(shlex.quote, flags)) + [sandbox.rewrite_path(target_path)], shell=True, - timeout=settings.OBJDUMP_TIMEOUT_SECONDS, + timeout=self.objdump_timeout_seconds, ) except subprocess.TimeoutExpired: raise ObjdumpError("Timeout expired") @@ -238,8 +239,8 @@ def run_objdump( out = objdump_proc.stdout return out - @staticmethod def get_dump( + self, elf_object: bytes, platform: Platform, diff_label: str, @@ -249,7 +250,7 @@ def get_dump( if len(elf_object) == 0: raise AssemblyError("Asm empty") - basedump = DiffWrapper.run_objdump( + basedump = self.run_objdump( elf_object, platform, tuple(config.arch.arch_flags), @@ -281,18 +282,14 @@ def run_diff( table_data = asm_differ.align_diffs(diff_output, diff_output, config) return config.formatter.raw(table_data) - @staticmethod def diff( - target_assembly: Assembly, + self, + target_elf: bytes, platform: Platform, diff_label: str, compiled_elf: bytes, diff_flags: list[str], ) -> DiffResult: - if platform == DUMMY: - # Todo produce diff for dummy - return DiffResult({"rows": ["a", "b"]}) - try: arch = asm_differ.get_arch(platform.arch or "") except ValueError: @@ -304,8 +301,8 @@ def diff( config = DiffWrapper.create_config(arch, diff_flags) warnings = [] try: - basedump = DiffWrapper.get_dump( - bytes(target_assembly.elf_object), + basedump = self.get_dump( + target_elf, platform, diff_label, config, @@ -316,7 +313,7 @@ def diff( raise DiffError(f"Error dumping target assembly: {e}") if compiled_elf: try: - mydump = DiffWrapper.get_dump( + mydump = self.get_dump( compiled_elf, platform, diff_label, config, objdump_flags ) except Exception as e: diff --git a/backend/coreapp/m2c_wrapper.py b/cromper/cromper/wrappers/m2c_wrapper.py similarity index 84% rename from backend/coreapp/m2c_wrapper.py rename to cromper/cromper/wrappers/m2c_wrapper.py index a51ac2687..adce52025 100644 --- a/backend/coreapp/m2c_wrapper.py +++ b/cromper/cromper/wrappers/m2c_wrapper.py @@ -4,16 +4,12 @@ from m2c.main import parse_flags, run -from coreapp.compilers import Compiler, CompilerType -from coreapp.sandbox import Sandbox +from ..compilers import Compiler, CompilerType +from ..error import M2CError +from ..sandbox import Sandbox logger = logging.getLogger(__name__) - -class M2CError(Exception): - pass - - PLATFORM_ID_TO_M2C_ARCH = { # mips "irix": "mips", @@ -33,6 +29,9 @@ class M2CError(Exception): class M2CWrapper: + def __init__(self, **sandbox_kwargs): + self.sandbox_kwargs = sandbox_kwargs + @staticmethod def is_platform_supported(platform_id: str) -> bool: return platform_id in PLATFORM_ID_TO_M2C_ARCH @@ -49,9 +48,10 @@ def get_triple(platform_id: str, compiler: Compiler) -> str: return triple - @staticmethod - def decompile(asm: str, context: str, platform_id: str, compiler: Compiler) -> str: - with Sandbox() as sandbox: + def decompile( + self, asm: str, context: str, platform_id: str, compiler: Compiler + ) -> str: + with Sandbox(**self.sandbox_kwargs) as sandbox: flags = ["--stop-on-error", "--pointer-style=left"] flags.append(f"--target={M2CWrapper.get_triple(platform_id, compiler)}") diff --git a/cromper/docker_entrypoint.sh b/cromper/docker_entrypoint.sh new file mode 100755 index 000000000..744dfa6af --- /dev/null +++ b/cromper/docker_entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail + +exec uv run --locked python -m cromper.main diff --git a/backend/libraries/.gitignore b/cromper/libraries/.gitignore similarity index 100% rename from backend/libraries/.gitignore rename to cromper/libraries/.gitignore diff --git a/backend/libraries/download.py b/cromper/libraries/download.py similarity index 100% rename from backend/libraries/download.py rename to cromper/libraries/download.py diff --git a/backend/libraries/libraries.yaml b/cromper/libraries/libraries.yaml similarity index 100% rename from backend/libraries/libraries.yaml rename to cromper/libraries/libraries.yaml diff --git a/cromper/pyproject.toml b/cromper/pyproject.toml new file mode 100644 index 000000000..f1216d60d --- /dev/null +++ b/cromper/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "cromper" +version = "0.1.0" +description = "Async compilation and assembly service for decomp.me" +authors = [ + {name = "decomp.me team"} +] +license = {text = "MIT"} +requires-python = ">=3.10" +dependencies = [ + "tornado>=6.5.2", + "requests>=2.32.5", + "python-dotenv>=1.0.0", + "m2c @ git+https://github.com/matt-kempster/m2c.git@2853e096eb2368f601cd0f05119e98d74c2edca6", + "asm-differ @ git+https://github.com/simonlindholm/asm-differ.git@5074289a13ced3a4ca4359c94d3f337d760a93ca", + "pyyaml>=6.0.3", +] + +[project.optional-dependencies] +dev = [ + "mypy>=1.18.2", + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", + "pytest-tornado>=0.8.1", + "ruff>=0.13.1", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.mypy] +ignore_missing_imports = true +strict_optional = true +warn_redundant_casts = true +warn_unused_ignores = true + +exclude = [ + "compilers/.*", + "libraries/.*", +] diff --git a/cromper/start.sh b/cromper/start.sh new file mode 100755 index 000000000..be353d25f --- /dev/null +++ b/cromper/start.sh @@ -0,0 +1,15 @@ +#!/bin/bash +set -euo pipefail + +# Load environment variables +if [ -f .env ]; then + export $(cat .env | grep -v '^#' | xargs) +fi + +echo "Starting cromper service..." +echo "Port: ${CROMPER_PORT:-8888}" +echo "Debug: ${CROMPER_DEBUG:-false}" +echo "Use sandbox jail: ${USE_SANDBOX_JAIL:-false}" + +# Start the cromper service +uv run python -m cromper.main \ No newline at end of file diff --git a/cromper/tests/__init__.py b/cromper/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/cromper/tests/common.py b/cromper/tests/common.py new file mode 100644 index 000000000..3364c1db7 --- /dev/null +++ b/cromper/tests/common.py @@ -0,0 +1,81 @@ +""" +Test utilities and common functionality for cromper tests. +""" + +import os +import tempfile +import unittest +from pathlib import Path +from typing import Any, Callable, Optional +from unittest import skip, skipIf + +from cromper.compilers import Compiler, Compilers + +# Create global compilers instance for tests +compilers = Compilers(Path(os.getenv("COMPILER_BASE_PATH", "./compilers"))) + + +def requiresCompiler(*compiler_list: Compiler) -> Callable[..., Any]: + """Decorator to skip tests if required compilers are not available.""" + for c in compiler_list: + if not compilers.is_compiler_available(c): + return skip(f"Compiler {c.id} not available") + return skipIf(False, "") + + +class CromperTestCase(unittest.TestCase): + """Base test case for cromper tests with common utilities.""" + + def setUp(self) -> None: + super().setUp() + # Set up test environment + self.test_dir = Path(tempfile.mkdtemp()) + + def tearDown(self) -> None: + # Clean up test directory + import shutil + + if self.test_dir.exists(): + shutil.rmtree(self.test_dir) + super().tearDown() + + def create_compiler_wrapper(self): + """Create a CompilerWrapper with proper test configuration.""" + from cromper.main import CromperConfig + from cromper.wrappers.compiler_wrapper import CompilerWrapper + + config = CromperConfig() + wrapper = CompilerWrapper( + use_sandbox_jail=config.use_sandbox_jail, + compilation_timeout_seconds=config.compilation_timeout_seconds, + assembly_timeout_seconds=config.assembly_timeout_seconds, + sandbox_tmp_path=config.sandbox_tmp_path, + sandbox_chroot_path=config.sandbox_chroot_path, + compiler_base_path=config.compiler_base_path, + library_base_path=config.library_base_path, + nsjail_bin_path=config.nsjail_bin_path, + sandbox_disable_proc=config.sandbox_disable_proc, + debug=config.debug, + ) + return wrapper + + def assertIsValidElfObject( + self, elf_object: bytes, msg: Optional[str] = None + ) -> None: + """Assert that the given bytes represent a valid ELF object.""" + if msg is None: + msg = "ELF object should be valid" + + self.assertIsInstance(elf_object, bytes, f"{msg}: should be bytes") + self.assertGreater(len(elf_object), 0, f"{msg}: should not be empty") + + +class AsyncCromperTestCase(CromperTestCase): + """Base test case for async cromper tests using Tornado's AsyncTestCase.""" + + def get_app(self): + """Override in subclasses to return the Tornado application.""" + from cromper.main import CromperConfig, make_app + + config = CromperConfig() + return make_app(config) diff --git a/cromper/tests/test_api.py b/cromper/tests/test_api.py new file mode 100644 index 000000000..3bbd07fd0 --- /dev/null +++ b/cromper/tests/test_api.py @@ -0,0 +1,211 @@ +""" +Test cromper API endpoints. +""" + +import json +import unittest +from tornado.testing import AsyncHTTPTestCase + +from cromper.platforms import N64 +from cromper.main import CromperConfig, make_app + + +class CromperAPITests(AsyncHTTPTestCase): + """Test cromper HTTP API endpoints.""" + + def get_app(self): + """Return the Tornado application for testing.""" + config = CromperConfig() + return make_app(config) + + def test_health_endpoint(self): + """Test the health check endpoint.""" + response = self.fetch("/health") + self.assertEqual(response.code, 200) + + data = json.loads(response.body) + self.assertEqual(data["status"], "healthy") + self.assertEqual(data["service"], "cromper") + + def test_platforms_endpoint(self): + """Test the platforms endpoint.""" + response = self.fetch("/platform") + self.assertEqual(response.code, 200) + + data = json.loads(response.body) + self.assertIsInstance(data, dict) + self.assertGreater(len(data), 0) + + # def test_platforms_endpoint_single(self): + # """Test the platforms endpoint.""" + # response = self.fetch("/platform/compiler_id") + # self.assertEqual(response.code, 200) + # data = json.loads(response.body) + + def test_compilers_endpoint(self): + """Test the compilers endpoint.""" + response = self.fetch("/compiler") + self.assertEqual(response.code, 200) + + data = json.loads(response.body) + self.assertIn("compilers", data) + self.assertIsInstance(data["compilers"], dict) + self.assertGreater(len(data["compilers"]), 0) + + # def test_compilers_endpoint_platform(self): + # """Test the compilers endpoint.""" + # response = self.fetch("/compiler/platform_id") + # self.assertEqual(response.code, 200) + # data = json.loads(response.body) + + # def test_compilers_endpoint_platform_compiler(self): + # """Test the compilers endpoint.""" + # response = self.fetch("/compiler/platform_id/compiler_id") + # self.assertEqual(response.code, 200) + # data = json.loads(response.body) + + def test_libraries_endpoint(self): + """Test the libraries endpoint.""" + response = self.fetch("/library") + self.assertEqual(response.code, 200) + + data = json.loads(response.body) + self.assertIn("libraries", data) + self.assertIsInstance(data["libraries"], list) + + def test_libraries_endpoint_with_platform_filter(self): + """Test the libraries endpoint with platform filtering.""" + response = self.fetch("/library?platform=n64") + self.assertEqual(response.code, 200) + + data = json.loads(response.body) + self.assertIn("libraries", data) + self.assertIsInstance(data["libraries"], list) + + # All libraries should be for n64 platform + for lib in data["libraries"]: + self.assertEqual(lib["platform"], "n64") + + def test_compile_endpoint_missing_compiler(self): + """Test compilation endpoint with missing compiler_id.""" + body = json.dumps( + { + "compiler_flags": "", + "code": "int main() { return 0; }", + "context": "", + } + ) + + response = self.fetch( + "/compile", + method="POST", + headers={"Content-Type": "application/json"}, + body=body, + ) + + self.assertEqual(response.code, 400) + + def test_compile_endpoint_invalid_compiler(self): + """Test compilation endpoint with invalid compiler_id.""" + body = json.dumps( + { + "compiler_id": "nonexistent_compiler", + "compiler_flags": "", + "code": "int main() { return 0; }", + "context": "", + } + ) + + response = self.fetch( + "/compile", + method="POST", + headers={"Content-Type": "application/json"}, + body=body, + ) + + self.assertEqual(response.code, 400) + + def test_assemble_endpoint_missing_platform(self): + """Test assembly endpoint with missing platform_id.""" + body = json.dumps( + { + "asm_data": ".text\\nmain:\\n nop\\n", + "asm_hash": "test_hash", + } + ) + + response = self.fetch( + "/assemble", + method="POST", + headers={"Content-Type": "application/json"}, + body=body, + ) + + self.assertEqual(response.code, 400) + + def test_diff_endpoint_missing_data(self): + """Test diff endpoint with missing required data.""" + body = json.dumps( + { + "platform_id": N64.id, + # Missing target_asm_data and compiled_elf + } + ) + + response = self.fetch( + "/diff", + method="POST", + headers={"Content-Type": "application/json"}, + body=body, + ) + + self.assertEqual(response.code, 400) + + def test_decompile_endpoint_missing_params(self): + """Test decompile endpoint with missing required parameters.""" + body = json.dumps( + { + "platform_id": N64.id, + # Missing compiler_id and asm + } + ) + + response = self.fetch( + "/decompile", + method="POST", + headers={"Content-Type": "application/json"}, + body=body, + ) + + self.assertEqual(response.code, 400) + + def test_cors_headers(self): + """Test that CORS headers are properly set.""" + response = self.fetch("/health") + + self.assertEqual(response.headers.get("Access-Control-Allow-Origin"), "*") + self.assertEqual( + response.headers.get("Content-Type"), "application/json; charset=UTF-8" + ) + + def test_options_request(self): + """Test that OPTIONS requests are handled correctly.""" + response = self.fetch("/compile", method="OPTIONS") + self.assertEqual(response.code, 204) + self.assertEqual(response.headers.get("Access-Control-Allow-Origin"), "*") + self.assertIn("POST", response.headers.get("Access-Control-Allow-Methods", "")) + + def test_invalid_json(self): + """Test endpoint with invalid JSON.""" + response = self.fetch( + "/compile", + method="POST", + headers={"Content-Type": "application/json"}, + body="invalid json", + ) + + self.assertEqual(response.code, 400) + + +if __name__ == "__main__": + unittest.main() diff --git a/cromper/tests/test_compilation.py b/cromper/tests/test_compilation.py new file mode 100644 index 000000000..b07b586d0 --- /dev/null +++ b/cromper/tests/test_compilation.py @@ -0,0 +1,254 @@ +""" +Test compilation functionality in cromper. +""" + +import unittest + +from cromper.wrappers.compiler_wrapper import CompilationResult +from cromper.compilers import ( + GCC281PM, + IDO53, + MWCC_247_92, + PBX_GCC3, + WATCOM_105_C, +) +from cromper import platforms +from cromper.wrappers.diff_wrapper import DiffWrapper +from cromper.flags import Language + +from .common import CromperTestCase, requiresCompiler, compilers + + +def _make_compiler_test(compiler): + """Create a test method for a specific compiler.""" + + def _test_method(self): + """Test that this specific compiler can compile basic code.""" + if not compilers.is_compiler_available(compiler): + self.fail(f"Compiler {compiler.id} not available") + + wrapper = self.create_compiler_wrapper() + + code = "int func(void) { return 5; }" + if compiler.language == Language.PASCAL: + code = "function func(): integer; begin func := 5; end;" + elif compiler.language == Language.ASSEMBLY: + code = "nada" + + result = wrapper.compile_code( + compiler=compiler, + compiler_flags="", + code=code, + context="", + libraries=[], + ) + + self.assertIsValidElfObject( + result.elf_object, + f"Compilation with {compiler.id} should produce valid ELF", + ) + + # Set a descriptive name and docstring for the test method + _test_method.__name__ = ( + f"test_compiler_{compiler.id.replace('-', '_').replace('.', '_')}" + ) + _test_method.__doc__ = f"Test compilation with {compiler.id}" + return _test_method + + +class CompilationTests(CromperTestCase): + """Test compilation functionality.""" + + @requiresCompiler(GCC281PM) + def test_gcc281pm_simple_compilation(self) -> None: + """Test simple compilation with GCC 2.8.1pm.""" + wrapper = self.create_compiler_wrapper() + result = wrapper.compile_code( + compiler=GCC281PM, + compiler_flags="-mips2 -O2", + code="int add(int a, int b) { return a + b; }", + context="", + libraries=[], + ) + + self.assertIsInstance(result, CompilationResult) + self.assertIsValidElfObject(result.elf_object) + + @requiresCompiler(GCC281PM) + def test_giant_compilation(self) -> None: + """Ensure that we can compile a giant file with lots of context.""" + wrapper = self.create_compiler_wrapper() + + # Generate large context + context = "" + for i in range(25000): # Reduced from 25000 for faster testing + context += "extern int test_symbol_to_be_used_in_a_test;\n" + + result = wrapper.compile_code( + compiler=GCC281PM, + compiler_flags="-mips2 -O2", + code="int add(int a, int b) { return a + b; }", + context=context, + libraries=[], + ) + + self.assertIsInstance(result, CompilationResult) + self.assertIsValidElfObject(result.elf_object) + + @requiresCompiler(IDO53) + def test_ido_line_endings(self) -> None: + """Ensure that compilations with \r\n line endings succeed.""" + wrapper = self.create_compiler_wrapper() + result = wrapper.compile_code( + compiler=IDO53, + compiler_flags="-mips2 -O2", + code="int dog = 5;", + context="extern char libvar1;\r\nextern char libvar2;\r\n", + libraries=[], + ) + + self.assertIsValidElfObject( + result.elf_object, "IDO compilation with \r\n line endings" + ) + + @requiresCompiler(IDO53) + def test_ido_kpic(self) -> None: + """Ensure that IDO compilations including -KPIC produce different code.""" + wrapper = self.create_compiler_wrapper() + + result_non_shared = wrapper.compile_code( + compiler=IDO53, + compiler_flags="-mips2 -O2", + code="int dog = 5;", + context="", + libraries=[], + ) + + result_kpic = wrapper.compile_code( + compiler=IDO53, + compiler_flags="-mips2 -O2 -KPIC", + code="int dog = 5;", + context="", + libraries=[], + ) + + self.assertNotEqual( + result_non_shared.elf_object, + result_kpic.elf_object, + "The compilation result should be different with -KPIC", + ) + + @requiresCompiler(PBX_GCC3) + def test_pbx_gcc3(self) -> None: + """Ensure that we can invoke the PowerPC GCC3 cross-compiler.""" + wrapper = self.create_compiler_wrapper() + result = wrapper.compile_code( + compiler=PBX_GCC3, + compiler_flags="-std=c99 -fPIC -O0 -g3", + code="int func(void) { float f = 5.0; return f; }", # test if floats are handled correctly + context="extern char libvar1;\r\nextern char libvar2;\r\n", + libraries=[], + ) + + self.assertIsValidElfObject(result.elf_object, "PowerPC GCC3 compilation") + + @requiresCompiler(MWCC_247_92) + def test_mwcc(self) -> None: + """Ensure that we can invoke mwcc.""" + wrapper = self.create_compiler_wrapper() + result = wrapper.compile_code( + compiler=MWCC_247_92, + compiler_flags="-str reuse -inline on -fp off -O0", + code="int func(void) { return 5; }", + context="extern char libvar1;\r\nextern char libvar2;\r\n", + libraries=[], + ) + + self.assertIsValidElfObject(result.elf_object, "MWCC compilation") + + @requiresCompiler(WATCOM_105_C) + def test_watcom_cc(self) -> None: + """Ensure that we can invoke watcom cc.""" + wrapper = self.create_compiler_wrapper() + result = wrapper.compile_code( + compiler=WATCOM_105_C, + compiler_flags="", + code="int func(void) { return 5; }", + context="extern char libvar1;\r\nextern char libvar2;\r\n", + libraries=[], + ) + + self.assertIsValidElfObject(result.elf_object, "Watcom compilation") + + def test_compilation_with_diff(self) -> None: + """Test compilation combined with diff generation.""" + wrapper = self.create_compiler_wrapper() + diff_wrapper = DiffWrapper() + + compiler = GCC281PM + platform = platforms.N64 # TODO test all available platforms + + code = "int func(void) { return 5; }" + if compiler.language == Language.PASCAL: + code = "function func(): integer; begin func := 5; end;" + elif compiler.language == Language.ASSEMBLY: + code = "nada" + + result = wrapper.compile_code( + compiler=compiler, + compiler_flags="", + code=code, + context="", + libraries=[], + ) + + diff_result = diff_wrapper.diff( + target_elf=result.elf_object, + platform=platform, + diff_label="func", + compiled_elf=result.elf_object, + diff_flags=[], + ) + + self.assertIsNotNone(diff_result.result, "Diff result should not be None") + if diff_result.result: + self.assertIn("rows", diff_result.result, "Diff result should contain rows") + self.assertGreater( + len(diff_result.result["rows"]), 0, "Diff should have rows" + ) + + +class CompilerFilteringTests(CromperTestCase): + """Test compiler flag filtering functionality.""" + + def test_filter_compiler_flags(self) -> None: + """Test that compiler flag filtering works correctly.""" + from cromper.wrappers.compiler_wrapper import CompilerWrapper + + test_cases = [ + # (input_flags, expected_output) + ("-O2 -g", "-O2 -g"), # Basic flags should pass through + ("-O2 -B/path -g", "-O2 -g"), # -B flag should be removed + ("-I/include -O2", "-O2"), # -I flag should be removed + ("-ffreestanding -O2", "-O2"), # -ffreestanding should be removed + ("-O2 -non_shared -g", "-O2 -g"), # -non_shared should be removed + ("-Xcpluscomm -O2", "-O2"), # -Xcpluscomm should be removed + ("-Wab,-r4300_mul -O2", "-O2"), # -Wab,-r4300_mul should be removed + ("-c -O2", "-O2"), # -c should be removed + ("-B/path/to/dir -I/inc -U MACRO -O2", "-O2"), # Multiple filtered flags + ] + + for input_flags, expected in test_cases: + with self.subTest(input_flags=input_flags): + result = CompilerWrapper.filter_compiler_flags(input_flags) + self.assertEqual(result, expected) + + +# Dynamically add individual compiler tests to the CompilationTests class +for compiler in compilers.all_compilers(): + compiler_test_method = _make_compiler_test(compiler) + setattr(CompilationTests, compiler_test_method.__name__, compiler_test_method) + + +if __name__ == "__main__": + unittest.main() diff --git a/cromper/tests/test_decompilation.py b/cromper/tests/test_decompilation.py new file mode 100644 index 000000000..593835be8 --- /dev/null +++ b/cromper/tests/test_decompilation.py @@ -0,0 +1,230 @@ +import unittest + +from cromper.compilers import GCC281PM, IDO53, MWCC_247_92 +from cromper.compilers import Compiler +from cromper.platforms import GC_WII, N64, Platform +from cromper.wrappers.decompiler_wrapper import ( + DecompilerWrapper, + DECOMP_WITH_CONTEXT_FAILED_PREAMBLE, +) +from cromper.wrappers.m2c_wrapper import M2CWrapper + +from .common import CromperTestCase, requiresCompiler + +MOCK_PLATFORM = Platform( + id="mock", + name="Mock Platform", + description="", + arch="mips", + assemble_cmd="", + objdump_cmd="", + nm_cmd="", +) + +MOCK_UNSUPPORTED_PLATFORM = Platform( + id="mock", + name="Mock Platform", + description="", + arch="unsupported_arch", + assemble_cmd="", + objdump_cmd="", + nm_cmd="", +) + + +class DecompilationTests(CromperTestCase): + """Test decompilation functionality.""" + + @requiresCompiler(GCC281PM) + def test_default_decompilation(self) -> None: + """Test basic decompilation functionality.""" + wrapper = DecompilerWrapper() + platform = N64 + + # Simple MIPS assembly that should decompile to a return statement + asm = "glabel return_2\njr $ra\nli $v0,2" + + result = wrapper.decompile( + default_source_code="", + platform=platform, + asm=asm, + context="", + compiler=GCC281PM, + ) + + self.assertIsInstance(result, str) + self.assertIn("return", result.lower()) + + @requiresCompiler(GCC281PM) + def test_decompilation_with_context(self) -> None: + """Test decompilation with context code.""" + wrapper = DecompilerWrapper() + platform = N64 + + asm = "glabel return_2\njr $ra\nli $v0,2" + context = "typedef int s32;" + + result = wrapper.decompile( + default_source_code="", + platform=platform, + asm=asm, + context=context, + compiler=GCC281PM, + ) + + self.assertIsInstance(result, str) + self.assertIn("return", result.lower()) + + @requiresCompiler(GCC281PM) + def test_decompilation_with_broken_context(self) -> None: + """Test decompilation with broken context code.""" + wrapper = DecompilerWrapper() + platform = N64 + + asm = "glabel return_2\njr $ra\nli $v0,2" + broken_context = "typedeff jeff;" # Intentional syntax error + + result = wrapper.decompile( + default_source_code="/* default source */", + platform=platform, + asm=asm, + context=broken_context, + compiler=GCC281PM, + ) + + self.assertIsInstance(result, str) + # Should contain error information and fallback + self.assertTrue( + "error" in result.lower() + or "syntax" in result.lower() + or DECOMP_WITH_CONTEXT_FAILED_PREAMBLE in result + ) + + def test_unsupported_architecture(self) -> None: + """Test decompilation with unsupported architecture.""" + wrapper = DecompilerWrapper() + + asm = "some assembly" + default_source = "/* default source */" + + result = wrapper.decompile( + default_source_code=default_source, + platform=MOCK_UNSUPPORTED_PLATFORM, + asm=asm, + context="", + compiler=GCC281PM, + ) + + self.assertIsInstance(result, str) + self.assertIn("No decompiler yet implemented", result) + self.assertIn("unsupported_arch", result) + + def test_too_many_lines(self) -> None: + """Test decompilation with too many lines of assembly.""" + wrapper = DecompilerWrapper() + platform = N64 + + # Create assembly with too many lines + asm_lines = ["nop"] * 20000 # Exceeds MAX_M2C_ASM_LINES + asm = "\n".join(asm_lines) + + result = wrapper.decompile( + default_source_code="/* default */", + platform=platform, + asm=asm, + context="", + compiler=GCC281PM, + ) + + self.assertIsInstance(result, str) + self.assertIn("Too many lines to decompile", result) + + +class M2CTests(CromperTestCase): + """Test M2C wrapper functionality.""" + + def test_left_pointer_style(self) -> None: + """Ensure that pointers are next to types (left style).""" + wrapper = M2CWrapper() + + c_code = wrapper.decompile( + asm=""" + glabel func + li $t6,1 + jr $ra + sw $t6,0($a0) + """, + context="", + platform_id=N64.id, + compiler=IDO53, + ) + + self.assertIsInstance(c_code, str) + self.assertIn( + "s32*", + c_code, + f"The decompiled c code should have a left-style pointer, was instead:\n{c_code}", + ) + + def test_ppc_decompilation(self) -> None: + """Ensure that we can decompile PPC code.""" + wrapper = M2CWrapper() + + c_code = wrapper.decompile( + asm=""" + .global func_800B43A8 + func_800B43A8: + xor r0, r3, r3 + subf r3, r4, r0 + blr + """, + context="", + platform_id=GC_WII.id, + compiler=MWCC_247_92, + ) + + expected = "s32 func_800B43A8(s32 arg0, s32 arg1) {\n return (arg0 ^ arg0) - arg1;\n}\n" + self.assertEqual(expected, c_code) + + def test_get_triple(self) -> None: + """Test M2C triple generation for different architectures and compilers.""" + test_cases = [ + (IDO53, "mips", "mips-ido"), + (GCC281PM, "mips", "mips-gcc"), + (MWCC_247_92, "ppc", "ppc-mwcc"), + (GCC281PM, "mipsel", "mips-gcc"), + (GCC281PM, "mipsee", "mips-gcc"), + ] + + for compiler, arch, expected_triple in test_cases: + with self.subTest(compiler=compiler.id, arch=arch): + triple = M2CWrapper.get_triple(compiler.platform.id, compiler) + self.assertEqual(triple, expected_triple) + + def test_unsupported_platform(self) -> None: + """Test M2C with unsupported platform""" + from cromper.error import M2CError + + compiler = Compiler( + id="mock", cc="mock", platform=MOCK_PLATFORM, library_include_flag="" + ) + + with self.assertRaises(M2CError) as cm: + M2CWrapper.get_triple("mips", compiler) + + self.assertIn("Unsupported platform", str(cm.exception)) + + def test_unsupported_compiler(self) -> None: + """Test M2C with unsupported compiler type.""" + from cromper.error import M2CError + + compiler = Compiler(id="mock", cc="mock", platform=N64, library_include_flag="") + + with self.assertRaises(M2CError) as cm: + M2CWrapper.get_triple("mips", compiler) + + self.assertIn("Unsupported platform", str(cm.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/cromper/uv.lock b/cromper/uv.lock new file mode 100644 index 000000000..6900b03c2 --- /dev/null +++ b/cromper/uv.lock @@ -0,0 +1,748 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "asm-differ" +version = "0.1.0" +source = { git = "https://github.com/simonlindholm/asm-differ.git?rev=5074289a13ced3a4ca4359c94d3f337d760a93ca#5074289a13ced3a4ca4359c94d3f337d760a93ca" } +dependencies = [ + { name = "colorama" }, + { name = "cxxfilt" }, + { name = "levenshtein" }, + { name = "watchdog" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/98/f3b8013223728a99b908c9344da3aa04ee6e3fa235f19409033eda92fb78/charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", size = 207695, upload-time = "2025-08-09T07:55:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/21/40/5188be1e3118c82dcb7c2a5ba101b783822cfb413a0268ed3be0468532de/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", size = 147153, upload-time = "2025-08-09T07:55:38.467Z" }, + { url = "https://files.pythonhosted.org/packages/37/60/5d0d74bc1e1380f0b72c327948d9c2aca14b46a9efd87604e724260f384c/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", size = 160428, upload-time = "2025-08-09T07:55:40.072Z" }, + { url = "https://files.pythonhosted.org/packages/85/9a/d891f63722d9158688de58d050c59dc3da560ea7f04f4c53e769de5140f5/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", size = 157627, upload-time = "2025-08-09T07:55:41.706Z" }, + { url = "https://files.pythonhosted.org/packages/65/1a/7425c952944a6521a9cfa7e675343f83fd82085b8af2b1373a2409c683dc/charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", size = 152388, upload-time = "2025-08-09T07:55:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c9/a2c9c2a355a8594ce2446085e2ec97fd44d323c684ff32042e2a6b718e1d/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", size = 150077, upload-time = "2025-08-09T07:55:44.903Z" }, + { url = "https://files.pythonhosted.org/packages/3b/38/20a1f44e4851aa1c9105d6e7110c9d020e093dfa5836d712a5f074a12bf7/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", size = 161631, upload-time = "2025-08-09T07:55:46.346Z" }, + { url = "https://files.pythonhosted.org/packages/a4/fa/384d2c0f57edad03d7bec3ebefb462090d8905b4ff5a2d2525f3bb711fac/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", size = 159210, upload-time = "2025-08-09T07:55:47.539Z" }, + { url = "https://files.pythonhosted.org/packages/33/9e/eca49d35867ca2db336b6ca27617deed4653b97ebf45dfc21311ce473c37/charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", size = 153739, upload-time = "2025-08-09T07:55:48.744Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/26c3036e62dfe8de8061182d33be5025e2424002125c9500faff74a6735e/charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", size = 99825, upload-time = "2025-08-09T07:55:50.305Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/f05db471f81af1fa01839d44ae2a8bfeec8d2a8b4590f16c4e7393afd323/charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", size = 107452, upload-time = "2025-08-09T07:55:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b5/991245018615474a60965a7c9cd2b4efbaabd16d582a5547c47ee1c7730b/charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", size = 204483, upload-time = "2025-08-09T07:55:53.12Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/ae245c41c06299ec18262825c1569c5d3298fc920e4ddf56ab011b417efd/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", size = 145520, upload-time = "2025-08-09T07:55:54.712Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a4/b3b6c76e7a635748c4421d2b92c7b8f90a432f98bda5082049af37ffc8e3/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", size = 158876, upload-time = "2025-08-09T07:55:56.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e6/63bb0e10f90a8243c5def74b5b105b3bbbfb3e7bb753915fe333fb0c11ea/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", size = 156083, upload-time = "2025-08-09T07:55:57.582Z" }, + { url = "https://files.pythonhosted.org/packages/87/df/b7737ff046c974b183ea9aa111b74185ac8c3a326c6262d413bd5a1b8c69/charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", size = 150295, upload-time = "2025-08-09T07:55:59.147Z" }, + { url = "https://files.pythonhosted.org/packages/61/f1/190d9977e0084d3f1dc169acd060d479bbbc71b90bf3e7bf7b9927dec3eb/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", size = 148379, upload-time = "2025-08-09T07:56:00.364Z" }, + { url = "https://files.pythonhosted.org/packages/4c/92/27dbe365d34c68cfe0ca76f1edd70e8705d82b378cb54ebbaeabc2e3029d/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", size = 160018, upload-time = "2025-08-09T07:56:01.678Z" }, + { url = "https://files.pythonhosted.org/packages/99/04/baae2a1ea1893a01635d475b9261c889a18fd48393634b6270827869fa34/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", size = 157430, upload-time = "2025-08-09T07:56:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/2f/36/77da9c6a328c54d17b960c89eccacfab8271fdaaa228305330915b88afa9/charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", size = 151600, upload-time = "2025-08-09T07:56:04.089Z" }, + { url = "https://files.pythonhosted.org/packages/64/d4/9eb4ff2c167edbbf08cdd28e19078bf195762e9bd63371689cab5ecd3d0d/charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", size = 99616, upload-time = "2025-08-09T07:56:05.658Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9c/996a4a028222e7761a96634d1820de8a744ff4327a00ada9c8942033089b/charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", size = 107108, upload-time = "2025-08-09T07:56:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +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 = "cromper" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "asm-differ" }, + { name = "m2c" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tornado" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-tornado" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "asm-differ", git = "https://github.com/simonlindholm/asm-differ.git?rev=5074289a13ced3a4ca4359c94d3f337d760a93ca" }, + { name = "m2c", git = "https://github.com/matt-kempster/m2c.git?rev=2853e096eb2368f601cd0f05119e98d74c2edca6" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.18.2" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2.0" }, + { name = "pytest-tornado", marker = "extra == 'dev'", specifier = ">=0.8.1" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.13.1" }, + { name = "tornado", specifier = ">=6.5.2" }, +] +provides-extras = ["dev"] + +[[package]] +name = "cxxfilt" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/dc/71ac606f7dfa71d49e3dc126b49b18daefaf6bd953078858af30fde40702/cxxfilt-0.3.0.tar.gz", hash = "sha256:7df6464ba5e8efbf0d8974c0b2c78b32546676f06059a83515dbdfa559b34214", size = 4806, upload-time = "2021-08-21T16:48:04.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/29/23572dc59bf4a3984fe3c5fc242f73be916785ee93387dd95c972dbf584e/cxxfilt-0.3.0-py2.py3-none-any.whl", hash = "sha256:774e85a8d0157775ed43276d89397d924b104135762d86b3a95f81f203094e07", size = 4649, upload-time = "2021-08-21T16:48:02.953Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, +] + +[[package]] +name = "graphviz" +version = "0.20.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/83/5a40d19b8347f017e417710907f824915fba411a9befd092e52746b63e9f/graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d", size = 256455, upload-time = "2024-03-21T07:50:45.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/be/d59db2d1d52697c6adc9eacaf50e8965b6345cc143f671e1ed068818d5cf/graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5", size = 47126, upload-time = "2024-03-21T07:50:43.091Z" }, +] + +[[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 = "levenshtein" +version = "0.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rapidfuzz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/b3/b5f8011483ba9083a0bc74c4d58705e9cf465fbe55c948a1b1357d0a2aa8/levenshtein-0.27.1.tar.gz", hash = "sha256:3e18b73564cfc846eec94dd13fab6cb006b5d2e0cc56bad1fd7d5585881302e3", size = 382571, upload-time = "2025-03-02T19:44:56.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/b1/9906a75b98dd9c008015a72d7658be53851e361a35492631edf1b1f334ab/levenshtein-0.27.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:13d6f617cb6fe63714c4794861cfaacd398db58a292f930edb7f12aad931dace", size = 174542, upload-time = "2025-03-02T19:42:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/3b/57/e26e0164a93fb045316856603111d95538cac8224a3709e4ac96a6bb74f3/levenshtein-0.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca9d54d41075e130c390e61360bec80f116b62d6ae973aec502e77e921e95334", size = 156367, upload-time = "2025-03-02T19:42:26.65Z" }, + { url = "https://files.pythonhosted.org/packages/6d/dd/92fcb71d48c1fe69c46c211156adafb8175037dc63e80e970106aef3f9d5/levenshtein-0.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de1f822b5c9a20d10411f779dfd7181ce3407261436f8470008a98276a9d07f", size = 152189, upload-time = "2025-03-02T19:42:28.533Z" }, + { url = "https://files.pythonhosted.org/packages/5e/23/3f331f5fbfa93634126439cfc8c01b31f7ef1fbedb81663581e27a69da4d/levenshtein-0.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:81270392c2e45d1a7e1b3047c3a272d5e28bb4f1eff0137637980064948929b7", size = 184271, upload-time = "2025-03-02T19:42:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/d6ac541a1a80bdc5c98584a6a2d2301e677af4cb2e4092247207791b56a6/levenshtein-0.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d30c3ea23a94dddd56dbe323e1fa8a29ceb24da18e2daa8d0abf78b269a5ad1", size = 185078, upload-time = "2025-03-02T19:42:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ed/d0c5abe8cfcf6a7f2a4197e889e12b7a0c2145a0ef3354b1c000bf367305/levenshtein-0.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3e0bea76695b9045bbf9ad5f67ad4cc01c11f783368f34760e068f19b6a6bc", size = 161505, upload-time = "2025-03-02T19:42:34.641Z" }, + { url = "https://files.pythonhosted.org/packages/f3/28/a5b78e1818211bc6407590876bbdcc6d79671e529a0c186780492c1f2136/levenshtein-0.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cdd190e468a68c31a5943368a5eaf4e130256a8707886d23ab5906a0cb98a43c", size = 246968, upload-time = "2025-03-02T19:42:36.195Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/981b903583956cb67b33bed39d9840ab5e4c7062bceec564b7bf2c3f6f49/levenshtein-0.27.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c3121314bb4b676c011c33f6a0ebb462cfdcf378ff383e6f9e4cca5618d0ba7", size = 1116000, upload-time = "2025-03-02T19:42:38.292Z" }, + { url = "https://files.pythonhosted.org/packages/75/1d/c4be47d5f436fd310373c5ebdf05828c1d95be9a44c3e94f29c40937b30c/levenshtein-0.27.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8ef378c873efcc5e978026b69b45342d841cd7a2f273447324f1c687cc4dc37", size = 1401162, upload-time = "2025-03-02T19:42:40.496Z" }, + { url = "https://files.pythonhosted.org/packages/91/e4/0b107676efe3ecd5fada1ed3a3bbddd4c829e2ef34e980b76374c116235b/levenshtein-0.27.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff18d78c5c16bea20876425e1bf5af56c25918fb01bc0f2532db1317d4c0e157", size = 1225141, upload-time = "2025-03-02T19:42:42.636Z" }, + { url = "https://files.pythonhosted.org/packages/29/f0/f3f88d766fdbb1d39fe98dc5527223bae099444e501550ae088c47ddd97b/levenshtein-0.27.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:13412ff805afbfe619d070280d1a76eb4198c60c5445cd5478bd4c7055bb3d51", size = 1419707, upload-time = "2025-03-02T19:42:44.69Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1c/f51ac1db4064a85effa50df240250e413f428164301d836c312baf09381e/levenshtein-0.27.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a2adb9f263557f7fb13e19eb2f34595d86929a44c250b2fca6e9b65971e51e20", size = 1189284, upload-time = "2025-03-02T19:42:46.098Z" }, + { url = "https://files.pythonhosted.org/packages/e0/67/5ace76bc964b93ed6203a9f8c4dcde1a50e336468f7da3a21dd29febaf46/levenshtein-0.27.1-cp310-cp310-win32.whl", hash = "sha256:6278a33d2e0e909d8829b5a72191419c86dd3bb45b82399c7efc53dabe870c35", size = 88036, upload-time = "2025-03-02T19:42:47.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/e0/d9737dbbe85842ddb300cb7974fc065edc56ec647652863f95ac1977d378/levenshtein-0.27.1-cp310-cp310-win_amd64.whl", hash = "sha256:5b602b8428ee5dc88432a55c5303a739ee2be7c15175bd67c29476a9d942f48e", size = 99922, upload-time = "2025-03-02T19:42:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/27/b8/13e22789ab700db0da98f973a508643dbe2d25bd0fb5dc53239e0e2852c1/levenshtein-0.27.1-cp310-cp310-win_arm64.whl", hash = "sha256:48334081fddaa0c259ba01ee898640a2cf8ede62e5f7e25fefece1c64d34837f", size = 87846, upload-time = "2025-03-02T19:42:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/22/84/110136e740655779aceb0da2399977362f21b2dbf3ea3646557f9c2237c4/levenshtein-0.27.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e6f1760108319a108dceb2f02bc7cdb78807ad1f9c673c95eaa1d0fe5dfcaae", size = 174555, upload-time = "2025-03-02T19:42:51.781Z" }, + { url = "https://files.pythonhosted.org/packages/19/5b/176d96959f5c5969f356d8856f8e20d2e72f7e4879f6d1cda8e5c2ac2614/levenshtein-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c4ed8400d94ab348099395e050b8ed9dd6a5d6b5b9e75e78b2b3d0b5f5b10f38", size = 156286, upload-time = "2025-03-02T19:42:53.106Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2d/a75abaafc8a46b0dc52ab14dc96708989a31799a02a4914f9210c3415f04/levenshtein-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7826efe51be8ff58bc44a633e022fdd4b9fc07396375a6dbc4945a3bffc7bf8f", size = 152413, upload-time = "2025-03-02T19:42:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5f/533f4adf964b10817a1d0ecca978b3542b3b9915c96172d20162afe18bed/levenshtein-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff5afb78719659d353055863c7cb31599fbea6865c0890b2d840ee40214b3ddb", size = 184236, upload-time = "2025-03-02T19:42:56.427Z" }, + { url = "https://files.pythonhosted.org/packages/02/79/e698623795e36e0d166a3aa1eac6fe1e446cac3a5c456664a95c351571d1/levenshtein-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:201dafd5c004cd52018560cf3213da799534d130cf0e4db839b51f3f06771de0", size = 185502, upload-time = "2025-03-02T19:42:57.596Z" }, + { url = "https://files.pythonhosted.org/packages/ac/94/76b64762f4af6e20bbab79713c4c48783240e6e502b2f52e5037ddda688a/levenshtein-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5ddd59f3cfaec216811ee67544779d9e2d6ed33f79337492a248245d6379e3d", size = 161749, upload-time = "2025-03-02T19:42:59.222Z" }, + { url = "https://files.pythonhosted.org/packages/56/d0/d10eff9224c94a478078a469aaeb43471fdeddad035f443091224c7544b8/levenshtein-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6afc241d27ecf5b921063b796812c55b0115423ca6fa4827aa4b1581643d0a65", size = 246686, upload-time = "2025-03-02T19:43:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8a/ebbeff74461da3230d00e8a8197480a2ea1a9bbb7dbc273214d7ea3896cb/levenshtein-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee2e766277cceb8ca9e584ea03b8dc064449ba588d3e24c1923e4b07576db574", size = 1116616, upload-time = "2025-03-02T19:43:02.431Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9b/e7323684f833ede13113fba818c3afe665a78b47d720afdeb2e530c1ecb3/levenshtein-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:920b23d6109453913ce78ec451bc402ff19d020ee8be4722e9d11192ec2fac6f", size = 1401483, upload-time = "2025-03-02T19:43:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/ef/1d/9b6ab30ff086a33492d6f7de86a07050b15862ccf0d9feeccfbe26af52d8/levenshtein-0.27.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:560d7edba126e2eea3ac3f2f12e7bd8bc9c6904089d12b5b23b6dfa98810b209", size = 1225805, upload-time = "2025-03-02T19:43:06.734Z" }, + { url = "https://files.pythonhosted.org/packages/1b/07/ae2f31e87ff65ba4857e25192646f1f3c8cca83c2ac1c27e551215b7e1b6/levenshtein-0.27.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8d5362b6c7aa4896dc0cb1e7470a4ad3c06124e0af055dda30d81d3c5549346b", size = 1419860, upload-time = "2025-03-02T19:43:08.084Z" }, + { url = "https://files.pythonhosted.org/packages/43/d2/dfcc5c22c07bab9be99f3f47a907be583bcd37bfd2eec57a205e59671019/levenshtein-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:65ba880815b0f80a80a293aeebac0fab8069d03ad2d6f967a886063458f9d7a1", size = 1188823, upload-time = "2025-03-02T19:43:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/8b/96/713335623f8ab50eba0627c8685618dc3a985aedaaea9f492986b9443551/levenshtein-0.27.1-cp311-cp311-win32.whl", hash = "sha256:fcc08effe77fec0bc5b0f6f10ff20b9802b961c4a69047b5499f383119ddbe24", size = 88156, upload-time = "2025-03-02T19:43:11.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ae/444d6e8ba9a35379a56926716f18bb2e77c6cf69e5324521fbe6885f14f6/levenshtein-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:0ed402d8902be7df212ac598fc189f9b2d520817fdbc6a05e2ce44f7f3ef6857", size = 100399, upload-time = "2025-03-02T19:43:13.066Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/ff226897a238a2deb2ca2c00d658755a1aa01884b0ddc8f5d406cb5f2b0d/levenshtein-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:7fdaab29af81a8eb981043737f42450efca64b9761ca29385487b29c506da5b5", size = 88033, upload-time = "2025-03-02T19:43:14.211Z" }, + { url = "https://files.pythonhosted.org/packages/0d/73/84a7126b9e6441c2547f1fbfd65f3c15c387d1fc04e0dd1d025a12107771/levenshtein-0.27.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25fb540d8c55d1dc7bdc59b7de518ea5ed9df92eb2077e74bcb9bb6de7b06f69", size = 173953, upload-time = "2025-03-02T19:43:16.029Z" }, + { url = "https://files.pythonhosted.org/packages/8f/5c/06c01870c0cf336f9f29397bbfbfbbfd3a59918868716e7bb15828e89367/levenshtein-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f09cfab6387e9c908c7b37961c045e8e10eb9b7ec4a700367f8e080ee803a562", size = 156399, upload-time = "2025-03-02T19:43:17.233Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4a/c1d3f27ec8b3fff5a96617251bf3f61c67972869ac0a0419558fc3e2cbe6/levenshtein-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dafa29c0e616f322b574e0b2aeb5b1ff2f8d9a1a6550f22321f3bd9bb81036e3", size = 151061, upload-time = "2025-03-02T19:43:18.414Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8f/2521081e9a265891edf46aa30e1b59c1f347a452aed4c33baafbec5216fa/levenshtein-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be7a7642ea64392fa1e6ef7968c2e50ef2152c60948f95d0793361ed97cf8a6f", size = 183119, upload-time = "2025-03-02T19:43:19.975Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a0/a63e3bce6376127596d04be7f57e672d2f3d5f540265b1e30b9dd9b3c5a9/levenshtein-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:060b48c45ed54bcea9582ce79c6365b20a1a7473767e0b3d6be712fa3a22929c", size = 185352, upload-time = "2025-03-02T19:43:21.424Z" }, + { url = "https://files.pythonhosted.org/packages/17/8c/8352e992063952b38fb61d49bad8d193a4a713e7eeceb3ae74b719d7863d/levenshtein-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:712f562c5e64dd0398d3570fe99f8fbb88acec7cc431f101cb66c9d22d74c542", size = 159879, upload-time = "2025-03-02T19:43:22.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/b4/564866e2038acf47c3de3e9292fc7fc7cc18d2593fedb04f001c22ac6e15/levenshtein-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6141ad65cab49aa4527a3342d76c30c48adb2393b6cdfeca65caae8d25cb4b8", size = 245005, upload-time = "2025-03-02T19:43:24.069Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f9/7367f87e3a6eed282f3654ec61a174b4d1b78a7a73f2cecb91f0ab675153/levenshtein-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:799b8d73cda3265331116f62932f553804eae16c706ceb35aaf16fc2a704791b", size = 1116865, upload-time = "2025-03-02T19:43:25.4Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/b5b3bfb4b4cd430e9d110bad2466200d51c6061dae7c5a64e36047c8c831/levenshtein-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ec99871d98e517e1cc4a15659c62d6ea63ee5a2d72c5ddbebd7bae8b9e2670c8", size = 1401723, upload-time = "2025-03-02T19:43:28.099Z" }, + { url = "https://files.pythonhosted.org/packages/ef/69/b93bccd093b3f06a99e67e11ebd6e100324735dc2834958ba5852a1b9fed/levenshtein-0.27.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8799164e1f83588dbdde07f728ea80796ea72196ea23484d78d891470241b222", size = 1226276, upload-time = "2025-03-02T19:43:30.192Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/37dd1bc5ce866c136716619e6f7081d7078d7dd1c1da7025603dcfd9cf5f/levenshtein-0.27.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:583943813898326516ab451a83f734c6f07488cda5c361676150d3e3e8b47927", size = 1420132, upload-time = "2025-03-02T19:43:33.322Z" }, + { url = "https://files.pythonhosted.org/packages/4b/08/f3bc828dd9f0f8433b26f37c4fceab303186ad7b9b70819f2ccb493d99fc/levenshtein-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bb22956af44bb4eade93546bf95be610c8939b9a9d4d28b2dfa94abf454fed7", size = 1189144, upload-time = "2025-03-02T19:43:34.814Z" }, + { url = "https://files.pythonhosted.org/packages/2d/54/5ecd89066cf579223d504abe3ac37ba11f63b01a19fd12591083acc00eb6/levenshtein-0.27.1-cp312-cp312-win32.whl", hash = "sha256:d9099ed1bcfa7ccc5540e8ad27b5dc6f23d16addcbe21fdd82af6440f4ed2b6d", size = 88279, upload-time = "2025-03-02T19:43:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/53/79/4f8fabcc5aca9305b494d1d6c7a98482e90a855e0050ae9ff5d7bf4ab2c6/levenshtein-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:7f071ecdb50aa6c15fd8ae5bcb67e9da46ba1df7bba7c6bf6803a54c7a41fd96", size = 100659, upload-time = "2025-03-02T19:43:40.082Z" }, + { url = "https://files.pythonhosted.org/packages/cb/81/f8e4c0f571c2aac2e0c56a6e0e41b679937a2b7013e79415e4aef555cff0/levenshtein-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:83b9033a984ccace7703f35b688f3907d55490182fd39b33a8e434d7b2e249e6", size = 88168, upload-time = "2025-03-02T19:43:41.42Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/30485fb9aee848542ee2d01aba85106a7f5da982ebeeffc619f70ea593c7/levenshtein-0.27.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ab00c2cae2889166afb7e1af64af2d4e8c1b126f3902d13ef3740df00e54032d", size = 173397, upload-time = "2025-03-02T19:43:42.553Z" }, + { url = "https://files.pythonhosted.org/packages/df/9f/40a81c54cfe74b22737710e654bd25ad934a675f737b60b24f84099540e0/levenshtein-0.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c27e00bc7527e282f7c437817081df8da4eb7054e7ef9055b851fa3947896560", size = 155787, upload-time = "2025-03-02T19:43:43.864Z" }, + { url = "https://files.pythonhosted.org/packages/df/98/915f4e24e21982b6eca2c0203546c160f4a83853fa6a2ac6e2b208a54afc/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5b07de42bfc051136cc8e7f1e7ba2cb73666aa0429930f4218efabfdc5837ad", size = 150013, upload-time = "2025-03-02T19:43:45.134Z" }, + { url = "https://files.pythonhosted.org/packages/80/93/9b0773107580416b9de14bf6a12bd1dd2b2964f7a9f6fb0e40723e1f0572/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fb11ad3c9dae3063405aa50d9c96923722ab17bb606c776b6817d70b51fd7e07", size = 181234, upload-time = "2025-03-02T19:43:47.125Z" }, + { url = "https://files.pythonhosted.org/packages/91/b1/3cd4f69af32d40de14808142cc743af3a1b737b25571bd5e8d2f46b885e0/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c5986fb46cb0c063305fd45b0a79924abf2959a6d984bbac2b511d3ab259f3f", size = 183697, upload-time = "2025-03-02T19:43:48.412Z" }, + { url = "https://files.pythonhosted.org/packages/bb/65/b691e502c6463f6965b7e0d8d84224c188aa35b53fbc85853c72a0e436c9/levenshtein-0.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75191e469269ddef2859bc64c4a8cfd6c9e063302766b5cb7e1e67f38cc7051a", size = 159964, upload-time = "2025-03-02T19:43:49.704Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c0/89a922a47306a475fb6d8f2ab08668f143d3dc7dea4c39d09e46746e031c/levenshtein-0.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51b3a7b2266933babc04e4d9821a495142eebd6ef709f90e24bc532b52b81385", size = 244759, upload-time = "2025-03-02T19:43:51.733Z" }, + { url = "https://files.pythonhosted.org/packages/b4/93/30283c6e69a6556b02e0507c88535df9613179f7b44bc49cdb4bc5e889a3/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbac509794afc3e2a9e73284c9e3d0aab5b1d928643f42b172969c3eefa1f2a3", size = 1115955, upload-time = "2025-03-02T19:43:53.739Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cf/7e19ea2c23671db02fbbe5a5a4aeafd1d471ee573a6251ae17008458c434/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8d68714785178347ecb272b94e85cbf7e638165895c4dd17ab57e7742d8872ec", size = 1400921, upload-time = "2025-03-02T19:43:55.146Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f7/fb42bfe2f3b46ef91f0fc6fa217b44dbeb4ef8c72a9c1917bbbe1cafc0f8/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8ee74ee31a5ab8f61cd6c6c6e9ade4488dde1285f3c12207afc018393c9b8d14", size = 1225037, upload-time = "2025-03-02T19:43:56.7Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/c86f8874ac7b0632b172d0d1622ed3ab9608a7f8fe85d41d632b16f5948e/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f2441b6365453ec89640b85344afd3d602b0d9972840b693508074c613486ce7", size = 1420601, upload-time = "2025-03-02T19:43:58.383Z" }, + { url = "https://files.pythonhosted.org/packages/20/fe/ebfbaadcd90ea7dfde987ae95b5c11dc27c2c5d55a2c4ccbbe4e18a8af7b/levenshtein-0.27.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a9be39640a46d8a0f9be729e641651d16a62b2c07d3f4468c36e1cc66b0183b9", size = 1188241, upload-time = "2025-03-02T19:44:00.976Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/aa6b07316e10781a6c5a5a8308f9bdc22213dc3911b959daa6d7ff654fc6/levenshtein-0.27.1-cp313-cp313-win32.whl", hash = "sha256:a520af67d976761eb6580e7c026a07eb8f74f910f17ce60e98d6e492a1f126c7", size = 88103, upload-time = "2025-03-02T19:44:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7b/9bbfd417f80f1047a28d0ea56a9b38b9853ba913b84dd5998785c5f98541/levenshtein-0.27.1-cp313-cp313-win_amd64.whl", hash = "sha256:7dd60aa49c2d8d23e0ef6452c8329029f5d092f386a177e3385d315cabb78f2a", size = 100579, upload-time = "2025-03-02T19:44:04.142Z" }, + { url = "https://files.pythonhosted.org/packages/8b/01/5f3ff775db7340aa378b250e2a31e6b4b038809a24ff0a3636ef20c7ca31/levenshtein-0.27.1-cp313-cp313-win_arm64.whl", hash = "sha256:149cd4f0baf5884ac5df625b7b0d281721b15de00f447080e38f5188106e1167", size = 87933, upload-time = "2025-03-02T19:44:05.364Z" }, + { url = "https://files.pythonhosted.org/packages/25/ed/37e2d1f5e690d7376cd7e8bdd19411479ff352a3df9ab5f845dd680ef779/levenshtein-0.27.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c92a222ab95b8d903eae6d5e7d51fe6c999be021b647715c18d04d0b0880f463", size = 170482, upload-time = "2025-03-02T19:44:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9f/30b1144b9d1da74743e7d7cdf47575b7013c9767e608c7454dbd318aacd2/levenshtein-0.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:71afc36b4ee950fa1140aff22ffda9e5e23280285858e1303260dbb2eabf342d", size = 153106, upload-time = "2025-03-02T19:44:31.489Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c5/18d0bec94a166cebaefa3db4beab9a7e0d75412b52e9626f5dce1ca8d149/levenshtein-0.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58b1daeebfc148a571f09cfe18c16911ea1eaaa9e51065c5f7e7acbc4b866afa", size = 150984, upload-time = "2025-03-02T19:44:32.697Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/4b80eb0c96caabdb683256cac9cc2cc9a73dee8ea80ab7cc3ee8aebd603f/levenshtein-0.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:105edcb14797d95c77f69bad23104314715a64cafbf4b0e79d354a33d7b54d8d", size = 158673, upload-time = "2025-03-02T19:44:33.998Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/a43daefbc6d5e5561176150363cbac73003795b85ae136ffd4d0691af3fb/levenshtein-0.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d9c58fb1ef8bdc8773d705fbacf628e12c3bb63ee4d065dda18a76e86042444a", size = 244419, upload-time = "2025-03-02T19:44:35.317Z" }, + { url = "https://files.pythonhosted.org/packages/d0/55/34f133f4f0998d7335bd96b9d315dc888b118e48e999c3d2c621b84965b9/levenshtein-0.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e52270591854af67217103955a36bd7436b57c801e3354e73ba44d689ed93697", size = 97932, upload-time = "2025-03-02T19:44:36.701Z" }, + { url = "https://files.pythonhosted.org/packages/7d/44/c5955d0b6830925559b00617d80c9f6e03a9b00c451835ee4da7010e71cd/levenshtein-0.27.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:909b7b6bce27a4ec90576c9a9bd9af5a41308dfecf364b410e80b58038277bbe", size = 170533, upload-time = "2025-03-02T19:44:38.096Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3f/858572d68b33e13a9c154b99f153317efe68381bf63cc4e986e820935fc3/levenshtein-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d193a7f97b8c6a350e36ec58e41a627c06fa4157c3ce4b2b11d90cfc3c2ebb8f", size = 153119, upload-time = "2025-03-02T19:44:39.388Z" }, + { url = "https://files.pythonhosted.org/packages/d1/60/2bd8d001ea4eb53ca16faa7a649d56005ba22b1bcc2a4f1617ab27ed7e48/levenshtein-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:614be316e3c06118705fae1f717f9072d35108e5fd4e66a7dd0e80356135340b", size = 149576, upload-time = "2025-03-02T19:44:40.617Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/0580797e1e4ac26cf67761a235b29b49f62d2b175dbbc609882f2aecd4e4/levenshtein-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31fc0a5bb070722bdabb6f7e14955a294a4a968c68202d294699817f21545d22", size = 157445, upload-time = "2025-03-02T19:44:41.901Z" }, + { url = "https://files.pythonhosted.org/packages/f4/de/9c171c96d1f15c900086d7212b5543a85539e767689fc4933d14048ba1ec/levenshtein-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9415aa5257227af543be65768a80c7a75e266c3c818468ce6914812f88f9c3df", size = 243141, upload-time = "2025-03-02T19:44:43.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1e/408fd10217eac0e43aea0604be22b4851a09e03d761d44d4ea12089dd70e/levenshtein-0.27.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7987ef006a3cf56a4532bd4c90c2d3b7b4ca9ad3bf8ae1ee5713c4a3bdfda913", size = 98045, upload-time = "2025-03-02T19:44:44.527Z" }, +] + +[[package]] +name = "m2c" +version = "0.1.0" +source = { git = "https://github.com/matt-kempster/m2c.git?rev=2853e096eb2368f601cd0f05119e98d74c2edca6#2853e096eb2368f601cd0f05119e98d74c2edca6" } +dependencies = [ + { name = "graphviz" }, +] + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" }, + { url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" }, + { url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" }, + { url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" }, + { url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" }, + { url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" }, + { url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" }, + { url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" }, + { url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" }, + { url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" }, + { url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "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 = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-tornado" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "setuptools" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/85/a7b43fb46dbd7d6c28e798491e5f7dc902e0fbbd1630b03307529faebf79/pytest-tornado-0.8.1.tar.gz", hash = "sha256:b9fa1930c333b9dfe29bde90f9f42e6643278b5d0c95528a8302a7454fd3f1b1", size = 9134, upload-time = "2020-06-17T12:04:51.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/7f/111eb35d4f4ea7a654294b76f3d98eee6c704282fd028ae37bfc259e2216/pytest_tornado-0.8.1-py2.py3-none-any.whl", hash = "sha256:8d43f400c64fabc85af58891096d4f93f05e9f9baae2c751e039932a0f62d0b5", size = 9585, upload-time = "2020-06-17T12:04:50.561Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rapidfuzz" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/fc/a98b616db9a42dcdda7c78c76bdfdf6fe290ac4c5ffbb186f73ec981ad5b/rapidfuzz-3.14.1.tar.gz", hash = "sha256:b02850e7f7152bd1edff27e9d584505b84968cacedee7a734ec4050c655a803c", size = 57869570, upload-time = "2025-09-08T21:08:15.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/b9/4e35178f405a1a95abd37cce4dc09d4a5bbc5e098687680b5ba796d3115b/rapidfuzz-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:489440e4b5eea0d150a31076eb183bed0ec84f934df206c72ae4fc3424501758", size = 1939645, upload-time = "2025-09-08T21:05:16.569Z" }, + { url = "https://files.pythonhosted.org/packages/51/af/fd7b8662a3b6952559af322dcf1c9d4eb5ec6be2697c30ae8ed3c44876ca/rapidfuzz-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eff22cc938c3f74d194df03790a6c3325d213b28cf65cdefd6fdeae759b745d5", size = 1393620, upload-time = "2025-09-08T21:05:18.598Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5b/5715445e29c1c6ba364b3d27278da3fdffb18d9147982e977c6638dcecbf/rapidfuzz-3.14.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0307f018b16feaa36074bcec2496f6f120af151a098910296e72e233232a62f", size = 1387721, upload-time = "2025-09-08T21:05:20.408Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/83a14a6a90982b090257c4b2e96b9b9c423a89012b8504d5a14d92a4f8c2/rapidfuzz-3.14.1-cp310-cp310-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bc133652da143aca1ab72de235446432888b2b7f44ee332d006f8207967ecb8a", size = 1694545, upload-time = "2025-09-08T21:05:22.137Z" }, + { url = "https://files.pythonhosted.org/packages/99/f7/94618fcaaac8c04abf364f405c6811a02bc9edef209f276dc513a9a50f7c/rapidfuzz-3.14.1-cp310-cp310-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e9e71b3fe7e4a1590843389a90fe2a8684649fc74b9b7446e17ee504ddddb7de", size = 2237075, upload-time = "2025-09-08T21:05:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/58/f6/a5ee2db25f36b0e5e06502fb77449b7718cd9f92ad36d598e669ba91db7b/rapidfuzz-3.14.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c51519eb2f20b52eba6fc7d857ae94acc6c2a1f5d0f2d794b9d4977cdc29dd7", size = 3168778, upload-time = "2025-09-08T21:05:25.508Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e8/c9620e358805c099e6755b7d2827b1e711b5e61914d6112ce2faa2c2af79/rapidfuzz-3.14.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:fe87d94602624f8f25fff9a0a7b47f33756c4d9fc32b6d3308bb142aa483b8a4", size = 1223827, upload-time = "2025-09-08T21:05:27.299Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/24916c3c3d55d6236474c9da0a595641d0013d3604de0625e8a8974371c3/rapidfuzz-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2d665380503a575dda52eb712ea521f789e8f8fd629c7a8e6c0f8ff480febc78", size = 2408366, upload-time = "2025-09-08T21:05:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/40/d4/4152e8821b5c548443a6c46568fccef13de5818a5ab370d553ea3d5955b3/rapidfuzz-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c0f0dd022b8a7cbf3c891f6de96a80ab6a426f1069a085327816cea749e096c2", size = 2530148, upload-time = "2025-09-08T21:05:30.782Z" }, + { url = "https://files.pythonhosted.org/packages/bd/af/6587c6d590abe232c530ad43fbfbcaec899bff7204e237f1fd21e2e44b81/rapidfuzz-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:bf1ba22d36858b265c95cd774ba7fe8991e80a99cd86fe4f388605b01aee81a3", size = 2810628, upload-time = "2025-09-08T21:05:32.844Z" }, + { url = "https://files.pythonhosted.org/packages/d7/90/a99e6cfd90feb9d770654f1f39321099bbbf7f85d2832f2ef48d3f4ebc5f/rapidfuzz-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ca1c1494ac9f9386d37f0e50cbaf4d07d184903aed7691549df1b37e9616edc9", size = 3314406, upload-time = "2025-09-08T21:05:34.585Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/eba5a6c217200fd1d3615997930a9e5db6a74e3002b7867b54545f9b5cbb/rapidfuzz-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9e4b12e921b0fa90d7c2248742a536f21eae5562174090b83edd0b4ab8b557d7", size = 4280030, upload-time = "2025-09-08T21:05:36.646Z" }, + { url = "https://files.pythonhosted.org/packages/04/6f/d2e060a2094cfb7f3cd487c376e098abb22601e0eea178e51a59ce0a3158/rapidfuzz-3.14.1-cp310-cp310-win32.whl", hash = "sha256:5e1c1f2292baa4049535b07e9e81feb29e3650d2ba35ee491e64aca7ae4cb15e", size = 1727070, upload-time = "2025-09-08T21:05:38.57Z" }, + { url = "https://files.pythonhosted.org/packages/73/0a/ca231464ec689f2aabf9547a52cbc76a10affe960bddde8660699ba3de33/rapidfuzz-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:59a8694beb9a13c4090ab3d1712cabbd896c6949706d1364e2a2e1713c413760", size = 1545335, upload-time = "2025-09-08T21:05:40.22Z" }, + { url = "https://files.pythonhosted.org/packages/59/c5/1e0b17f20fd3d701470548a6db8f36d589fb1a8a65d3828968547d987486/rapidfuzz-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:e94cee93faa792572c574a615abe12912124b4ffcf55876b72312914ab663345", size = 816960, upload-time = "2025-09-08T21:05:42.225Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c7/c3c860d512606225c11c8ee455b4dc0b0214dbcfac90a2c22dddf55320f3/rapidfuzz-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4d976701060886a791c8a9260b1d4139d14c1f1e9a6ab6116b45a1acf3baff67", size = 1938398, upload-time = "2025-09-08T21:05:44.031Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f3/67f5c5cd4d728993c48c1dcb5da54338d77c03c34b4903cc7839a3b89faf/rapidfuzz-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e6ba7e6eb2ab03870dcab441d707513db0b4264c12fba7b703e90e8b4296df2", size = 1392819, upload-time = "2025-09-08T21:05:45.549Z" }, + { url = "https://files.pythonhosted.org/packages/d5/06/400d44842f4603ce1bebeaeabe776f510e329e7dbf6c71b6f2805e377889/rapidfuzz-3.14.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e532bf46de5fd3a1efde73a16a4d231d011bce401c72abe3c6ecf9de681003f", size = 1391798, upload-time = "2025-09-08T21:05:47.044Z" }, + { url = "https://files.pythonhosted.org/packages/90/97/a6944955713b47d88e8ca4305ca7484940d808c4e6c4e28b6fa0fcbff97e/rapidfuzz-3.14.1-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9b6a6fb8ed9b951e5f3b82c1ce6b1665308ec1a0da87f799b16e24fc59e4662", size = 1699136, upload-time = "2025-09-08T21:05:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1e/f311a5c95ddf922db6dd8666efeceb9ac69e1319ed098ac80068a4041732/rapidfuzz-3.14.1-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b6ac3f9810949caef0e63380b11a3c32a92f26bacb9ced5e32c33560fcdf8d1", size = 2236238, upload-time = "2025-09-08T21:05:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/85/27/e14e9830255db8a99200f7111b158ddef04372cf6332a415d053fe57cc9c/rapidfuzz-3.14.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e52e4c34fd567f77513e886b66029c1ae02f094380d10eba18ba1c68a46d8b90", size = 3183685, upload-time = "2025-09-08T21:05:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/61/b2/42850c9616ddd2887904e5dd5377912cbabe2776fdc9fd4b25e6e12fba32/rapidfuzz-3.14.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:2ef72e41b1a110149f25b14637f1cedea6df192462120bea3433980fe9d8ac05", size = 1231523, upload-time = "2025-09-08T21:05:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/de/b5/6b90ed7127a1732efef39db46dd0afc911f979f215b371c325a2eca9cb15/rapidfuzz-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fb654a35b373d712a6b0aa2a496b2b5cdd9d32410cfbaecc402d7424a90ba72a", size = 2415209, upload-time = "2025-09-08T21:05:55.422Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/af51c50d238c82f2179edc4b9f799cc5a50c2c0ebebdcfaa97ded7d02978/rapidfuzz-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2b2c12e5b9eb8fe9a51b92fe69e9ca362c0970e960268188a6d295e1dec91e6d", size = 2532957, upload-time = "2025-09-08T21:05:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/50/92/29811d2ba7c984251a342c4f9ccc7cc4aa09d43d800af71510cd51c36453/rapidfuzz-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4f069dec5c450bd987481e752f0a9979e8fdf8e21e5307f5058f5c4bb162fa56", size = 2815720, upload-time = "2025-09-08T21:05:58.618Z" }, + { url = "https://files.pythonhosted.org/packages/78/69/cedcdee16a49e49d4985eab73b59447f211736c5953a58f1b91b6c53a73f/rapidfuzz-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4d0d9163725b7ad37a8c46988cae9ebab255984db95ad01bf1987ceb9e3058dd", size = 3323704, upload-time = "2025-09-08T21:06:00.576Z" }, + { url = "https://files.pythonhosted.org/packages/76/3e/5a3f9a5540f18e0126e36f86ecf600145344acb202d94b63ee45211a18b8/rapidfuzz-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db656884b20b213d846f6bc990c053d1f4a60e6d4357f7211775b02092784ca1", size = 4287341, upload-time = "2025-09-08T21:06:02.301Z" }, + { url = "https://files.pythonhosted.org/packages/46/26/45db59195929dde5832852c9de8533b2ac97dcc0d852d1f18aca33828122/rapidfuzz-3.14.1-cp311-cp311-win32.whl", hash = "sha256:4b42f7b9c58cbcfbfaddc5a6278b4ca3b6cd8983e7fd6af70ca791dff7105fb9", size = 1726574, upload-time = "2025-09-08T21:06:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/01/5c/a4caf76535f35fceab25b2aaaed0baecf15b3d1fd40746f71985d20f8c4b/rapidfuzz-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:e5847f30d7d4edefe0cb37294d956d3495dd127c1c56e9128af3c2258a520bb4", size = 1547124, upload-time = "2025-09-08T21:06:06.002Z" }, + { url = "https://files.pythonhosted.org/packages/c6/66/aa93b52f95a314584d71fa0b76df00bdd4158aafffa76a350f1ae416396c/rapidfuzz-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:5087d8ad453092d80c042a08919b1cb20c8ad6047d772dc9312acd834da00f75", size = 816958, upload-time = "2025-09-08T21:06:07.509Z" }, + { url = "https://files.pythonhosted.org/packages/df/77/2f4887c9b786f203e50b816c1cde71f96642f194e6fa752acfa042cf53fd/rapidfuzz-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:809515194f628004aac1b1b280c3734c5ea0ccbd45938c9c9656a23ae8b8f553", size = 1932216, upload-time = "2025-09-08T21:06:09.342Z" }, + { url = "https://files.pythonhosted.org/packages/de/bd/b5e445d156cb1c2a87d36d8da53daf4d2a1d1729b4851660017898b49aa0/rapidfuzz-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0afcf2d6cb633d0d4260d8df6a40de2d9c93e9546e2c6b317ab03f89aa120ad7", size = 1393414, upload-time = "2025-09-08T21:06:10.959Z" }, + { url = "https://files.pythonhosted.org/packages/de/bd/98d065dd0a4479a635df855616980eaae1a1a07a876db9400d421b5b6371/rapidfuzz-3.14.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1c3d07d53dcafee10599da8988d2b1f39df236aee501ecbd617bd883454fcd", size = 1377194, upload-time = "2025-09-08T21:06:12.471Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/1265547b771128b686f3c431377ff1db2fa073397ed082a25998a7b06d4e/rapidfuzz-3.14.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e9ee3e1eb0a027717ee72fe34dc9ac5b3e58119f1bd8dd15bc19ed54ae3e62b", size = 1669573, upload-time = "2025-09-08T21:06:14.016Z" }, + { url = "https://files.pythonhosted.org/packages/a8/57/e73755c52fb451f2054196404ccc468577f8da023b3a48c80bce29ee5d4a/rapidfuzz-3.14.1-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:70c845b64a033a20c44ed26bc890eeb851215148cc3e696499f5f65529afb6cb", size = 2217833, upload-time = "2025-09-08T21:06:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/20/14/7399c18c460e72d1b754e80dafc9f65cb42a46cc8f29cd57d11c0c4acc94/rapidfuzz-3.14.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26db0e815213d04234298dea0d884d92b9cb8d4ba954cab7cf67a35853128a33", size = 3159012, upload-time = "2025-09-08T21:06:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/f8/5e/24f0226ddb5440cabd88605d2491f99ae3748a6b27b0bc9703772892ced7/rapidfuzz-3.14.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:6ad3395a416f8b126ff11c788531f157c7debeb626f9d897c153ff8980da10fb", size = 1227032, upload-time = "2025-09-08T21:06:21.06Z" }, + { url = "https://files.pythonhosted.org/packages/40/43/1d54a4ad1a5fac2394d5f28a3108e2bf73c26f4f23663535e3139cfede9b/rapidfuzz-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61c5b9ab6f730e6478aa2def566223712d121c6f69a94c7cc002044799442afd", size = 2395054, upload-time = "2025-09-08T21:06:23.482Z" }, + { url = "https://files.pythonhosted.org/packages/0c/71/e9864cd5b0f086c4a03791f5dfe0155a1b132f789fe19b0c76fbabd20513/rapidfuzz-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:13e0ea3d0c533969158727d1bb7a08c2cc9a816ab83f8f0dcfde7e38938ce3e6", size = 2524741, upload-time = "2025-09-08T21:06:26.825Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0c/53f88286b912faf4a3b2619a60df4f4a67bd0edcf5970d7b0c1143501f0c/rapidfuzz-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6325ca435b99f4001aac919ab8922ac464999b100173317defb83eae34e82139", size = 2785311, upload-time = "2025-09-08T21:06:29.471Z" }, + { url = "https://files.pythonhosted.org/packages/53/9a/229c26dc4f91bad323f07304ee5ccbc28f0d21c76047a1e4f813187d0bad/rapidfuzz-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:07a9fad3247e68798424bdc116c1094e88ecfabc17b29edf42a777520347648e", size = 3303630, upload-time = "2025-09-08T21:06:31.094Z" }, + { url = "https://files.pythonhosted.org/packages/05/de/20e330d6d58cbf83da914accd9e303048b7abae2f198886f65a344b69695/rapidfuzz-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8ff5dbe78db0a10c1f916368e21d328935896240f71f721e073cf6c4c8cdedd", size = 4262364, upload-time = "2025-09-08T21:06:32.877Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/2327f83fad3534a8d69fe9cd718f645ec1fe828b60c0e0e97efc03bf12f8/rapidfuzz-3.14.1-cp312-cp312-win32.whl", hash = "sha256:9c83270e44a6ae7a39fc1d7e72a27486bccc1fa5f34e01572b1b90b019e6b566", size = 1711927, upload-time = "2025-09-08T21:06:34.669Z" }, + { url = "https://files.pythonhosted.org/packages/78/8d/199df0370133fe9f35bc72f3c037b53c93c5c1fc1e8d915cf7c1f6bb8557/rapidfuzz-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:e06664c7fdb51c708e082df08a6888fce4c5c416d7e3cc2fa66dd80eb76a149d", size = 1542045, upload-time = "2025-09-08T21:06:36.364Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c6/cc5d4bd1b16ea2657c80b745d8b1c788041a31fad52e7681496197b41562/rapidfuzz-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:6c7c26025f7934a169a23dafea6807cfc3fb556f1dd49229faf2171e5d8101cc", size = 813170, upload-time = "2025-09-08T21:06:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/0024cc8eead108c4c29337abe133d72ddf3406ce9bbfbcfc110414a7ea07/rapidfuzz-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d69f470d63ee824132ecd80b1974e1d15dd9df5193916901d7860cef081a260", size = 1926515, upload-time = "2025-09-08T21:06:39.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/ae/6cb211f8930bea20fa989b23f31ee7f92940caaf24e3e510d242a1b28de4/rapidfuzz-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6f571d20152fc4833b7b5e781b36d5e4f31f3b5a596a3d53cf66a1bd4436b4f4", size = 1388431, upload-time = "2025-09-08T21:06:41.73Z" }, + { url = "https://files.pythonhosted.org/packages/39/88/bfec24da0607c39e5841ced5594ea1b907d20f83adf0e3ee87fa454a425b/rapidfuzz-3.14.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61d77e09b2b6bc38228f53b9ea7972a00722a14a6048be9a3672fb5cb08bad3a", size = 1375664, upload-time = "2025-09-08T21:06:43.737Z" }, + { url = "https://files.pythonhosted.org/packages/f4/43/9f282ba539e404bdd7052c7371d3aaaa1a9417979d2a1d8332670c7f385a/rapidfuzz-3.14.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b41d95ef86a6295d353dc3bb6c80550665ba2c3bef3a9feab46074d12a9af8f", size = 1668113, upload-time = "2025-09-08T21:06:45.758Z" }, + { url = "https://files.pythonhosted.org/packages/7f/2f/0b3153053b1acca90969eb0867922ac8515b1a8a48706a3215c2db60e87c/rapidfuzz-3.14.1-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0591df2e856ad583644b40a2b99fb522f93543c65e64b771241dda6d1cfdc96b", size = 2212875, upload-time = "2025-09-08T21:06:47.447Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/623001dddc518afaa08ed1fbbfc4005c8692b7a32b0f08b20c506f17a770/rapidfuzz-3.14.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f277801f55b2f3923ef2de51ab94689a0671a4524bf7b611de979f308a54cd6f", size = 3161181, upload-time = "2025-09-08T21:06:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b7/d8404ed5ad56eb74463e5ebf0a14f0019d7eb0e65e0323f709fe72e0884c/rapidfuzz-3.14.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:893fdfd4f66ebb67f33da89eb1bd1674b7b30442fdee84db87f6cb9074bf0ce9", size = 1225495, upload-time = "2025-09-08T21:06:51.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6c/b96af62bc7615d821e3f6b47563c265fd7379d7236dfbc1cbbcce8beb1d2/rapidfuzz-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fe2651258c1f1afa9b66f44bf82f639d5f83034f9804877a1bbbae2120539ad1", size = 2396294, upload-time = "2025-09-08T21:06:53.063Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b7/c60c9d22a7debed8b8b751f506a4cece5c22c0b05e47a819d6b47bc8c14e/rapidfuzz-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ace21f7a78519d8e889b1240489cd021c5355c496cb151b479b741a4c27f0a25", size = 2529629, upload-time = "2025-09-08T21:06:55.188Z" }, + { url = "https://files.pythonhosted.org/packages/25/94/a9ec7ccb28381f14de696ffd51c321974762f137679df986f5375d35264f/rapidfuzz-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cb5acf24590bc5e57027283b015950d713f9e4d155fda5cfa71adef3b3a84502", size = 2782960, upload-time = "2025-09-08T21:06:57.339Z" }, + { url = "https://files.pythonhosted.org/packages/68/80/04e5276d223060eca45250dbf79ea39940c0be8b3083661d58d57572c2c5/rapidfuzz-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:67ea46fa8cc78174bad09d66b9a4b98d3068e85de677e3c71ed931a1de28171f", size = 3298427, upload-time = "2025-09-08T21:06:59.319Z" }, + { url = "https://files.pythonhosted.org/packages/4a/63/24759b2a751562630b244e68ccaaf7a7525c720588fcc77c964146355aee/rapidfuzz-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:44e741d785de57d1a7bae03599c1cbc7335d0b060a35e60c44c382566e22782e", size = 4267736, upload-time = "2025-09-08T21:07:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/18/a4/73f1b1f7f44d55f40ffbffe85e529eb9d7e7f7b2ffc0931760eadd163995/rapidfuzz-3.14.1-cp313-cp313-win32.whl", hash = "sha256:b1fe6001baa9fa36bcb565e24e88830718f6c90896b91ceffcb48881e3adddbc", size = 1710515, upload-time = "2025-09-08T21:07:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8b/a8fe5a6ee4d06fd413aaa9a7e0a23a8630c4b18501509d053646d18c2aa7/rapidfuzz-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:83b8cc6336709fa5db0579189bfd125df280a554af544b2dc1c7da9cdad7e44d", size = 1540081, upload-time = "2025-09-08T21:07:05.401Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/4b0ac16c118a2367d85450b45251ee5362661e9118a1cef88aae1765ffff/rapidfuzz-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:cf75769662eadf5f9bd24e865c19e5ca7718e879273dce4e7b3b5824c4da0eb4", size = 812725, upload-time = "2025-09-08T21:07:07.148Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cb/1ad9a76d974d153783f8e0be8dbe60ec46488fac6e519db804e299e0da06/rapidfuzz-3.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d937dbeda71c921ef6537c6d41a84f1b8112f107589c9977059de57a1d726dd6", size = 1945173, upload-time = "2025-09-08T21:07:08.893Z" }, + { url = "https://files.pythonhosted.org/packages/d9/61/959ed7460941d8a81cbf6552b9c45564778a36cf5e5aa872558b30fc02b2/rapidfuzz-3.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7a2d80cc1a4fcc7e259ed4f505e70b36433a63fa251f1bb69ff279fe376c5efd", size = 1413949, upload-time = "2025-09-08T21:07:11.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/f46fca44457ca1f25f23cc1f06867454fc3c3be118cd10b552b0ab3e58a2/rapidfuzz-3.14.1-cp313-cp313t-win32.whl", hash = "sha256:40875e0c06f1a388f1cab3885744f847b557e0b1642dfc31ff02039f9f0823ef", size = 1760666, upload-time = "2025-09-08T21:07:12.884Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d0/7a5d9c04446f8b66882b0fae45b36a838cf4d31439b5d1ab48a9d17c8e57/rapidfuzz-3.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:876dc0c15552f3d704d7fb8d61bdffc872ff63bedf683568d6faad32e51bbce8", size = 1579760, upload-time = "2025-09-08T21:07:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/4e/aa/2c03ae112320d0746f2c869cae68c413f3fe3b6403358556f2b747559723/rapidfuzz-3.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:61458e83b0b3e2abc3391d0953c47d6325e506ba44d6a25c869c4401b3bc222c", size = 832088, upload-time = "2025-09-08T21:07:17.03Z" }, + { url = "https://files.pythonhosted.org/packages/d6/36/53debca45fbe693bd6181fb05b6a2fd561c87669edb82ec0d7c1961a43f0/rapidfuzz-3.14.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e84d9a844dc2e4d5c4cabd14c096374ead006583304333c14a6fbde51f612a44", size = 1926336, upload-time = "2025-09-08T21:07:18.809Z" }, + { url = "https://files.pythonhosted.org/packages/ae/32/b874f48609665fcfeaf16cbaeb2bbc210deef2b88e996c51cfc36c3eb7c3/rapidfuzz-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:40301b93b99350edcd02dbb22e37ca5f2a75d0db822e9b3c522da451a93d6f27", size = 1389653, upload-time = "2025-09-08T21:07:20.667Z" }, + { url = "https://files.pythonhosted.org/packages/97/25/f6c5a1ff4ec11edadacb270e70b8415f51fa2f0d5730c2c552b81651fbe3/rapidfuzz-3.14.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fedd5097a44808dddf341466866e5c57a18a19a336565b4ff50aa8f09eb528f6", size = 1380911, upload-time = "2025-09-08T21:07:22.584Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/d322202ef8fab463759b51ebfaa33228100510c82e6153bd7a922e150270/rapidfuzz-3.14.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e3e61c9e80d8c26709d8aa5c51fdd25139c81a4ab463895f8a567f8347b0548", size = 1673515, upload-time = "2025-09-08T21:07:24.417Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b9/6b2a97f4c6be96cac3749f32301b8cdf751ce5617b1c8934c96586a0662b/rapidfuzz-3.14.1-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da011a373722fac6e64687297a1d17dc8461b82cb12c437845d5a5b161bc24b9", size = 2219394, upload-time = "2025-09-08T21:07:26.402Z" }, + { url = "https://files.pythonhosted.org/packages/11/bf/afb76adffe4406e6250f14ce48e60a7eb05d4624945bd3c044cfda575fbc/rapidfuzz-3.14.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5967d571243cfb9ad3710e6e628ab68c421a237b76e24a67ac22ee0ff12784d6", size = 3163582, upload-time = "2025-09-08T21:07:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/e6405227560f61e956cb4c5de653b0f874751c5ada658d3532d6c1df328e/rapidfuzz-3.14.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:474f416cbb9099676de54aa41944c154ba8d25033ee460f87bb23e54af6d01c9", size = 1221116, upload-time = "2025-09-08T21:07:30.8Z" }, + { url = "https://files.pythonhosted.org/packages/55/e6/5b757e2e18de384b11d1daf59608453f0baf5d5d8d1c43e1a964af4dc19a/rapidfuzz-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae2d57464b59297f727c4e201ea99ec7b13935f1f056c753e8103da3f2fc2404", size = 2402670, upload-time = "2025-09-08T21:07:32.702Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/d753a415fe54531aa882e288db5ed77daaa72e05c1a39e1cbac00d23024f/rapidfuzz-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:57047493a1f62f11354c7143c380b02f1b355c52733e6b03adb1cb0fe8fb8816", size = 2521659, upload-time = "2025-09-08T21:07:35.218Z" }, + { url = "https://files.pythonhosted.org/packages/cd/28/d4e7fe1515430db98f42deb794c7586a026d302fe70f0216b638d89cf10f/rapidfuzz-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4acc20776f225ee37d69517a237c090b9fa7e0836a0b8bc58868e9168ba6ef6f", size = 2788552, upload-time = "2025-09-08T21:07:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/4f/00/eab05473af7a2cafb4f3994bc6bf408126b8eec99a569aac6254ac757db4/rapidfuzz-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4373f914ff524ee0146919dea96a40a8200ab157e5a15e777a74a769f73d8a4a", size = 3306261, upload-time = "2025-09-08T21:07:39.624Z" }, + { url = "https://files.pythonhosted.org/packages/d1/31/2feb8dfcfcff6508230cd2ccfdde7a8bf988c6fda142fe9ce5d3eb15704d/rapidfuzz-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:37017b84953927807847016620d61251fe236bd4bcb25e27b6133d955bb9cafb", size = 4269522, upload-time = "2025-09-08T21:07:41.663Z" }, + { url = "https://files.pythonhosted.org/packages/a3/99/250538d73c8fbab60597c3d131a11ef2a634d38b44296ca11922794491ac/rapidfuzz-3.14.1-cp314-cp314-win32.whl", hash = "sha256:c8d1dd1146539e093b84d0805e8951475644af794ace81d957ca612e3eb31598", size = 1745018, upload-time = "2025-09-08T21:07:44.313Z" }, + { url = "https://files.pythonhosted.org/packages/c5/15/d50839d20ad0743aded25b08a98ffb872f4bfda4e310bac6c111fcf6ea1f/rapidfuzz-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:f51c7571295ea97387bac4f048d73cecce51222be78ed808263b45c79c40a440", size = 1587666, upload-time = "2025-09-08T21:07:46.917Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ff/d73fec989213fb6f0b6f15ee4bbdf2d88b0686197951a06b036111cd1c7d/rapidfuzz-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:01eab10ec90912d7d28b3f08f6c91adbaf93458a53f849ff70776ecd70dd7a7a", size = 835780, upload-time = "2025-09-08T21:07:49.256Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e7/f0a242687143cebd33a1fb165226b73bd9496d47c5acfad93de820a18fa8/rapidfuzz-3.14.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:60879fcae2f7618403c4c746a9a3eec89327d73148fb6e89a933b78442ff0669", size = 1945182, upload-time = "2025-09-08T21:07:51.84Z" }, + { url = "https://files.pythonhosted.org/packages/96/29/ca8a3f8525e3d0e7ab49cb927b5fb4a54855f794c9ecd0a0b60a6c96a05f/rapidfuzz-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f94d61e44db3fc95a74006a394257af90fa6e826c900a501d749979ff495d702", size = 1413946, upload-time = "2025-09-08T21:07:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ef/6fd10aa028db19c05b4ac7fe77f5613e4719377f630c709d89d7a538eea2/rapidfuzz-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:93b6294a3ffab32a9b5f9b5ca048fa0474998e7e8bb0f2d2b5e819c64cb71ec7", size = 1795851, upload-time = "2025-09-08T21:07:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/e4/30/acd29ebd906a50f9e0f27d5f82a48cf5e8854637b21489bd81a2459985cf/rapidfuzz-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:6cb56b695421538fdbe2c0c85888b991d833b8637d2f2b41faa79cea7234c000", size = 1626748, upload-time = "2025-09-08T21:07:58.166Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f4/dfc7b8c46b1044a47f7ca55deceb5965985cff3193906cb32913121e6652/rapidfuzz-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7cd312c380d3ce9d35c3ec9726b75eee9da50e8a38e89e229a03db2262d3d96b", size = 853771, upload-time = "2025-09-08T21:08:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/6d/10/0ed838b296fdac08ecbaa3a220fb4f1d887ff41b0be44fe8eade45bb650e/rapidfuzz-3.14.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:673ce55a9be5b772dade911909e42382c0828b8a50ed7f9168763fa6b9f7054d", size = 1860246, upload-time = "2025-09-08T21:08:02.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/70/a08f4a86387dec97508ead51cc7a4b3130d4e62ac0eae938a6d8e1feff14/rapidfuzz-3.14.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:45c62ada1980ebf4c64c4253993cc8daa018c63163f91db63bb3af69cb74c2e3", size = 1336749, upload-time = "2025-09-08T21:08:04.783Z" }, + { url = "https://files.pythonhosted.org/packages/d4/39/c12f76f69184bcfb9977d6404b2c5dac7dd4d70ee6803e61556e539d0097/rapidfuzz-3.14.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4d51efb29c0df0d4f7f64f672a7624c2146527f0745e3572098d753676538800", size = 1512629, upload-time = "2025-09-08T21:08:06.697Z" }, + { url = "https://files.pythonhosted.org/packages/05/c7/1b17347e30f2b50dd976c54641aa12003569acb1bdaabf45a5cc6f471c58/rapidfuzz-3.14.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4a21ccdf1bd7d57a1009030527ba8fae1c74bf832d0a08f6b67de8f5c506c96f", size = 1862602, upload-time = "2025-09-08T21:08:09.088Z" }, + { url = "https://files.pythonhosted.org/packages/09/cf/95d0dacac77eda22499991bd5f304c77c5965fb27348019a48ec3fe4a3f6/rapidfuzz-3.14.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:589fb0af91d3aff318750539c832ea1100dbac2c842fde24e42261df443845f6", size = 1339548, upload-time = "2025-09-08T21:08:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/b6/58/f515c44ba8c6fa5daa35134b94b99661ced852628c5505ead07b905c3fc7/rapidfuzz-3.14.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a4f18092db4825f2517d135445015b40033ed809a41754918a03ef062abe88a0", size = 1513859, upload-time = "2025-09-08T21:08:13.07Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "ruff" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/33/c8e89216845615d14d2d42ba2bee404e7206a8db782f33400754f3799f05/ruff-0.13.1.tar.gz", hash = "sha256:88074c3849087f153d4bb22e92243ad4c1b366d7055f98726bc19aa08dc12d51", size = 5397987, upload-time = "2025-09-18T19:52:44.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/41/ca37e340938f45cfb8557a97a5c347e718ef34702546b174e5300dbb1f28/ruff-0.13.1-py3-none-linux_armv6l.whl", hash = "sha256:b2abff595cc3cbfa55e509d89439b5a09a6ee3c252d92020bd2de240836cf45b", size = 12304308, upload-time = "2025-09-18T19:51:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/ff/84/ba378ef4129415066c3e1c80d84e539a0d52feb250685091f874804f28af/ruff-0.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4ee9f4249bf7f8bb3984c41bfaf6a658162cdb1b22e3103eabc7dd1dc5579334", size = 12937258, upload-time = "2025-09-18T19:52:00.184Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b6/ec5e4559ae0ad955515c176910d6d7c93edcbc0ed1a3195a41179c58431d/ruff-0.13.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c5da4af5f6418c07d75e6f3224e08147441f5d1eac2e6ce10dcce5e616a3bae", size = 12214554, upload-time = "2025-09-18T19:52:02.753Z" }, + { url = "https://files.pythonhosted.org/packages/70/d6/cb3e3b4f03b9b0c4d4d8f06126d34b3394f6b4d764912fe80a1300696ef6/ruff-0.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80524f84a01355a59a93cef98d804e2137639823bcee2931f5028e71134a954e", size = 12448181, upload-time = "2025-09-18T19:52:05.279Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ea/bf60cb46d7ade706a246cd3fb99e4cfe854efa3dfbe530d049c684da24ff/ruff-0.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff7f5ce8d7988767dd46a148192a14d0f48d1baea733f055d9064875c7d50389", size = 12104599, upload-time = "2025-09-18T19:52:07.497Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3e/05f72f4c3d3a69e65d55a13e1dd1ade76c106d8546e7e54501d31f1dc54a/ruff-0.13.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c55d84715061f8b05469cdc9a446aa6c7294cd4bd55e86a89e572dba14374f8c", size = 13791178, upload-time = "2025-09-18T19:52:10.189Z" }, + { url = "https://files.pythonhosted.org/packages/81/e7/01b1fc403dd45d6cfe600725270ecc6a8f8a48a55bc6521ad820ed3ceaf8/ruff-0.13.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac57fed932d90fa1624c946dc67a0a3388d65a7edc7d2d8e4ca7bddaa789b3b0", size = 14814474, upload-time = "2025-09-18T19:52:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/fa/92/d9e183d4ed6185a8df2ce9faa3f22e80e95b5f88d9cc3d86a6d94331da3f/ruff-0.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c366a71d5b4f41f86a008694f7a0d75fe409ec298685ff72dc882f882d532e36", size = 14217531, upload-time = "2025-09-18T19:52:15.245Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4a/6ddb1b11d60888be224d721e01bdd2d81faaf1720592858ab8bac3600466/ruff-0.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4ea9d1b5ad3e7a83ee8ebb1229c33e5fe771e833d6d3dcfca7b77d95b060d38", size = 13265267, upload-time = "2025-09-18T19:52:17.649Z" }, + { url = "https://files.pythonhosted.org/packages/81/98/3f1d18a8d9ea33ef2ad508f0417fcb182c99b23258ec5e53d15db8289809/ruff-0.13.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0f70202996055b555d3d74b626406476cc692f37b13bac8828acff058c9966a", size = 13243120, upload-time = "2025-09-18T19:52:20.332Z" }, + { url = "https://files.pythonhosted.org/packages/8d/86/b6ce62ce9c12765fa6c65078d1938d2490b2b1d9273d0de384952b43c490/ruff-0.13.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f8cff7a105dad631085d9505b491db33848007d6b487c3c1979dd8d9b2963783", size = 13443084, upload-time = "2025-09-18T19:52:23.032Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6e/af7943466a41338d04503fb5a81b2fd07251bd272f546622e5b1599a7976/ruff-0.13.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9761e84255443316a258dd7dfbd9bfb59c756e52237ed42494917b2577697c6a", size = 12295105, upload-time = "2025-09-18T19:52:25.263Z" }, + { url = "https://files.pythonhosted.org/packages/3f/97/0249b9a24f0f3ebd12f007e81c87cec6d311de566885e9309fcbac5b24cc/ruff-0.13.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:3d376a88c3102ef228b102211ef4a6d13df330cb0f5ca56fdac04ccec2a99700", size = 12072284, upload-time = "2025-09-18T19:52:27.478Z" }, + { url = "https://files.pythonhosted.org/packages/f6/85/0b64693b2c99d62ae65236ef74508ba39c3febd01466ef7f354885e5050c/ruff-0.13.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cbefd60082b517a82c6ec8836989775ac05f8991715d228b3c1d86ccc7df7dae", size = 12970314, upload-time = "2025-09-18T19:52:30.212Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/342e9f28179915d28b3747b7654f932ca472afbf7090fc0c4011e802f494/ruff-0.13.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:dd16b9a5a499fe73f3c2ef09a7885cb1d97058614d601809d37c422ed1525317", size = 13422360, upload-time = "2025-09-18T19:52:32.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/54/6177a0dc10bce6f43e392a2192e6018755473283d0cf43cc7e6afc182aea/ruff-0.13.1-py3-none-win32.whl", hash = "sha256:55e9efa692d7cb18580279f1fbb525146adc401f40735edf0aaeabd93099f9a0", size = 12178448, upload-time = "2025-09-18T19:52:35.545Z" }, + { url = "https://files.pythonhosted.org/packages/64/51/c6a3a33d9938007b8bdc8ca852ecc8d810a407fb513ab08e34af12dc7c24/ruff-0.13.1-py3-none-win_amd64.whl", hash = "sha256:3a3fb595287ee556de947183489f636b9f76a72f0fa9c028bdcabf5bab2cc5e5", size = 13286458, upload-time = "2025-09-18T19:52:38.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/04/afc078a12cf68592345b1e2d6ecdff837d286bac023d7a22c54c7a698c5b/ruff-0.13.1-py3-none-win_arm64.whl", hash = "sha256:c0bae9ffd92d54e03c2bf266f466da0a65e145f298ee5b5846ed435f6a00518a", size = 12437893, upload-time = "2025-09-18T19:52:41.283Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429, upload-time = "2024-11-27T22:37:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067, upload-time = "2024-11-27T22:37:57.63Z" }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030, upload-time = "2024-11-27T22:37:59.344Z" }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898, upload-time = "2024-11-27T22:38:00.429Z" }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894, upload-time = "2024-11-27T22:38:02.094Z" }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319, upload-time = "2024-11-27T22:38:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273, upload-time = "2024-11-27T22:38:04.217Z" }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310, upload-time = "2024-11-27T22:38:05.908Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309, upload-time = "2024-11-27T22:38:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762, upload-time = "2024-11-27T22:38:07.731Z" }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453, upload-time = "2024-11-27T22:38:09.384Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486, upload-time = "2024-11-27T22:38:10.329Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349, upload-time = "2024-11-27T22:38:11.443Z" }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159, upload-time = "2024-11-27T22:38:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243, upload-time = "2024-11-27T22:38:14.766Z" }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645, upload-time = "2024-11-27T22:38:15.843Z" }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584, upload-time = "2024-11-27T22:38:17.645Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875, upload-time = "2024-11-27T22:38:19.159Z" }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418, upload-time = "2024-11-27T22:38:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708, upload-time = "2024-11-27T22:38:21.659Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582, upload-time = "2024-11-27T22:38:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543, upload-time = "2024-11-27T22:38:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691, upload-time = "2024-11-27T22:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170, upload-time = "2024-11-27T22:38:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530, upload-time = "2024-11-27T22:38:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666, upload-time = "2024-11-27T22:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954, upload-time = "2024-11-27T22:38:31.702Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724, upload-time = "2024-11-27T22:38:32.837Z" }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383, upload-time = "2024-11-27T22:38:34.455Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/09/ce/1eb500eae19f4648281bb2186927bb062d2438c2e5093d1360391afd2f90/tornado-6.5.2.tar.gz", hash = "sha256:ab53c8f9a0fa351e2c0741284e06c7a45da86afb544133201c5cc8578eb076a0", size = 510821, upload-time = "2025-08-08T18:27:00.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/48/6a7529df2c9cc12efd2e8f5dd219516184d703b34c06786809670df5b3bd/tornado-6.5.2-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2436822940d37cde62771cff8774f4f00b3c8024fe482e16ca8387b8a2724db6", size = 442563, upload-time = "2025-08-08T18:26:42.945Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b5/9b575a0ed3e50b00c40b08cbce82eb618229091d09f6d14bce80fc01cb0b/tornado-6.5.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:583a52c7aa94ee046854ba81d9ebb6c81ec0fd30386d96f7640c96dad45a03ef", size = 440729, upload-time = "2025-08-08T18:26:44.473Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4e/619174f52b120efcf23633c817fd3fed867c30bff785e2cd5a53a70e483c/tornado-6.5.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0fe179f28d597deab2842b86ed4060deec7388f1fd9c1b4a41adf8af058907e", size = 444295, upload-time = "2025-08-08T18:26:46.021Z" }, + { url = "https://files.pythonhosted.org/packages/95/fa/87b41709552bbd393c85dd18e4e3499dcd8983f66e7972926db8d96aa065/tornado-6.5.2-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b186e85d1e3536d69583d2298423744740986018e393d0321df7340e71898882", size = 443644, upload-time = "2025-08-08T18:26:47.625Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/fb15f06e33d7430ca89420283a8762a4e6b8025b800ea51796ab5e6d9559/tornado-6.5.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e792706668c87709709c18b353da1f7662317b563ff69f00bab83595940c7108", size = 443878, upload-time = "2025-08-08T18:26:50.599Z" }, + { url = "https://files.pythonhosted.org/packages/11/92/fe6d57da897776ad2e01e279170ea8ae726755b045fe5ac73b75357a5a3f/tornado-6.5.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:06ceb1300fd70cb20e43b1ad8aaee0266e69e7ced38fa910ad2e03285009ce7c", size = 444549, upload-time = "2025-08-08T18:26:51.864Z" }, + { url = "https://files.pythonhosted.org/packages/9b/02/c8f4f6c9204526daf3d760f4aa555a7a33ad0e60843eac025ccfd6ff4a93/tornado-6.5.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:74db443e0f5251be86cbf37929f84d8c20c27a355dd452a5cfa2aada0d001ec4", size = 443973, upload-time = "2025-08-08T18:26:53.625Z" }, + { url = "https://files.pythonhosted.org/packages/ae/2d/f5f5707b655ce2317190183868cd0f6822a1121b4baeae509ceb9590d0bd/tornado-6.5.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e735ab2889d7ed33b32a459cac490eda71a1ba6857b0118de476ab6c366c04", size = 443954, upload-time = "2025-08-08T18:26:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/e8/59/593bd0f40f7355806bf6573b47b8c22f8e1374c9b6fd03114bd6b7a3dcfd/tornado-6.5.2-cp39-abi3-win32.whl", hash = "sha256:c6f29e94d9b37a95013bb669616352ddb82e3bfe8326fccee50583caebc8a5f0", size = 445023, upload-time = "2025-08-08T18:26:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2a/f609b420c2f564a748a2d80ebfb2ee02a73ca80223af712fca591386cafb/tornado-6.5.2-cp39-abi3-win_amd64.whl", hash = "sha256:e56a5af51cc30dd2cae649429af65ca2f6571da29504a07995175df14c18f35f", size = 445427, upload-time = "2025-08-08T18:26:57.91Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e1f65e8f8c76d73658b33d33b81eed4322fb5085350e4328d5c956f0c8f9/tornado-6.5.2-cp39-abi3-win_arm64.whl", hash = "sha256:d6c33dc3672e3a1f3618eb63b7ef4683a7688e7b9e6e8f0d9aa5726360a004af", size = 444456, upload-time = "2025-08-08T18:26:59.207Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] diff --git a/deploy.py b/deploy.py index 38a5075be..8a3dcca5f 100644 --- a/deploy.py +++ b/deploy.py @@ -14,10 +14,11 @@ DOCKER_COMPOSE = ["docker", "compose", "-f", "docker-compose.prod.yaml"] SLOTS = {"blue", "green"} -INFRA_SERVICES = ["postgres", "nginx", "certbot"] +INFRA_SERVICES = ["postgres", "cromper", "nginx", "certbot"] BLUE_TAG = "BLUE_TAG" GREEN_TAG = "GREEN_TAG" NGINX_TAG = "NGINX_TAG" +CROMPER_TAG = "CROMPER_TAG" ACTIVE_SLOT = "ACTIVE_SLOT" SLOT_COLORS = { "blue": "\033[34m", @@ -51,7 +52,7 @@ def read_env_file(): def write_env_file(data): - keys = [ACTIVE_SLOT, BLUE_TAG, GREEN_TAG, NGINX_TAG] + keys = [ACTIVE_SLOT, BLUE_TAG, GREEN_TAG, NGINX_TAG, CROMPER_TAG] lines = [] for key in keys: @@ -73,6 +74,7 @@ def compose_env(state): env.setdefault(BLUE_TAG, "latest") env.setdefault(GREEN_TAG, "latest") env.setdefault(NGINX_TAG, "latest") + env.setdefault(CROMPER_TAG, "latest") return env @@ -232,6 +234,7 @@ def smoke_test(slot, env): print(f"Smoke testing {slot} from nginx...") nginx_fetch(f"http://backend-{slot}:8000/api/healthz", env) nginx_fetch(f"http://frontend-{slot}:8080/healthz", env) + nginx_fetch("http://cromper:8888/health", env) def nginx_test_and_reload(env): @@ -254,6 +257,7 @@ def print_status(state, env): print(f" blue tag: {state.get(BLUE_TAG, 'unset')}") print(f" green tag: {state.get(GREEN_TAG, 'unset')}") print(f" nginx tag: {state.get(NGINX_TAG, 'latest')}") + print(f" cromper tag: {state.get(CROMPER_TAG, 'latest')}") print() print("Slot health:") @@ -349,6 +353,7 @@ def cmd_ensure(args): env = compose_env(state) pre_nginx_services = [ "postgres", + "cromper", "certbot", f"backend-{active}", f"frontend-{active}", @@ -358,6 +363,7 @@ def cmd_ensure(args): ensure_services(pre_nginx_services, env) wait_for_healthy("postgres", env) + wait_for_healthy("cromper", env) wait_for_healthy("certbot", env) wait_for_healthy(f"backend-{active}", env) wait_for_healthy(f"frontend-{active}", env) diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index 4ebbb7890..8a93dc2e2 100644 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -2,12 +2,6 @@ x-backend-base: &backend-base build: context: backend target: prod - cap_drop: - - all - cap_add: - - setuid - - setgid - - setfcap env_file: - docker.prod.env restart: unless-stopped @@ -17,21 +11,11 @@ x-backend-base: &backend-base timeout: 3s retries: 10 start_period: 20s - security_opt: - - apparmor=unconfined - - seccomp=unconfined volumes: - # persist compilers + libraries - - ./backend/compilers:/backend/compilers - - ./backend/libraries:/backend/libraries - # static files for django /admin control panel - ./backend/static:/backend/static - tmpfs: - # Use a separate tmpfs to prevent a rogue jailed process - # from filling /tmp on the parent container - - /sandbox/tmp:exec,uid=1000,gid=1000,size=64M,mode=0700 networks: - decompme + x-frontend-base: &frontend-base build: context: frontend @@ -49,23 +33,58 @@ x-frontend-base: &frontend-base - decompme services: + cromper: + image: ghcr.io/decompme/decompme-cromper:${CROMPER_TAG:-latest} + build: + context: cromper + target: prod + cap_drop: + - all + cap_add: + - setuid + - setgid + - setfcap + env_file: + - docker.prod.env + restart: unless-stopped + security_opt: + - apparmor=unconfined + - seccomp=unconfined + volumes: + - ./cromper/compilers:/cromper/compilers:ro + - ./cromper/libraries:/cromper/libraries:ro + tmpfs: + - /tmp/sandbox:exec,uid=1000,gid=1000,size=256M,mode=0700 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8888/health"] + interval: 15s + timeout: 3s + retries: 10 + start_period: 20s + networks: + - decompme + backend-blue: <<: *backend-base image: ghcr.io/decompme/decompme-backend:${BLUE_TAG:-latest} + frontend-blue: <<: *frontend-base image: ghcr.io/decompme/decompme-frontend:${BLUE_TAG:-latest} environment: INTERNAL_API_BASE: http://backend-blue:8000/api + INTERNAL_CROMPER_BASE: http://cromper:8888 backend-green: <<: *backend-base image: ghcr.io/decompme/decompme-backend:${GREEN_TAG:-latest} + frontend-green: <<: *frontend-base image: ghcr.io/decompme/decompme-frontend:${GREEN_TAG:-latest} environment: INTERNAL_API_BASE: http://backend-green:8000/api + INTERNAL_CROMPER_BASE: http://cromper:8888 nginx: image: ghcr.io/decompme/decompme-nginx:${NGINX_TAG:-latest} @@ -82,21 +101,12 @@ services: - "443:443" restart: unless-stopped volumes: - # repo files - ./nginx/production/:/etc/nginx/conf.d/:ro - ./frontend/down.html:/var/www/decomp.me/down.html:ro - # certbot - ./certbot:/var/www/certbot - ./letsencrypt:/etc/letsencrypt - # NOTE: /media is a "Project" feature, currently unused - ./backend/media:/media - # static files for django /admin control panel - ./backend/static:/var/www/decomp.me/static - # TODO: mount static + public files from frontend - # BOOTSTRAP: allow nginx to start before backend/frontend containers are up - #extra_hosts: - # - "backend-blue=172.17.0.1" # docker0 bridge - # - "frontend-blue=172.17.0.1" # docker0 bridge networks: - decompme @@ -116,25 +126,14 @@ services: image: postgres:17 env_file: - docker.prod.env - # ports: - # - "5432:5432" restart: unless-stopped volumes: - ./postgres:/var/lib/postgresql/data - # BOOTSTRAP: for dumping/restoring db - ./pgdump:/pgdump networks: - decompme command: > - postgres - -c shared_buffers=8GB - -c work_mem=128MB - -c maintenance_work_mem=2GB - -c effective_cache_size=16GB - -c wal_buffers=32MB - -c checkpoint_completion_target=0.9 - -c random_page_cost=1.1 - -c effective_io_concurrency=300 + postgres -c shared_buffers=8GB -c work_mem=128MB -c maintenance_work_mem=2GB -c effective_cache_size=16GB -c wal_buffers=32MB -c checkpoint_completion_target=0.9 -c random_page_cost=1.1 -c effective_io_concurrency=300 networks: decompme: diff --git a/docker-compose.yaml b/docker-compose.yaml index 41cee67c1..433d508ad 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,9 +9,11 @@ services: volumes: - ./postgres:/var/lib/postgresql/data - ./pgdump:/pgdump - backend: + restart: unless-stopped + + cromper: build: - context: backend + context: cromper target: dev cap_drop: - all @@ -20,24 +22,50 @@ services: - setgid - setfcap environment: - - ENABLE_SWITCH_SUPPORT=NO - env_file: - - backend/docker.dev.env + CROMPER_PORT: 8888 + USE_SANDBOX_JAIL: "true" + SANDBOX_DISABLE_PROC: "true" ports: - - "8000:8000" - - "5678:5678" # vscode debugger + - "8888:8888" restart: unless-stopped security_opt: - apparmor=unconfined - seccomp=unconfined volumes: - - ./backend:/backend + - ./cromper:/cromper + - ./cromper/compilers:/cromper/compilers:ro + - ./cromper/libraries:/cromper/libraries:ro tmpfs: - # Use a separate tmpfs to prevent a rogue jailed process - # from filling /tmp on the parent container - - /sandbox/tmp:exec,uid=1000,gid=1000,size=64M,mode=0700 + - /tmp/sandbox:exec,uid=1000,gid=1000,size=256M,mode=0700 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8888/health"] + interval: 15s + timeout: 3s + retries: 10 + start_period: 20s + + backend: + build: + context: backend + target: dev + env_file: + - backend/docker.dev.env + depends_on: + cromper: + condition: service_healthy + ports: + - "8000:8000" + - "5678:5678" # vscode debugger + restart: unless-stopped + volumes: + - ./backend:/backend healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/api/healthz"] + interval: 15s + timeout: 3s + retries: 10 + start_period: 20s + frontend: build: context: frontend @@ -45,18 +73,20 @@ services: environment: API_BASE: /api INTERNAL_API_BASE: http://backend:8000/api - healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"] - interval: 15s - timeout: 3s - retries: 10 - start_period: 20s + INTERNAL_CROMPER_BASE: http://cromper:8888 ports: - "8080:8080" restart: unless-stopped volumes: - ./frontend:/frontend - .env:/.env + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/healthz"] + interval: 15s + timeout: 3s + retries: 10 + start_period: 20s + nginx: build: context: nginx diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 4d13d8abc..a70203675 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -138,3 +138,11 @@ uv run mypy uv run ruff check . uv run ruff format . ``` + +- Check cromper +```shell +cd cromper +uv run mypy +uv run ruff check . +uv run ruff format . +``` diff --git a/docs/SANDBOX.md b/docs/SANDBOX.md index 0bc9d7db6..824eea242 100644 --- a/docs/SANDBOX.md +++ b/docs/SANDBOX.md @@ -2,7 +2,7 @@ There is support for running subprocesses within [`nsjail`](https://github.com/google/nsjail). -This is controlled by the `SANDBOX` settings, and is disabled by default in the development `.env` but is enabled inside the `backend` Docker container. +This is controlled by the `SANDBOX` settings, and is disabled by default in the development `.env` but is enabled inside the `cromper` Docker container. To enable it locally outside of the Docker container: diff --git a/docs/VSCODE.md b/docs/VSCODE.md index 0770cb418..319a0b0f7 100644 --- a/docs/VSCODE.md +++ b/docs/VSCODE.md @@ -2,7 +2,7 @@ uv creates a virtual environment where packages for the site are installed. You can set your python interpreter path in vscode to use this virtual environment's interpreter, which will allow a much nicer backend development experience. -1. Inside the `backend/` directory, run `uv run which python` and copy the output path. +1. Inside the `backend/` or `cromper/` directories, run `uv run which python` and copy the output path. image @@ -10,7 +10,7 @@ uv creates a virtual environment where packages for the site are installed. You image -3. Click `Enter interpreter path...` and paste your copied path from before. +2. Click `Enter interpreter path...` and specify backend/.venv. image image diff --git a/frontend/src/app/(navfooter)/new/NewScratchForm.tsx b/frontend/src/app/(navfooter)/new/NewScratchForm.tsx index b20af7f48..309c125df 100644 --- a/frontend/src/app/(navfooter)/new/NewScratchForm.tsx +++ b/frontend/src/app/(navfooter)/new/NewScratchForm.tsx @@ -18,8 +18,8 @@ import AsyncButton from "@/components/AsyncButton"; import PresetSelect from "@/components/compiler/PresetSelect"; import CodeMirror from "@/components/Editor/CodeMirror"; import PlatformSelect from "@/components/PlatformSelect"; -import Select from "@/components/Select2"; import { SingleLineScratchItem } from "@/components/ScratchItem"; +import Select from "@/components/Select2"; import * as api from "@/lib/api"; import { useCompilers, usePresets } from "@/lib/api"; import { useRouter } from "@/lib/navigation"; diff --git a/frontend/src/lib/api/request.ts b/frontend/src/lib/api/request.ts index caea4e492..0df7bd4c7 100644 --- a/frontend/src/lib/api/request.ts +++ b/frontend/src/lib/api/request.ts @@ -8,6 +8,9 @@ if (!API_BASE) { throw new Error("No API_BASE set"); } +const CROMPER_BASE = + process.env.INTERNAL_CROMPER_BASE ?? process.env.NEXT_PUBLIC_API_BASE; + type Json = any; const commonOpts: RequestInit = { @@ -41,9 +44,16 @@ export class RequestFailedError extends Error { } } +function isCromperUrl(url: string) { + if (url.startsWith("/platform")) return true; + if (url.startsWith("/compiler")) return true; + if (url.startsWith("/library")) return true; + return false; +} + export function normalizeUrl(url: string) { if (url.startsWith("/")) { - url = API_BASE + url; + url = (isCromperUrl(url) ? CROMPER_BASE : API_BASE) + url; } return url; } diff --git a/frontend/src/lib/i18n/locales/en/compilers.json b/frontend/src/lib/i18n/locales/en/compilers.json index 198bea361..2b1429cae 100644 --- a/frontend/src/lib/i18n/locales/en/compilers.json +++ b/frontend/src/lib/i18n/locales/en/compilers.json @@ -24,9 +24,6 @@ "clang-4.0.1": "Clang 4.0.1", "clang-8.0.0": "Clang 8.0.0", - "dummy": "Dummy", - "dummy_longrunning": "Dummy (long-running)", - "iop-gcc2.8.1": "IOP GCC 2.8.1", "iop-gcc2.95.2-102": "IOP GCC 2.95.2 (SN BUILD v1.02)", "ee-gcc2.9-990721": "EE GCC 2.9 build 990721", diff --git a/integration_tests/.gitignore b/integration_tests/.gitignore new file mode 100644 index 000000000..4044e0b6b --- /dev/null +++ b/integration_tests/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual environments +.venv/ +venv/ +ENV/ +env/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +*.cover + +# Ruff +.ruff_cache/ + +# MyPy +.mypy_cache/ +.dmypy.json +dmypy.json + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# UV +uv.lock diff --git a/integration_tests/README.md b/integration_tests/README.md new file mode 100644 index 000000000..72e29ea52 --- /dev/null +++ b/integration_tests/README.md @@ -0,0 +1,80 @@ +# Integration Tests + +This directory contains integration tests for decomp.me that require both the Django backend and cromper services to be running. + +## What goes here? + +Tests that: +- Create scratches with real compilers +- Compile code and verify compilation results +- Test assembly/diff generation +- Test scoring functionality +- Test export functionality that requires compilation + +## What stays in Django tests? + +Tests that: +- Test pure Django functionality (authentication, database operations) +- Test API routing and permissions without compilation +- Test model relationships and validations +- Test middleware and request handling + +## Running Integration Tests + +These tests require: +1. Cromper service running (port 8888) +2. Django backend running (port 8000) +3. Database connection + +### Quick Start (Recommended) + +If you already have services running: +```bash +cd integration_tests +./run_tests_manual.sh +``` + +Or directly: +```bash +cd integration_tests +uv run pytest +``` + +### Setup + +Install dependencies with uv: +```bash +cd integration_tests +uv sync +``` + +### Manual Setup (Recommended for Development) + +1. Start cromper: +```bash +cd cromper +poetry run python -m cromper.main +``` + +2. Start Django: +```bash +cd backend +poetry run python manage.py runserver +``` + +3. Run tests: +```bash +cd integration_tests +./run_tests_manual.sh +# or +uv run pytest +``` + +### Using Docker Compose (Experimental) + +**Note**: Currently has permission issues with cromper container. See [DEBUGGING.md](DEBUGGING.md) for details. + +```bash +cd integration_tests +./run_tests.sh +``` diff --git a/integration_tests/__init__.py b/integration_tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/integration_tests/conftest.py b/integration_tests/conftest.py new file mode 100644 index 000000000..1a29bb740 --- /dev/null +++ b/integration_tests/conftest.py @@ -0,0 +1,70 @@ +""" +Common utilities and fixtures for integration tests. +""" + +# ruff: noqa: E402 + +import os +from typing import Any + +import pytest + +# Setup Django settings (pytest will add backend to path via pythonpath setting) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "decompme.settings") + + +@pytest.fixture +def api_client(): + """Return an API client configured for integration tests.""" + # Import here to avoid import before django.setup() + from rest_framework.test import APIClient + + client = APIClient() + client.credentials(HTTP_USER_AGENT="IntegrationTest 1.0") + return client + + +@pytest.fixture +def cromper_url(): + """Return the cromper URL from environment or use default.""" + return os.getenv("CROMPER_URL", "http://localhost:8888") + + +class IntegrationTestBase: + """Base class for integration tests with common utilities.""" + + @staticmethod + def create_scratch(client, partial: dict[str, Any]): + """ + Create a scratch via API and return the database object. + + This will trigger cromper compilation if source_code is provided. + """ + # Import here to avoid import before django.setup() + from coreapp.models.scratch import Scratch + from django.urls import reverse + from rest_framework import status + + response = client.post(reverse("scratch-list"), partial, format="json") + assert response.status_code == status.HTTP_201_CREATED, response.json() + scratch = Scratch.objects.get(slug=response.json()["slug"]) + assert scratch is not None + return scratch + + @staticmethod + def create_nop_scratch(client): + """Create a simple NOP scratch for testing.""" + scratch_dict = { + "compiler": "gcc2.8.1pm", + "platform": "n64", + "context": "", + "target_asm": "jr $ra\nnop\n", + } + return IntegrationTestBase.create_scratch(client, scratch_dict) + + +def pytest_collection_modifyitems(config, items): + """Add integration test marker to all tests in this directory.""" + for item in items: + item.add_marker(pytest.mark.integration) + item.add_marker(pytest.mark.django_db) diff --git a/integration_tests/example-github-workflow.yml b/integration_tests/example-github-workflow.yml new file mode 100644 index 000000000..a4bed1b27 --- /dev/null +++ b/integration_tests/example-github-workflow.yml @@ -0,0 +1,74 @@ +# Example GitHub Actions workflow for running tests +# This shows how to run Django tests separately from integration tests + +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + django-tests: + name: Django Unit Tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + cd backend + pip install poetry + poetry install + + - name: Run Django tests + run: | + cd backend + poetry run python manage.py test + env: + DJANGO_SETTINGS_MODULE: decompme.settings + DATABASE_URL: sqlite:///test.db + + integration-tests: + name: Integration Tests (Cromper + Django) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + cd integration_tests + pip install uv + uv sync + + - name: Start services with Docker Compose + run: | + docker-compose up -d cromper backend postgres + sleep 15 # Wait for services to be ready + + - name: Check cromper health + run: | + curl --retry 5 --retry-delay 2 --retry-connrefused http://localhost:3000/health + + - name: Run integration tests + run: | + cd integration_tests + uv run pytest -v + + - name: Stop services + if: always() + run: | + docker-compose down diff --git a/integration_tests/pyproject.toml b/integration_tests/pyproject.toml new file mode 100644 index 000000000..dec8a3cf4 --- /dev/null +++ b/integration_tests/pyproject.toml @@ -0,0 +1,95 @@ +[project] +name = "integration-tests" +version = "0.1.0" +description = "Integration tests for decomp.me requiring cromper and Django backend" +requires-python = ">=3.10" +dependencies = [ + "pytest>=8.0.0", + "pytest-django>=4.9.0", + "pytest-env>=1.1.0", + "requests>=2.32.3", + "responses>=0.25.3", + # All backend dependencies + "django>=5.1.3", + "django-cors-headers>=4.6.0", + "django-environ>=0.11.2", + "django-filter>=24.3", + "djangorestframework>=3.15.2", + "psycopg2-binary>=2.9.10", + "jwt>=1.3.1", + "PyGithub>=2.5.0", + "drf-extensions>=0.7.1", + "tzdata==2024.2", + "Pillow==11.0", + "html-json-forms>=1.1.1", + "django-resized>=1.0.3", + "django-cleanup>=9.0.0", + "sentry-sdk>=2.19.0", + "django-session-timeout>=0.1.0", + "setuptools>=75.6.0", + "attrs>=24.0.0", +] + +[dependency-groups] +dev = [ + "ruff>=0.8.0", + "mypy>=1.13.0", + "django-stubs[compatible-mypy]>=5.2.5", + "djangorestframework-stubs>=3.15.1", + "types-requests>=2.32.0.20241016", +] + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-v --tb=short --strict-markers" +testpaths = ["."] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +markers = [ + "integration: marks tests as integration tests (requires cromper + Django)", +] +# Django test database settings +DJANGO_SETTINGS_MODULE = "decompme.settings" +pythonpath = ["../backend"] + +[tool.ruff] +line-length = 100 +target-version = "py310" +extend-exclude = ["__pycache__"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "PIE", # flake8-pie + "PT", # flake8-pytest-style + "RUF", # ruff-specific rules +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.lint.per-file-ignores] +"test_*.py" = ["PT009", "PT027"] # Allow pytest fixtures and assertions + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +plugins = ["mypy_django_plugin.main"] +disable_error_code = ["var-annotated", "import-untyped"] + +[tool.django-stubs] +django_settings_module = "decompme.settings" diff --git a/integration_tests/run_tests.sh b/integration_tests/run_tests.sh new file mode 100755 index 000000000..7bda683b3 --- /dev/null +++ b/integration_tests/run_tests.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Script to run integration tests with cromper and Django services +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${YELLOW}Starting integration test suite...${NC}" + +# Check if docker-compose is available +if ! command -v docker-compose &> /dev/null; then + echo -e "${RED}Error: docker-compose is not installed${NC}" + exit 1 +fi + +# Function to cleanup +cleanup() { + echo -e "${YELLOW}Cleaning up services...${NC}" + docker-compose -f ../docker-compose.yaml down +} + +# Set trap to cleanup on exit +trap cleanup EXIT + +# Start services +echo -e "${YELLOW}Starting cromper and Django services...${NC}" +docker-compose -f ../docker-compose.yaml up -d cromper backend postgres + +# Wait for services to be ready +echo -e "${YELLOW}Waiting for services to be ready...${NC}" + +# Wait for cromper with retry logic +MAX_RETRIES=30 +RETRY_COUNT=0 +echo -n "Checking cromper health..." +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -sf http://localhost:8888/health > /dev/null 2>&1; then + echo -e " ${GREEN}OK${NC}" + break + fi + echo -n "." + sleep 2 + RETRY_COUNT=$((RETRY_COUNT + 1)) +done + +if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then + echo -e " ${RED}FAILED${NC}" + echo -e "${RED}Error: Cromper service is not responding after 60 seconds${NC}" + echo -e "${YELLOW}Checking cromper logs:${NC}" + docker-compose -f ../docker-compose.yaml logs --tail=50 cromper + exit 1 +fi + +echo -e "${GREEN}Services are ready!${NC}" + +# Run tests +echo -e "${YELLOW}Running integration tests...${NC}" +cd "$(dirname "$0")" + +if uv run pytest "$@"; then + echo -e "${GREEN}All integration tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some integration tests failed!${NC}" + exit 1 +fi diff --git a/integration_tests/test_preset.py b/integration_tests/test_preset.py new file mode 100644 index 000000000..23cc9173a --- /dev/null +++ b/integration_tests/test_preset.py @@ -0,0 +1,86 @@ +""" +Integration tests for preset functionality that requires compilation. + +These tests require cromper to be running. +""" + +from typing import Any + +import pytest +from coreapp.models.preset import Preset +from django.contrib.auth.models import User +from django.urls import reverse +from rest_framework import status + +from .conftest import IntegrationTestBase + +SAMPLE_PRESET_DICT = { + "name": "Kitty's Adventure", + "platform": "n64", + "compiler": "gcc2.8.1pm", + "assembler_flags": "-march=vr4300 -mabi=32 -mtune=vr4300", + "compiler_flags": "-O2 -G0", + "decompiler_flags": "-capy", +} + + +@pytest.mark.integration +class TestPresetWithCompilation(IntegrationTestBase): + """Tests for preset functionality that requires compilation.""" + + @staticmethod + def create_admin(api_client) -> User: + """Create and login as an admin user.""" + username = "admin" + password = "testpassword" + user, _created = User.objects.get_or_create(username=username) + user.set_password(password) + user.is_staff = True + user.is_superuser = True + user.save() + api_client.login(username=username, password=password) + return user + + @staticmethod + def create_preset(api_client, partial: dict[str, Any]) -> Preset: + """Create a preset via API.""" + response = api_client.post(reverse("preset-list"), partial) + assert response.status_code == status.HTTP_201_CREATED, response.json() + preset = Preset.objects.get(id=response.json()["id"]) + assert preset is not None + return preset + + def test_create_scratch_from_preset(self, api_client): + """Test creating a scratch from a preset and verifying compilation works.""" + self.create_admin(api_client) + preset = self.create_preset(api_client, SAMPLE_PRESET_DICT) + scratch_dict = { + "preset": str(preset.id), + "context": "", + "target_asm": "jr $ra\nnop\n", + } + scratch = self.create_scratch(api_client, scratch_dict) + assert scratch.preset is not None + assert scratch.preset.id == preset.id + assert scratch.platform == preset.platform + assert scratch.compiler == preset.compiler + assert scratch.compiler_flags == preset.compiler_flags + assert scratch.libraries == preset.libraries + + def test_create_scratch_from_preset_override(self, api_client): + """Test creating a scratch from a preset with overrides and verifying compilation works.""" + self.create_admin(api_client) + preset = self.create_preset(api_client, SAMPLE_PRESET_DICT) + scratch_dict = { + "preset": str(preset.id), + "context": "", + "target_asm": "jr $ra\nnop\n", + "compiler_flags": "-O3", + } + scratch = self.create_scratch(api_client, scratch_dict) + assert scratch.preset is not None + assert scratch.preset.id == preset.id + assert scratch.platform == preset.platform + assert scratch.compiler == preset.compiler + assert scratch.compiler_flags == "-O3" # should override preset's value + assert scratch.libraries == preset.libraries diff --git a/integration_tests/test_scratch.py b/integration_tests/test_scratch.py new file mode 100644 index 000000000..c16460d2c --- /dev/null +++ b/integration_tests/test_scratch.py @@ -0,0 +1,298 @@ +""" +Integration tests for scratch compilation and assembly. + +These tests require cromper to be running. +""" + +import io +import zipfile + +import pytest +from coreapp.models.scratch import Scratch +from coreapp.views.scratch import compile_scratch_update_score +from django.test import override_settings +from django.urls import reverse +from rest_framework import status + +from .conftest import IntegrationTestBase + + +@pytest.mark.integration +class TestScratchCreation(IntegrationTestBase): + """Tests for scratch creation with real compilers.""" + + def test_accept_late_rodata(self, api_client): + """ + Ensure that .late_rodata (used in ASM_PROCESSOR) is accepted during scratch creation. + """ + scratch_dict = { + "platform": "n64", + "compiler": "ido7.1", + "context": "", + "target_asm": """.late_rodata +glabel D_8092C224 +.float 0.1 + +.text +glabel func_80929D04 +jr $ra +nop""", + } + self.create_scratch(api_client, scratch_dict) + + def test_n64_func(self, api_client): + """ + Ensure that functions with t6/t7 registers can be assembled. + """ + scratch_dict = { + "platform": "n64", + "compiler": "ido5.3", + "context": "typedef unsigned char u8;", + "target_asm": """ +.text +glabel func_8019B378 +lui $t6, %hi(sOcarinaSongAppendPos) +lbu $t6, %lo(sOcarinaSongAppendPos)($t6) +lui $at, %hi(D_801D702C) +jr $ra +sb $t6, %lo(D_801D702C)($at) +""", + } + self.create_scratch(api_client, scratch_dict) + + def test_fpr_reg_names(self, api_client): + """ + Ensure that functions with O32 register names can be assembled. + """ + scratch_dict = { + "platform": "n64", + "compiler": "ido7.1", + "context": "", + "target_asm": """ +glabel test +lui $at, 0x3ff0 +mtc1 $at, $fv1f +mtc1 $zero, $fv1 +beqz $a0, .L00400194 +move $v0, $a0 +andi $a1, $a0, 3 +negu $a1, $a1 +beqz $a1, .L004000EC +addu $v1, $a1, $a0 +mtc1 $v0, $ft0 +nop +""", + } + self.create_scratch(api_client, scratch_dict) + + def test_max_score(self, api_client): + """ + Ensure that max_score is available upon scratch creation even if the initial compilation fails + """ + scratch_dict = { + "platform": "n64", + "compiler": "ido7.1", + "context": "this aint cod", + "target_asm": ".text\nglabel func_80929D04\njr $ra\nnop", + } + scratch = self.create_scratch(api_client, scratch_dict) + assert scratch.max_score == 200 + + def test_import_scratch(self, api_client): + """ + Ensure that creating a scratch created via permuter import.py is successful + """ + scratch_dict = { + "name": "imported_function", + "target_asm": ".text\nglabel imported_function\njr $ra\nnop", + "context": "/* context */", + "source_code": "void imported_function(void) {}", + "compiler": "ido7.1", + "compiler_flags": "-O2", + "diff_label": "imported_function", + } + scratch = self.create_scratch(api_client, scratch_dict) + assert scratch.name == "imported_function" + + def test_mwcc_242_81(self, api_client): + """ + Ensure that MWCC works + """ + scratch_dict = { + "platform": "gc_wii", + "compiler": "mwcc_242_81", + "context": "", + "target_asm": ".fn somefunc, local\nblr\n.endfn somefunc", + } + self.create_scratch(api_client, scratch_dict) + + def test_ps2_platform(self, api_client): + """ + Ensure that we can create scratches with the ps2 platform and compiler + """ + scratch_dict = { + "platform": "ps2", + "compiler": "ee-gcc2.9-991111", + "context": "", + "target_asm": "jr $ra\nnop", + } + self.create_scratch(api_client, scratch_dict) + + +@pytest.mark.integration +class TestScratchModification(IntegrationTestBase): + """Tests for scratch modification with compilation.""" + + def test_update_scratch_score(self, api_client): + """ + Ensure that a scratch's score gets updated when the code changes. + """ + scratch_dict = { + "platform": "n64", + "compiler": "gcc2.8.1pm", + "context": "", + "target_asm": "jr $ra", + } + scratch = self.create_scratch(api_client, scratch_dict) + slug = scratch.slug + + assert scratch.score > 0 + + # Obtain ownership of the scratch + response = api_client.post( + reverse("scratch-claim", kwargs={"pk": slug}), + {"token": scratch.claim_token}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["success"] is True + + # Update the scratch's code and compiler output + scratch_patch = { + "source_code": "int func() { return 2; }", + "compiler": "ido5.3", + } + + response = api_client.patch(reverse("scratch-detail", kwargs={"pk": slug}), scratch_patch) + assert response.status_code == status.HTTP_200_OK + + scratch = Scratch.objects.get(slug=slug) + assert scratch is not None + assert scratch.score == 200 + + def test_update_scratch_score_on_compile_get(self, api_client): + """ + Ensure that a scratch's score gets updated on a GET to compile + """ + scratch_dict = { + "platform": "n64", + "compiler": "gcc2.8.1pm", + "compiler_flags": "-O2", + "context": "", + "target_asm": "jr $ra\nli $v0,2", + "source_code": "int func() { return 2; }", + } + scratch = self.create_scratch(api_client, scratch_dict) + + scratch.score = -1 + scratch.max_score = -1 + scratch.save() + + assert scratch.score == -1 + slug = scratch.slug + + response = api_client.get(reverse("scratch-compile", kwargs={"pk": slug})) + assert response.status_code == status.HTTP_200_OK + + scratch = Scratch.objects.get(slug=slug) + assert scratch is not None + assert scratch.score == 0 + + def test_create_scratch_score(self, api_client): + """ + Ensure that a scratch's score gets set upon creation. + """ + scratch_dict = { + "platform": "n64", + "compiler": "ido7.1", + "context": "", + "target_asm": "jr $ra\nli $v0,2", + "source_code": "int func() { return 2; }", + } + scratch = self.create_scratch(api_client, scratch_dict) + assert scratch.score == 0 + + def test_update_scratch_score_does_not_affect_last_updated(self, api_client): + """ + Ensure that a scratch's last_updated field does not get updated when the max_score changes. + """ + scratch_dict = { + "platform": "n64", + "compiler": "ido7.1", + "context": "", + "target_asm": "jr $ra\nli $v0,2", + "source_code": "int func() { return 2; }", + } + scratch = self.create_scratch(api_client, scratch_dict) + scratch.max_score = -1 + scratch.save() + assert scratch.max_score == -1 + + prev_last_updated = scratch.last_updated + compile_scratch_update_score(scratch) + assert scratch.max_score == 200 + assert prev_last_updated == scratch.last_updated + + +@pytest.mark.integration +class TestScratchExport(IntegrationTestBase): + """Tests for scratch export with compilation.""" + + def test_export_asm_scratch(self, api_client): + """ + Ensure that a scratch can be exported as a zip + """ + scratch_dict = { + "platform": "n64", + "compiler": "ido7.1", + "context": "typedef signed int s32;", + "target_asm": "jr $ra\nli $v0,2", + "source_code": "s32 func() { return 2; }", + } + scratch = self.create_scratch(api_client, scratch_dict) + response = api_client.get(f"/api/scratch/{scratch.slug}/export") + + zip_file = zipfile.ZipFile(io.BytesIO(response.content)) + file_names = zip_file.namelist() + + assert "metadata.json" in file_names + assert "target.s" in file_names + assert "target.o" in file_names + assert "code.c" in file_names + assert "ctx.c" in file_names + assert "current.o" in file_names + + def test_export_asm_scratch_target_only(self, api_client): + """ + Ensure that a scratch can be exported as a zip + without performing the actual compilation step + """ + scratch_dict = { + "platform": "n64", + "compiler": "ido7.1", + "context": "typedef signed int s32;", + "target_asm": "jr $ra\nli $v0,2", + "source_code": "s32 func() { return 2; }", + } + scratch = self.create_scratch(api_client, scratch_dict) + response = api_client.get(f"/api/scratch/{scratch.slug}/export?target_only=1") + + zip_file = zipfile.ZipFile(io.BytesIO(response.content)) + file_names = zip_file.namelist() + + assert "metadata.json" in file_names + assert "target.s" in file_names + assert "target.o" in file_names + assert "code.c" in file_names + assert "ctx.c" in file_names + assert "current.o" not in file_names + diff --git a/nginx/development/default.conf b/nginx/development/default.conf index e4cc87946..3fa328bb9 100644 --- a/nginx/development/default.conf +++ b/nginx/development/default.conf @@ -16,8 +16,12 @@ server { location / { try_files $uri @proxy_frontend; } - - location ~ ^/api(/.*)?$ { + location ~ ^/api/(compiler|platform|library) { + # drop /api from url + rewrite ^/api/(.*)$ /$1 break; + try_files $uri @proxy_cromper; + } + location /api { try_files $uri @proxy_api; } location /admin { @@ -44,6 +48,20 @@ server { proxy_pass http://backend:8000; } + location @proxy_cromper { + proxy_intercept_errors on; + error_page 502 503 504 =200 @backend_down; + + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Url-Scheme $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_pass http://cromper:8888; + } + location @proxy_frontend { proxy_intercept_errors on; error_page 502 503 504 =200 /down.html; diff --git a/nginx/production/default.conf b/nginx/production/default.conf index a024ac9bb..09588511b 100644 --- a/nginx/production/default.conf +++ b/nginx/production/default.conf @@ -94,7 +94,13 @@ server { try_files /dummy.html @proxy_frontend; } - location ~ ^/api(/.*)?$ { + location ~ ^/api/(compiler|platform|library) { + # drop /api from url + rewrite ^/api/(.*)$ /$1 break; + try_files $uri @proxy_cromper; + } + + location /api { try_files /dummy.html @proxy_api; } @@ -124,6 +130,20 @@ server { proxy_pass http://backend_upstream; } + location @proxy_cromper { + proxy_intercept_errors on; + error_page 502 503 504 =200 @backend_down; + + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Url-Scheme $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $http_host; + proxy_redirect off; + proxy_pass http://cromper:8888; + } + location @proxy_frontend { proxy_intercept_errors on; error_page 502 503 504 =200 /down.html;