diff --git a/.circleci/config.yml b/.circleci/config.yml index 14ce6ddd0..250ff65ec 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,7 +3,7 @@ version: 2 jobs: test: machine: - image: ubuntu-2004:2022.07.1 + image: ubuntu-2204:2024.01.2 steps: - checkout @@ -34,18 +34,18 @@ jobs: - run: name: Build containers and collect static command: | - docker-compose -f docker-compose.yml -f docker-compose.selenium.yml up -d - docker-compose -f docker-compose.yml -f docker-compose.selenium.yml exec django python manage.py collectstatic --noinput + docker compose -f docker-compose.yml -f docker-compose.selenium.yml up -d + docker compose -f docker-compose.yml -f docker-compose.selenium.yml exec django python manage.py collectstatic --noinput - run: docker-compose exec django flake8 src/ - run: name: pytest - command: docker-compose -f docker-compose.yml -f docker-compose.selenium.yml exec django py.test src/ -m "not e2e" + command: docker compose -f docker-compose.yml -f docker-compose.selenium.yml exec django py.test src/ -m "not e2e" - run: name: e2e tests - command: docker-compose -f docker-compose.yml -f docker-compose.selenium.yml exec django py.test src/tests/functional/ -m e2e + command: docker compose -f docker-compose.yml -f docker-compose.selenium.yml exec django py.test src/tests/functional/ -m e2e no_output_timeout: 60m - store_artifacts: diff --git a/.env_circleci b/.env_circleci index 886df1b62..3037e07af 100644 --- a/.env_circleci +++ b/.env_circleci @@ -10,6 +10,7 @@ RABBITMQ_DEFAULT_USER=rabbit-username RABBITMQ_DEFAULT_PASS=rabbit-password-you-should-change RABBITMQ_PORT=5672 RABBITMQ_HOST=rabbit +WORKER_CONNECTION_TIMEOUT=100000000 # milliseconds FLOWER_BASIC_AUTH=root:password-you-should-change diff --git a/.env_sample b/.env_sample index 84a0888dc..b3f62d157 100644 --- a/.env_sample +++ b/.env_sample @@ -10,19 +10,24 @@ DB_PORT=5432 DJANGO_SETTINGS_MODULE=settings.develop ALLOWED_HOSTS=localhost,example.com SUBMISSIONS_API_URL=http://django:8000/api +MAX_EXECUTION_TIME_LIMIT=600 # time limit for the default queue (in seconds) # Local domain definition DOMAIN_NAME=localhost:80 # SSL style domain definition +TLS_EMAIL=your@email.com # DOMAIN_NAME=example.com:443 -# TLS_EMAIL=your@email.com RABBITMQ_HOST=rabbit RABBITMQ_DEFAULT_USER=rabbit-username RABBITMQ_DEFAULT_PASS=rabbit-password-you-should-change RABBITMQ_MANAGEMENT_PORT=15672 RABBITMQ_PORT=5672 +WORKER_CONNECTION_TIMEOUT=100000000 # milliseconds +#RABBITMQ_HTTP_PROXY=http://proxy-example:3128 +#RABBITMQ_HTTPS_PROXY=http://proxy-example:3128 +#RABBITMQ_NO_PROXY=localhost,172.0.0.0/8 FLOWER_PUBLIC_PORT=5555 @@ -37,6 +42,9 @@ SELENIUM_HOSTNAME=selenium #EMAIL_HOST_PASSWORD=pass #EMAIL_PORT=587 #EMAIL_USE_TLS=True +#DEFAULT_FROM_EMAIL="Codabench " +#SERVER_EMAIL=noreply@example.com + # ----------------------------------------------------------------------------- # Storage @@ -59,6 +67,7 @@ AWS_STORAGE_PRIVATE_BUCKET_NAME=private AWS_S3_ENDPOINT_URL=http://minio:9000/ AWS_QUERYSTRING_AUTH=False + # ----------------------------------------------------------------------------- # Limit for re-running submission # This is used to limit users to rerun submissions @@ -67,6 +76,13 @@ AWS_QUERYSTRING_AUTH=False RERUN_SUBMISSION_LIMIT=30 +# ----------------------------------------------------------------------------- +# Enable or disbale regular email sign-in an sign-up +# ----------------------------------------------------------------------------- +ENABLE_SIGN_UP=True +ENABLE_SIGN_IN=True + + # # S3 storage example # STORAGE_TYPE=s3 # AWS_ACCESS_KEY_ID=12312312312312312331223 diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..3c4685a7a --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,55 @@ +# Code of Conduct + +## 1. Introduction + +Welcome to Codabench! We are committed to fostering an open, inclusive, and respectful community. The purpose of this Code of Conduct is to ensure a safe and welcoming environment for everyone who contributes to this project. + +## 2. Expected Behavior + +We expect all participants in our community to: +- Be respectful and considerate in all interactions. +- Use welcoming and inclusive language. +- Give constructive feedback and accept it gracefully. +- Be collaborative and help foster a positive community. +- Respect differing viewpoints and experiences. + +## 3. Unacceptable Behavior + +The following behaviors are not tolerated: +- Harassment, discrimination, or offensive comments based on race, gender, sexual orientation, disability, religion, or any other personal characteristic. +- Personal attacks, insults, trolling, or inflammatory remarks. +- Posting inappropriate content, including hate speech, sexualized material, or violence. +- Spamming, advertising, or other disruptive behavior. +- Any other behavior that disrupts the integrity and inclusivity of the community. + +## 4. Scope + +This Code of Conduct applies to all interactions related to this project, including: +- Issues, pull requests, and discussions on GitHub. +- Communication via social media, forums, or other project-related channels. +- Any other interactions between members of this community. + +## 5. Reporting Issues + +If you witness or experience any behavior that violates this Code of Conduct, please report it to **info@codalab.org**. All reports will be reviewed and handled confidentially. + +## 6. Enforcement + +Violations of this Code of Conduct may result in: +- A warning. +- Temporary or permanent suspension from project participation. +- Reporting to relevant authorities if necessary. + +All enforcement decisions are made at the discretion of the project maintainers. + +## 7. Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1. + +--- + +### Questions? +If you have any questions or concerns, please reach out to **info@codalab.org**. + + + diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..31aa14768 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,23 @@ +# HOW YOU CAN CONTRIBUTE TO THE CODABENCH PROJECT + +## 1. Being a Codabench user. + +- Create a user account on https://codalab.lisn.fr and on https://codabench.org. +- Register on https://codabench.org to this existing competition (IRIS-tuto) https://www.codabench.org/competitions/1115/ and make a submission (from https://github.com/codalab/competition-examples/tree/master/codabench/iris): sample_result_submission and sample_code_submission. See https://github.com/codalab/codabench/wiki/User_Participating-in-a-Competition +- Create your own private competition (from https://github.com/codalab/competition-examples/tree/master/codabench/ ). See https://github.com/codalab/codabench/wiki/Getting-started-with-Codabench + + ## 2. Setting a local instance of Codabench. + +- Follow the tutorial in codabench wiki: https://github.com/codalab/codabench/wiki/Codabench-Installation. According to your hosting OS, you might have to tune your environment file a bit. Try without enabling the SSL protocol (doing so, you don't need a domain name for the server). Try using the embedded Minio storage solution instead of a private cloud storage. +- If needed, you can also look into https://github.com/codalab/codabench/wiki/How-to-deploy-Codabench-on-your-server + +## 3. Using one's local instance + +- Create your own competition and play with it. You can look at the output logs of each different docker container. +- Setting you as an admin of your platform (https://github.com/codalab/codabench/wiki/Administrator-procedures#give-superuser-privileges-to-an-user) and visit the Django Admin menu: https://github.com/codalab/codabench/wiki/Administrator-procedures#give-superuser-privileges-to-an-user + +## 4. Setting an autonomous computer-worker on your PC + +- Configure and launch the docker container: https://github.com/codalab/codabench/wiki/Compute-Worker-Management---Setup +- Create a private queue on your new own competition on the production server codabench.org: https://github.com/codalab/codabench/wiki/Queue-Management#create-queue +- Assign your own compute-worker to this private queue instead of the default queue. diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..7c7bda519 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,19 @@ +***This is a template, please remove any non-relevant details to your issue*** + +If you are competition participant: +----------------------------------- + +- This Github repository is about the **platform** itself. +- If you problem is specific to a competition, **please contact directly its organizers**. + +If you are an organizer and have problem hosting your competition: +------------------------------------------------------------------ + +- Please post a link to your competition. + + +If you are having trouble using the site: +----------------------------------------- + +- What browser and version are you using? +- What is the URL of the problem? Codalab is an open source project, we may not be supporting the instance you are using! diff --git a/.gitignore b/.gitignore index b5b34f56a..c7b8707c9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ var/ var_*/ certs/ backups/ +logs/ src/static/output.css src/static/output.js @@ -34,3 +35,8 @@ server_config.yaml .DS_Store .DS_Store? + +caddy_config/ +caddy_data/ + +home_page_counters.json diff --git a/Caddyfile b/Caddyfile index 49d03f690..8774b7381 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,5 +1,5 @@ {$DOMAIN_NAME} { - # HTTPS options: + # HTTPS Options tls {$TLS_EMAIL} # Test HTTPS setup @@ -7,23 +7,46 @@ # ca https://acme-staging-v02.api.letsencrypt.org/directory # } + # Removing some headers for improved security: - header / -Server + header -Server + + @noRedirect { + not path /maintenance.html + not path /offline.png + } + @maintenanceModeActive file { + root /srv + try_files maintenance.on + } + handle @maintenanceModeActive { + root * /srv + redir @noRedirect /maintenance.html + file_server { + status 503 + } + } # Serves static files, should be the same as `STATIC_ROOT` setting: - root /var/www/django + root * /var/www/django + file_server - # Serving dynamic requests: - proxy / django:8000 { - except /static /media - transparent - websocket + @noStatic { + not path /static/* + not path /media/* } + + # Serving dynamic requests: + reverse_proxy @noStatic django:8000 + # Allows to use `.gz` files when available: - gzip + encode gzip - # Logs: - log stdout - errors stdout + # Logs, will keep 5 GB + log { + output file /var/log/caddyaccess.log { + roll_size 5000MiB + } + } } diff --git a/Containerfile.compute_worker_podman b/Containerfile.compute_worker_podman index 9c96ead37..049e97435 100644 --- a/Containerfile.compute_worker_podman +++ b/Containerfile.compute_worker_podman @@ -4,12 +4,12 @@ FROM fedora:37 RUN dnf -y update && \ # https://bugzilla.redhat.com/show_bug.cgi?id=1995337#c3 rpm --setcaps shadow-utils 2>/dev/null && \ - dnf -y install podman fuse-overlayfs python3.8 python3-pip \ + dnf -y install podman fuse-overlayfs python3.9 \ --exclude container-selinux && \ dnf clean all && \ rm -rf /var/cache /var/log/dnf* /var/log/yum.* -# Setup user +# Setup user RUN useradd worker; \ echo -e "worker:1:999\nworker:1001:64535" > /etc/subuid; \ echo -e "worker:1:999\nworker:1001:64535" > /etc/subgid; @@ -47,16 +47,23 @@ RUN echo -e "[registries.search]\nregistries = ['docker.io']\n" > /etc/container ENV PYTHONUNBUFFERED 1 ENV CONTAINER_ENGINE_EXECUTABLE podman -# Get pip for 3.8 -RUN python3.8 -m ensurepip --upgrade - WORKDIR /home/worker/compute_worker ADD compute_worker/ /home/worker/compute_worker RUN chown worker:worker -R /home/worker/compute_worker -RUN pip3.8 install -r /home/worker/compute_worker/compute_worker_requirements.txt +RUN curl -sSL https://install.python-poetry.org | python3.9 - +# Poetry location so future commands (below) work +ENV PATH $PATH:/root/.local/bin +# Want poetry to use system python of docker container +RUN poetry config virtualenvs.create false +RUN poetry config virtualenvs.in-project false +# So we get 3.9 +RUN poetry config virtualenvs.prefer-active-python true +COPY ./compute_worker/pyproject.toml ./ +COPY ./compute_worker/poetry.lock ./ +RUN poetry install CMD celery -A compute_worker worker \ -l info \ diff --git a/Containerfile.compute_worker_podman_gpu b/Containerfile.compute_worker_podman_gpu index 0ef68e1a2..a90da0b27 100644 --- a/Containerfile.compute_worker_podman_gpu +++ b/Containerfile.compute_worker_podman_gpu @@ -7,12 +7,12 @@ RUN curl -s -L https://developer.download.nvidia.com/compute/cuda/repos/rhel9/x8 rpm -Uvh http://download1.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm && \ dnf -y update && \ dnf module install -y nvidia-driver:latest-dkms && \ - dnf -y install podman fuse-overlayfs python3.8 python3-pip nvidia-container-runtime nvidia-container-toolkit \ + dnf -y install podman fuse-overlayfs python3.9 nvidia-container-runtime nvidia-container-toolkit \ cuda --exclude container-selinux && \ dnf clean all && \ rm -rf /var/cache /var/log/dnf* /var/log/yum.* -# Setup user +# Setup user RUN useradd worker; \ echo -e "worker:1:999\nworker:1001:64535" > /etc/subuid; \ echo -e "worker:1:999\nworker:1001:64535" > /etc/subgid; @@ -49,15 +49,24 @@ RUN mkdir /codabench && \ chown worker:worker /codabench && \ # Set up podman registry for dockerhub echo -e "[registries.search]\nregistries = ['docker.io']\n" > /etc/containers/registries.conf && \ -# Get pip for 3.8 - python3.8 -m ensurepip --upgrade WORKDIR /home/worker/compute_worker ADD compute_worker/ /home/worker/compute_worker -RUN chown worker:worker -R /home/worker/compute_worker && \ - pip3.8 install -r /home/worker/compute_worker/compute_worker_requirements.txt +RUN curl -sSL https://install.python-poetry.org | python3.9 - +# Poetry location so future commands (below) work +ENV PATH $PATH:/root/.local/bin +# Want poetry to use system python of docker container +RUN poetry config virtualenvs.create false +RUN poetry config virtualenvs.in-project false +# So we get 3.9 +RUN poetry config virtualenvs.prefer-active-python true +COPY ./compute_worker/pyproject.toml ./ +COPY ./compute_worker/poetry.lock ./ +RUN poetry install + +RUN chown worker:worker -R /home/worker/compute_worker CMD nvidia-smi && celery -A compute_worker worker \ -l info \ diff --git a/Dockerfile b/Dockerfile index 93541187f..a1c11a741 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,21 @@ -FROM python:3.8 +FROM python:3.9.20 RUN apt-get update && apt-get install -y gcc build-essential && rm -rf /var/lib/apt/lists/* -ENV PYTHONUNBUFFERED 1 +ENV PYTHONUNBUFFERED=1 + +RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.3 +# Poetry location so future commands (below) work + +ENV PATH=$PATH:/root/.local/bin +# Want poetry to use system python of docker container +RUN poetry config virtualenvs.create false +RUN poetry config virtualenvs.in-project false + +COPY pyproject.toml poetry.lock ./ + +# Install dependencies +RUN poetry install -ADD requirements.dev.txt . -ADD requirements.txt . -RUN pip install -U pip -RUN pip install -r requirements.dev.txt WORKDIR /app diff --git a/Dockerfile.celery b/Dockerfile.celery deleted file mode 100644 index f9fee6f41..000000000 --- a/Dockerfile.celery +++ /dev/null @@ -1,5 +0,0 @@ -FROM python:3.6 -ENV PYTHONUNBUFFERED 1 -ENV C_FORCE_ROOT True -ADD requirements.txt . -RUN pip install -r requirements.txt diff --git a/Dockerfile.compute_worker b/Dockerfile.compute_worker index 482924931..6e6b626bf 100644 --- a/Dockerfile.compute_worker +++ b/Dockerfile.compute_worker @@ -1,4 +1,4 @@ -FROM --platform=linux/amd64 python:3.8 +FROM --platform=linux/amd64 python:3.9 # This makes output not buffer and return immediately, nice for seeing results in stdout ENV PYTHONUNBUFFERED 1 @@ -6,8 +6,16 @@ ENV PYTHONUNBUFFERED 1 # Install Docker RUN apt-get update && curl -fsSL https://get.docker.com | sh -ADD compute_worker/compute_worker_requirements.txt . -RUN pip install -r compute_worker_requirements.txt + +RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.3 +# Poetry location so future commands (below) work +ENV PATH $PATH:/root/.local/bin +# Want poetry to use system python of docker container +RUN poetry config virtualenvs.create false +RUN poetry config virtualenvs.in-project false +COPY ./compute_worker/pyproject.toml ./ +COPY ./compute_worker/poetry.lock ./ +RUN poetry install ADD compute_worker . diff --git a/Dockerfile.compute_worker_gpu b/Dockerfile.compute_worker_gpu index e559a0667..6bf96f3c5 100644 --- a/Dockerfile.compute_worker_gpu +++ b/Dockerfile.compute_worker_gpu @@ -1,7 +1,4 @@ -FROM --platform=linux/amd64 python:3.8.1-buster - -# We need curl to get docker/nvidia-docker -RUN apt-get update && apt-get install curl wget -y +FROM --platform=linux/amd64 python:3.9 # This makes output not buffer and return immediately, nice for seeing results in stdout ENV PYTHONUNBUFFERED 1 @@ -9,18 +6,29 @@ ENV PYTHONUNBUFFERED 1 # Install Docker RUN apt-get update && curl -fsSL https://get.docker.com | sh -# nvidia-docker jazz -RUN curl -s -L https://nvidia.github.io/nvidia-docker/gpgkey | apt-key add - -RUN curl -s -L https://nvidia.github.io/nvidia-docker/$(. /etc/os-release;echo $ID$VERSION_ID)/nvidia-docker.list | \ - tee /etc/apt/sources.list.d/nvidia-docker.list -RUN apt-get update && apt-get install -y nvidia-docker2 -# make it explicit that we're using GPUs -ENV NVIDIA_DOCKER 1 +# Nvidia Container Toolkit for cuda use with docker +# [source](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) +RUN curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg \ + && curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \ + sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \ + tee /etc/apt/sources.list.d/nvidia-container-toolkit.list +RUN apt-get update -y; +RUN apt-get install -y nvidia-container-toolkit +# Make it explicit that we're using GPUs +# BB - not convinced we need this +ENV USE_GPU 1 + +RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.3 +# Poetry location so future commands (below) work +ENV PATH $PATH:/root/.local/bin +# Want poetry to use system python of docker container +RUN poetry config virtualenvs.create false +RUN poetry config virtualenvs.in-project false +COPY ./compute_worker/pyproject.toml ./ +COPY ./compute_worker/poetry.lock ./ +RUN poetry install -# Python reqs and actual worker stuff -ADD compute_worker/compute_worker_requirements.txt . -RUN pip3 install -r compute_worker_requirements.txt ADD compute_worker . CMD celery -A compute_worker worker \ diff --git a/Dockerfile.flower b/Dockerfile.flower index 58d6540b1..30d482a8e 100644 --- a/Dockerfile.flower +++ b/Dockerfile.flower @@ -1,16 +1,27 @@ -FROM python:3.9-alpine - -# Get latest root certificates -RUN apk add --no-cache ca-certificates && update-ca-certificates - -# Install the required packages -RUN pip install --no-cache-dir redis==3.0.1 flower==0.9.3 "celery<5.0.0" +FROM python:3.9 # PYTHONUNBUFFERED: Force stdin, stdout and stderr to be totally unbuffered. (equivalent to `python -u`) # PYTHONHASHSEED: Enable hash randomization (equivalent to `python -R`) # PYTHONDONTWRITEBYTECODE: Do not write byte files to disk, since we maintain it as readonly. (equivalent to `python -B`) ENV PYTHONUNBUFFERED=1 PYTHONHASHSEED=random PYTHONDONTWRITEBYTECODE=1 +# Get latest root certificates +RUN apt-get update && apt-get install -y ca-certificates && update-ca-certificates + +# # Install the required packages +RUN curl -sSL https://install.python-poetry.org | python3 - --version 1.8.3 +# Poetry location so future commands (below) work +ENV PATH $PATH:/root/.local/bin +# Want poetry to use system python of docker container +RUN poetry config virtualenvs.create false +RUN poetry config virtualenvs.in-project false + +RUN poetry init --no-interaction + +RUN poetry add redis=3.0.1 +RUN poetry add flower=0.9.3 +RUN poetry add celery="<5.0.0" + # Default port EXPOSE 5555 diff --git a/Dockerfile.rabbitmq b/Dockerfile.rabbitmq new file mode 100644 index 000000000..106c81022 --- /dev/null +++ b/Dockerfile.rabbitmq @@ -0,0 +1,3 @@ +FROM rabbitmq:3.13.7-management +ARG WORKER_CONNECTION_TIMEOUT +RUN echo "consumer_timeout = $WORKER_CONNECTION_TIMEOUT" >> /etc/rabbitmq/conf.d/10-defaults.conf diff --git a/README.md b/README.md index 2b3308d0f..c35898589 100644 --- a/README.md +++ b/README.md @@ -20,16 +20,18 @@ If you wish to configure your own instance of Codabench platform, here are the i ``` $ cp .env_sample .env -$ docker-compose up -d -$ docker-compose exec django ./manage.py migrate -$ docker-compose exec django ./manage.py generate_data -$ docker-compose exec django ./manage.py collectstatic --noinput +$ docker compose up -d +$ docker compose exec django ./manage.py migrate +$ docker compose exec django ./manage.py generate_data +$ docker compose exec django ./manage.py collectstatic --noinput ``` -You can now login as username "admin" with password "admin" at http://localhost:8000 +You can now login as username "admin" with password "admin" at http://localhost/ If you ever need to reset the database, use the script `./reset_db.sh` +For more information about installation, checkout [Codabench Basic Installation Guide](https://github.com/codalab/codabench/wiki/Codabench-Installation) and [How to Deploy Server](https://github.com/codalab/codabench/wiki/How-to-deploy-Codabench-on-your-server). + ## License diff --git a/compute_worker/compute_worker.py b/compute_worker/compute_worker.py index 5df5995eb..37c14c911 100644 --- a/compute_worker/compute_worker.py +++ b/compute_worker/compute_worker.py @@ -82,9 +82,6 @@ # Setup the container engine that we are using if os.environ.get("CONTAINER_ENGINE_EXECUTABLE"): CONTAINER_ENGINE_EXECUTABLE = os.environ.get("CONTAINER_ENGINE_EXECUTABLE") -# We could probably depreciate this now that we can specify the executable -elif os.environ.get("NVIDIA_DOCKER"): - CONTAINER_ENGINE_EXECUTABLE = "nvidia-docker" else: CONTAINER_ENGINE_EXECUTABLE = "docker" @@ -164,7 +161,7 @@ def get_folder_size_in_gb(folder): total_size += os.path.getsize(path) elif os.path.isdir(path): total_size += get_folder_size_in_gb(path) - return total_size / 1024 / 1024 / 1024 + return total_size / 1000 / 1000 / 1000 # GB: decimal system (1000^3) def delete_files_in_folder(folder): @@ -283,7 +280,7 @@ async def watch_detailed_results(self): else: logger.info(time.time() - start) if time.time() - start > expiration_seconds: - timeout_error_message = f"Detailed results not written to after {expiration_seconds} seconds, exiting!" + timeout_error_message = f"WARNING: Detailed results not written before the execution." logger.warning(timeout_error_message) await asyncio.sleep(5) file_path = self.get_detailed_results_file_path() @@ -334,7 +331,7 @@ def _update_submission(self, data): logger.info(f"Updating submission @ {url} with data = {data}") - resp = self.requests_session.patch(url, data, timeout=15) + resp = self.requests_session.patch(url, data, timeout=150) if resp.status_code == 200: logger.info("Submission updated successfully!") else: @@ -360,23 +357,32 @@ def _update_status(self, status, extra_information=None): def _get_container_image(self, image_name): logger.info("Running pull for image: {}".format(image_name)) - try: - cmd = [CONTAINER_ENGINE_EXECUTABLE, 'pull', image_name] - container_engine_pull = check_output(cmd) - logger.info("Pull complete for image: {0} with output of {1}".format(image_name, container_engine_pull)) - except CalledProcessError: - error_message = f"Pull for image: {image_name} returned a non-zero exit code! Check if the docker image exists on docker hub." - logger.info(error_message) - # Prepare data to be sent to submissions api - docker_pull_fail_data = { - "type": "Docker_Image_Pull_Fail", - "error_message": error_message, - } - # Send data to be written to ingestion logs - self._update_submission(docker_pull_fail_data) - # Send error through web socket to the frontend - asyncio.run(self._send_data_through_socket(error_message)) - raise DockerImagePullException(f"Pull for {image_name} failed!") + retries, max_retries = (0, 3) + while retries < max_retries: + try: + cmd = [CONTAINER_ENGINE_EXECUTABLE, 'pull', image_name] + container_engine_pull = check_output(cmd) + logger.info("Pull complete for image: {0} with output of {1}".format(image_name, container_engine_pull)) + break # Break if the loop is successful + except CalledProcessError as pull_error: + retries += 1 + if retries >= max_retries: + error_message = f"Pull for image: {image_name} returned a non-zero exit code! Check if the docker image exists on docker hub. {pull_error}" + logger.info(error_message) + # Prepare data to be sent to submissions api + docker_pull_fail_data = { + "type": "Docker_Image_Pull_Fail", + "error_message": error_message, + "is_scoring": self.is_scoring + } + # Send data to be written to ingestion logs + self._update_submission(docker_pull_fail_data) + # Send error through web socket to the frontend + asyncio.run(self._send_data_through_socket(error_message)) + raise DockerImagePullException(f"Pull for {image_name} failed!") + else: + logger.info("Failed. Retrying in 5 seconds...") + time.sleep(5) # Wait 5 seconds before retrying async def _send_data_through_socket(self, error_message): """ @@ -498,12 +504,25 @@ async def _run_container_engine_cmd(self, engine_cmd, kind): websocket = await websockets.connect(self.websocket_url) websocket_errors = (socket.gaierror, websockets.WebSocketException, websockets.ConnectionClosedError, ConnectionRefusedError) + # Function to read a line, if the line is larger than the buffer size we will + # return the buffer so we can continue reading until we get a newline, rather + # than getting a LimitOverrunError + async def _readline_or_chunk(stream): + try: + return await stream.readuntil(b"\n") + except asyncio.exceptions.IncompleteReadError as e: + # Just return what has been read so far + return e.partial + except asyncio.exceptions.LimitOverrunError as e: + # If we get a LimitOverrunError, we will return the buffer so we can continue reading + return await stream.read(e.consumed) + while any(v["continue"] for k, v in self.logs[kind].items() if k in ['stdout', 'stderr']): try: logs = [self.logs[kind][key] for key in ('stdout', 'stderr')] for value in logs: try: - out = await asyncio.wait_for(value["stream"].readline(), timeout=.1) + out = await asyncio.wait_for(_readline_or_chunk(value["stream"]), timeout=.1) if out: value["data"] += out print("WS: " + str(out)) @@ -564,7 +583,14 @@ def _get_host_path(self, *paths): return path - async def _run_program_directory(self, program_dir, kind, can_be_output=False): + async def _run_program_directory(self, program_dir, kind): + """ + Function responsible for running program directory + + Args: + - program_dir : can be either ingestion program or program/submission + - kind : either `program` or `ingestion` + """ # If the directory doesn't even exist, move on if not os.path.exists(program_dir): logger.info(f"{program_dir} not found, no program to execute") @@ -578,12 +604,13 @@ async def _run_program_directory(self, program_dir, kind, can_be_output=False): elif os.path.exists(os.path.join(program_dir, "metadata")): metadata_path = 'metadata' else: - if can_be_output: + # Display a warning in logs when there is no metadata file in submission/program dir + if kind == "program": logger.info( - "Program directory missing metadata, assuming it's going to be handled by ingestion " - "program so move it to output" + "Program directory missing metadata, assuming it's going to be handled by ingestion" ) - # Copying so that we don't move a code submission w/out a metadata command + # Copy submission files into prediction output + # This is useful for results submissions but wrongly uses storage shutil.copytree(program_dir, self.output_dir) return else: @@ -632,6 +659,10 @@ async def _run_program_directory(self, program_dir, kind, can_be_output=False): '-e', 'PYTHONUNBUFFERED=1', ] + # GPU or not + if os.environ.get("USE_GPU"): + engine_cmd.extend(['--gpus', 'all']) + if kind == 'ingestion': # program here is either scoring program or submission, depends on if this ran during Prediction or Scoring if self.ingestion_only_during_scoring and self.is_scoring: @@ -682,7 +713,7 @@ def _put_dir(self, url, directory): start_time = time.time() zip_path = make_archive(os.path.join(self.root_dir, str(uuid.uuid4())), 'zip', directory) duration = time.time() - start_time - logger.info("Time needed to zip archive: {duration} seconds.") + logger.info(f"Time needed to zip archive: {duration} seconds.") if is_valid_zip(zip_path): # Check zip integrity self._put_file(url, file=zip_path) # Send the file break # Leave the loop in case of success @@ -792,7 +823,7 @@ def start(self): logger.info("Running scoring program, and then ingestion program") loop = asyncio.new_event_loop() gathered_tasks = asyncio.gather( - self._run_program_directory(program_dir, kind='program', can_be_output=True), + self._run_program_directory(program_dir, kind='program'), self._run_program_directory(ingestion_program_dir, kind='ingestion'), self.watch_detailed_results(), loop=loop, diff --git a/compute_worker/compute_worker_requirements.txt b/compute_worker/compute_worker_requirements.txt deleted file mode 100644 index 89600fa51..000000000 --- a/compute_worker/compute_worker_requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -celery==4.4.0 -requests==2.20.0 -watchdog==0.8.3 -websockets==8.1 -aiofiles==0.4.0 diff --git a/compute_worker/poetry.lock b/compute_worker/poetry.lock new file mode 100644 index 000000000..4e4e4ff0b --- /dev/null +++ b/compute_worker/poetry.lock @@ -0,0 +1,415 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "aiofiles" +version = "0.4.0" +description = "File support for asyncio." +optional = false +python-versions = "*" +files = [ + {file = "aiofiles-0.4.0-py3-none-any.whl", hash = "sha256:1e644c2573f953664368de28d2aa4c89dfd64550429d0c27c4680ccd3aa4985d"}, + {file = "aiofiles-0.4.0.tar.gz", hash = "sha256:021ea0ba314a86027c166ecc4b4c07f2d40fc0f4b3a950d1868a0f2571c2bbee"}, +] + +[[package]] +name = "amqp" +version = "2.6.1" +description = "Low-level AMQP client for Python (fork of amqplib)." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "amqp-2.6.1-py2.py3-none-any.whl", hash = "sha256:aa7f313fb887c91f15474c1229907a04dac0b8135822d6603437803424c0aa59"}, + {file = "amqp-2.6.1.tar.gz", hash = "sha256:70cdb10628468ff14e57ec2f751c7aa9e48e7e3651cfd62d431213c0c4e58f21"}, +] + +[package.dependencies] +vine = ">=1.1.3,<5.0.0a1" + +[[package]] +name = "argh" +version = "0.26.2" +description = "An unobtrusive argparse wrapper with natural syntax" +optional = false +python-versions = "*" +files = [ + {file = "argh-0.26.2-py2.py3-none-any.whl", hash = "sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3"}, + {file = "argh-0.26.2.tar.gz", hash = "sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65"}, +] + +[[package]] +name = "billiard" +version = "3.6.4.0" +description = "Python multiprocessing fork with improvements and bugfixes" +optional = false +python-versions = "*" +files = [ + {file = "billiard-3.6.4.0-py3-none-any.whl", hash = "sha256:87103ea78fa6ab4d5c751c4909bcff74617d985de7fa8b672cf8618afd5a875b"}, + {file = "billiard-3.6.4.0.tar.gz", hash = "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547"}, +] + +[[package]] +name = "celery" +version = "4.4.0" +description = "Distributed Task Queue." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*," +files = [ + {file = "celery-4.4.0-py2.py3-none-any.whl", hash = "sha256:7c544f37a84a5eadc44cab1aa8c9580dff94636bb81978cdf9bf8012d9ea7d8f"}, + {file = "celery-4.4.0.tar.gz", hash = "sha256:d3363bb5df72d74420986a435449f3c3979285941dff57d5d97ecba352a0e3e2"}, +] + +[package.dependencies] +billiard = ">=3.6.1,<4.0" +kombu = ">=4.6.7,<4.7" +pytz = ">0.0-dev" +vine = "1.3.0" + +[package.extras] +arangodb = ["pyArango (>=1.3.2)"] +auth = ["cryptography"] +azureblockblob = ["azure-common (==1.1.5)", "azure-storage (==0.36.0)", "azure-storage-common (==1.1.0)"] +brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"] +cassandra = ["cassandra-driver"] +consul = ["python-consul"] +cosmosdbsql = ["pydocumentdb (==2.3.2)"] +couchbase = ["couchbase", "couchbase-cffi"] +couchdb = ["pycouchdb"] +django = ["Django (>=1.11)"] +dynamodb = ["boto3 (>=1.9.178)"] +elasticsearch = ["elasticsearch"] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent"] +librabbitmq = ["librabbitmq (>=1.5.0)"] +lzma = ["backports.lzma"] +memcache = ["pylibmc"] +mongodb = ["pymongo[srv] (>=3.3.0)"] +msgpack = ["msgpack"] +pymemcache = ["python-memcached"] +pyro = ["pyro4"] +redis = ["redis (>=3.2.0)"] +riak = ["riak (>=2.0)"] +s3 = ["boto3 (>=1.9.125)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +solar = ["ephem"] +sqlalchemy = ["sqlalchemy"] +sqs = ["boto3 (>=1.9.125)", "pycurl"] +tblib = ["tblib (>=1.3.0)", "tblib (>=1.5.0)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=1.3.1)"] +zstd = ["zstandard"] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "kombu" +version = "4.6.11" +description = "Messaging library for Python." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "kombu-4.6.11-py2.py3-none-any.whl", hash = "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a"}, + {file = "kombu-4.6.11.tar.gz", hash = "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74"}, +] + +[package.dependencies] +amqp = ">=2.6.0,<2.7" + +[package.extras] +azureservicebus = ["azure-servicebus (>=0.21.1)"] +azurestoragequeues = ["azure-storage-queue"] +consul = ["python-consul (>=0.6.0)"] +librabbitmq = ["librabbitmq (>=1.5.2)"] +mongodb = ["pymongo (>=3.3.0)"] +msgpack = ["msgpack"] +pyro = ["pyro4"] +qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] +redis = ["redis (>=3.3.11)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +sqlalchemy = ["sqlalchemy"] +sqs = ["boto3 (>=1.4.4)", "pycurl (==7.43.0.2)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=1.3.1)"] + +[[package]] +name = "pytz" +version = "2024.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, + {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, +] + +[[package]] +name = "pyyaml" +version = "5.3.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = "*" +files = [ + {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, + {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, + {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, + {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, + {file = "PyYAML-5.3.1-cp39-cp39-win32.whl", hash = "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a"}, + {file = "PyYAML-5.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e"}, + {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "urllib3" +version = "2.2.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.8" +files = [ + {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, + {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "vine" +version = "1.3.0" +description = "Promises, promises, promises." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "vine-1.3.0-py2.py3-none-any.whl", hash = "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af"}, + {file = "vine-1.3.0.tar.gz", hash = "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87"}, +] + +[[package]] +name = "watchdog" +version = "2.1.1" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.6" +files = [ + {file = "watchdog-2.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f3edbe1e15e229d2ba8ff5156908adba80d1ba21a9282d9f72247403280fc799"}, + {file = "watchdog-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c1325b47463fce231d88eb74f330ab0cb4a1bab5defe12c0c80a3a4f197345b4"}, + {file = "watchdog-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b391bac7edbdf96fb82a381d04829bbc0d1bb259c206b2b283ef8989340240f"}, + {file = "watchdog-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:22c13c19599b0dec7192f8f7d26404d5223cb36c9a450e96430483e685dccd7e"}, + {file = "watchdog-2.1.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:604ca364a79c27a694ab10947cd41de81bf229cff507a3156bf2c56c064971a1"}, + {file = "watchdog-2.1.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:dca75d12712997c713f76e6d68ff41580598c7df94cedf83f1089342e7709081"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:aa59afc87a892ed92d7d88d09f4b736f1336fc35540b403da7ee00c3be74bd07"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_armv7l.whl", hash = "sha256:a1b3f76e2a0713b406348dd5b9df2aa02bdd741a6ddf54f4c6410b395e077502"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_i686.whl", hash = "sha256:9f1b124fe2d4a1f37b7068f6289c2b1eba44859eb790bf6bd709adff224a5469"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_ppc64.whl", hash = "sha256:a9005f968220b715101d5fcdde5f5deda54f0d1873f618724f547797171f5e97"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:027c532e2fd3367d55fe235510fc304381a6cc88d0dcd619403e57ffbd83c1d2"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:4d83c89ba24bd67b7a7d5752a4ef953ec40db69d4d30582bd1f27d3ecb6b61b0"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:67c645b1e500cc74d550e9aad4829309c5084dc55e8dc4e1c25d5da23e5be239"}, + {file = "watchdog-2.1.1-py3-none-win32.whl", hash = "sha256:12645d41d7307601b318c48861e776ce7a9fdcad9f74961013ec39037050582c"}, + {file = "watchdog-2.1.1-py3-none-win_amd64.whl", hash = "sha256:16078cd241a95124acd4d8d3efba2140faec9300674b12413cc08be11b825d56"}, + {file = "watchdog-2.1.1-py3-none-win_ia64.whl", hash = "sha256:20d4cabfa2ad7239995d81a0163bc0264a3e104a64f33c6f0a21ad75a0d915d9"}, + {file = "watchdog-2.1.1.tar.gz", hash = "sha256:2894440b4ea95a6ef4c5d152deedbe270cae46092682710b7028a04d6a6980f6"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)", "argh (>=0.24.1)"] + +[[package]] +name = "websockets" +version = "8.1" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "websockets-8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c"}, + {file = "websockets-8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170"}, + {file = "websockets-8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8"}, + {file = "websockets-8.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb"}, + {file = "websockets-8.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5"}, + {file = "websockets-8.1-cp36-cp36m-win32.whl", hash = "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a"}, + {file = "websockets-8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5"}, + {file = "websockets-8.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989"}, + {file = "websockets-8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d"}, + {file = "websockets-8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779"}, + {file = "websockets-8.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8"}, + {file = "websockets-8.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422"}, + {file = "websockets-8.1-cp37-cp37m-win32.whl", hash = "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc"}, + {file = "websockets-8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308"}, + {file = "websockets-8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092"}, + {file = "websockets-8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485"}, + {file = "websockets-8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1"}, + {file = "websockets-8.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55"}, + {file = "websockets-8.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824"}, + {file = "websockets-8.1-cp38-cp38-win32.whl", hash = "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36"}, + {file = "websockets-8.1-cp38-cp38-win_amd64.whl", hash = "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"}, + {file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9" +content-hash = "9697a280c4d0e548f73c3166b083bbd3ddfb84086ecb481c8df7db8a7cef179b" diff --git a/compute_worker/pyproject.toml b/compute_worker/pyproject.toml new file mode 100644 index 000000000..23b546311 --- /dev/null +++ b/compute_worker/pyproject.toml @@ -0,0 +1,20 @@ +[tool.poetry] +name = "compute-worker" +version = "0.1.0" +description = "" +authors = ["codalab"] + +[tool.poetry.dependencies] +python = "^3.9" +celery = "4.4.0" +requests = "^2.20.0" +watchdog = "2.1.1" +argh = "0.26.2" +websockets = "8.1" +aiofiles = "0.4.0" +pyyaml = "5.3.1" + + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/docker-compose.selenium.yml b/docker-compose.selenium.yml index b918d72f7..450f2c3e4 100644 --- a/docker-compose.selenium.yml +++ b/docker-compose.selenium.yml @@ -8,7 +8,7 @@ services: - 36475:36475 selenium: - image: selenium/standalone-firefox-debug:3.141.59 + image: selenium/standalone-firefox:124.0 volumes: - ./src/tests/functional/test_files:/test_files/ - ./artifacts:/artifacts/:z diff --git a/docker-compose.yml b/docker-compose.yml index 22311bb68..09165a9d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,14 +4,17 @@ services: # Web Services #----------------------------------------------- caddy: - image: abiosoft/caddy:1.0.3 + image: caddy:2.10.0 env_file: .env environment: - ACME_AGREE=true volumes: - - ./Caddyfile:/etc/Caddyfile + - ./Caddyfile:/etc/caddy/Caddyfile - ./src/staticfiles:/var/www/django/static - - ./certs/caddy:/etc/caddycerts + - ./caddy_data:/data + - ./caddy_config:/config + - ./var/log/caddy:/var/log/ + - ./maintenance_mode/:/srv restart: unless-stopped ports: - 80:80 @@ -22,7 +25,7 @@ services: django: build: . # NOTE: We use watchmedo to reload gunicorn nicely, Uvicorn + Gunicorn reloads don't work well - command: bash -c "cd /app/src && watchmedo auto-restart -p '*.py' --recursive -- gunicorn asgi:application -w 2 -k uvicorn.workers.UvicornWorker -b :8000 -b :80 --capture-output --log-level debug" + command: bash -c "python manage.py collectstatic --noinput && cd /app/src && watchmedo auto-restart -p '*.py' --recursive -- gunicorn asgi:application -w 2 -k uvicorn.workers.UvicornWorker -b :8000 -b :80 --capture-output --log-level debug" environment: - DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME} env_file: .env @@ -30,6 +33,7 @@ services: - .:/app:delegated - /tmp/codalab-v2/django:/codalab_tmp - ./backups:/app/backups + - ./var/logs:/app/logs restart: unless-stopped ports: - 8000:8000 @@ -41,8 +45,8 @@ services: tty: true logging: options: - max-size: "20k" - max-file: "10" + max-size: "20m" + max-file: "5" #----------------------------------------------- @@ -98,8 +102,8 @@ services: restart: unless-stopped logging: options: - max-size: "20k" - max-file: "10" + max-size: "20m" + max-file: "5" #----------------------------------------------- @@ -111,6 +115,7 @@ services: environment: - PGDATA=/var/lib/postgresql/data/pgdata - POSTGRES_PASSWORD=${DB_PASSWORD} + command: ["postgres", "-c", "log_statement=all", "-c", "log_destination=stderr"] ports: - 5432:5432 volumes: @@ -119,18 +124,26 @@ services: restart: unless-stopped logging: options: - max-size: "20k" - max-file: "10" + max-size: "20m" + max-file: "5" #----------------------------------------------- # Rabbitmq & Flower monitoring tool #----------------------------------------------- rabbit: - image: rabbitmq:management + build: + context: . + dockerfile: Dockerfile.rabbitmq + args: + - WORKER_CONNECTION_TIMEOUT=${WORKER_CONNECTION_TIMEOUT} # setting hostname here makes data persist properly between # containers being destroyed..! hostname: rabbit env_file: .env + environment: + - http_proxy=${RABBITMQ_HTTP_PROXY} + - https_proxy=${RABBITMQ_HTTPS_PROXY} + - no_proxy=${RABBITMQ_NO_PROXY} ports: - ${RABBITMQ_MANAGEMENT_PORT:-15672}:15672 - ${RABBITMQ_PORT}:5672 @@ -139,8 +152,8 @@ services: restart: unless-stopped logging: options: - max-size: "20k" - max-file: "10" + max-size: "20m" + max-file: "5" flower: # image: mher/flower @@ -157,8 +170,8 @@ services: - rabbit logging: options: - max-size: "20k" - max-file: "10" + max-size: "20m" + max-file: "5" #----------------------------------------------- # Redis @@ -170,8 +183,8 @@ services: restart: unless-stopped logging: options: - max-size: "20k" - max-file: "10" + max-size: "20m" + max-file: "5" #----------------------------------------------- # Celery Service @@ -191,8 +204,8 @@ services: restart: unless-stopped logging: options: - max-size: "20k" - max-file: "10" + max-size: "20m" + max-file: "5" deploy: resources: limits: @@ -221,5 +234,5 @@ services: - CODALAB_IGNORE_CLEANUP_STEP=1 logging: options: - max-size: "20k" - max-file: "10" + max-size: "20m" + max-file: "5" diff --git a/docs/example_scripts/example_submission.py b/docs/example_scripts/example_submission.py index 6f48c36a9..337a4d2ce 100755 --- a/docs/example_scripts/example_submission.py +++ b/docs/example_scripts/example_submission.py @@ -41,7 +41,7 @@ # ---------------------------------------------------------------------------- from urllib.parse import urljoin # noqa: E402 import requests # noqa: E402,E261 # Ignore E261 to line up these noqa - +import os # Check someone updated PHASE_ID argument! assert PHASE_ID, "PHASE_ID must be set at the top of this script" @@ -68,11 +68,16 @@ print(f"Failed to create submission: {resp.json()['reason']}") exit(-2) + + +file_size = os.path.getsize(SUBMISSION_ZIP_PATH) + # Create + Upload our dataset datasets_url = urljoin(CODALAB_URL, '/api/datasets/') datasets_payload = { "type": "submission", "request_sassy_file_name": "submission.zip", + "file_size": file_size, } resp = requests.post(datasets_url, datasets_payload, headers=headers) if resp.status_code != 201: diff --git a/maintenance_mode/maintenance.html b/maintenance_mode/maintenance.html new file mode 100644 index 000000000..e300ec782 --- /dev/null +++ b/maintenance_mode/maintenance.html @@ -0,0 +1,19 @@ + + + + Codabench + + + + offline +

The Codabench site is being upgraded...

+

Sorry, the page you are looking for is currently unavailable because the site is undergoing maintenance.

+

Please try again later. Thank you.

+ + \ No newline at end of file diff --git a/maintenance_mode/offline.png b/maintenance_mode/offline.png new file mode 100644 index 000000000..837974125 Binary files /dev/null and b/maintenance_mode/offline.png differ diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 000000000..2a8fa6429 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,3528 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "aiofiles" +version = "0.4.0" +description = "File support for asyncio." +optional = false +python-versions = "*" +files = [ + {file = "aiofiles-0.4.0-py3-none-any.whl", hash = "sha256:1e644c2573f953664368de28d2aa4c89dfd64550429d0c27c4680ccd3aa4985d"}, + {file = "aiofiles-0.4.0.tar.gz", hash = "sha256:021ea0ba314a86027c166ecc4b4c07f2d40fc0f4b3a950d1868a0f2571c2bbee"}, +] + +[[package]] +name = "amqp" +version = "2.6.1" +description = "Low-level AMQP client for Python (fork of amqplib)." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "amqp-2.6.1-py2.py3-none-any.whl", hash = "sha256:aa7f313fb887c91f15474c1229907a04dac0b8135822d6603437803424c0aa59"}, + {file = "amqp-2.6.1.tar.gz", hash = "sha256:70cdb10628468ff14e57ec2f751c7aa9e48e7e3651cfd62d431213c0c4e58f21"}, +] + +[package.dependencies] +vine = ">=1.1.3,<5.0.0a1" + +[[package]] +name = "ansicon" +version = "1.89.0" +description = "Python wrapper for loading Jason Hood's ANSICON" +optional = false +python-versions = "*" +files = [ + {file = "ansicon-1.89.0-py2.py3-none-any.whl", hash = "sha256:f1def52d17f65c2c9682cf8370c03f541f410c1752d6a14029f97318e4b9dfec"}, + {file = "ansicon-1.89.0.tar.gz", hash = "sha256:e4d039def5768a47e4afec8e89e83ec3ae5a26bf00ad851f914d1240b444d2b1"}, +] + +[[package]] +name = "appnope" +version = "0.1.4" +description = "Disable App Nap on macOS >= 10.9" +optional = false +python-versions = ">=3.6" +files = [ + {file = "appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c"}, + {file = "appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee"}, +] + +[[package]] +name = "argh" +version = "0.26.2" +description = "An unobtrusive argparse wrapper with natural syntax" +optional = false +python-versions = "*" +files = [ + {file = "argh-0.26.2-py2.py3-none-any.whl", hash = "sha256:a9b3aaa1904eeb78e32394cd46c6f37ac0fb4af6dc488daa58971bdc7d7fcaf3"}, + {file = "argh-0.26.2.tar.gz", hash = "sha256:e9535b8c84dc9571a48999094fda7f33e63c3f1b74f3e5f3ac0105a58405bb65"}, +] + +[[package]] +name = "asgiref" +version = "3.8.1" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.8" +files = [ + {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, + {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""} + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "asttokens" +version = "3.0.0" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = ">=3.8" +files = [ + {file = "asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2"}, + {file = "asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7"}, +] + +[package.extras] +astroid = ["astroid (>=2,<4)"] +test = ["astroid (>=2,<4)", "pytest", "pytest-cov", "pytest-xdist"] + +[[package]] +name = "async-timeout" +version = "5.0.1" +description = "Timeout context manager for asyncio programs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, +] + +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, +] + +[[package]] +name = "attrs" +version = "24.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, + {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + +[[package]] +name = "autobahn" +version = "24.4.2" +description = "WebSocket client & server library, WAMP real-time framework" +optional = false +python-versions = ">=3.9" +files = [ + {file = "autobahn-24.4.2-py2.py3-none-any.whl", hash = "sha256:c56a2abe7ac78abbfb778c02892d673a4de58fd004d088cd7ab297db25918e81"}, + {file = "autobahn-24.4.2.tar.gz", hash = "sha256:a2d71ef1b0cf780b6d11f8b205fd2c7749765e65795f2ea7d823796642ee92c9"}, +] + +[package.dependencies] +cryptography = ">=3.4.6" +hyperlink = ">=21.0.0" +setuptools = "*" +txaio = ">=21.2.1" + +[package.extras] +all = ["PyGObject (>=3.40.0)", "argon2-cffi (>=20.1.0)", "attrs (>=20.3.0)", "base58 (>=2.1.0)", "bitarray (>=2.7.5)", "cbor2 (>=5.2.0)", "cffi (>=1.14.5)", "click (>=8.1.2)", "ecdsa (>=0.16.1)", "eth-abi (>=4.0.0)", "flatbuffers (>=22.12.6)", "hkdf (>=0.0.3)", "jinja2 (>=2.11.3)", "mnemonic (>=0.19)", "msgpack (>=1.0.2)", "passlib (>=1.7.4)", "py-ecc (>=5.1.0)", "py-eth-sig-utils (>=0.4.0)", "py-multihash (>=2.0.1)", "py-ubjson (>=0.16.1)", "pynacl (>=1.4.0)", "pyopenssl (>=20.0.1)", "python-snappy (>=0.6.0)", "pytrie (>=0.4.0)", "qrcode (>=7.3.1)", "rlp (>=2.0.1)", "service-identity (>=18.1.0)", "spake2 (>=0.8)", "twisted (>=20.3.0)", "twisted (>=24.3.0)", "u-msgpack-python (>=2.1)", "ujson (>=4.0.2)", "web3[ipfs] (>=6.0.0)", "xbr (>=21.2.1)", "yapf (==0.29.0)", "zlmdb (>=21.2.1)", "zope.interface (>=5.2.0)"] +compress = ["python-snappy (>=0.6.0)"] +dev = ["backports.tempfile (>=1.0)", "build (>=1.2.1)", "bumpversion (>=0.5.3)", "codecov (>=2.0.15)", "flake8 (<5)", "humanize (>=0.5.1)", "mypy (>=0.610)", "passlib", "pep8-naming (>=0.3.3)", "pip (>=9.0.1)", "pyenchant (>=1.6.6)", "pyflakes (>=1.0.0)", "pyinstaller (>=4.2)", "pylint (>=1.9.2)", "pytest (>=3.4.2)", "pytest-aiohttp", "pytest-asyncio (>=0.14.0)", "pytest-runner (>=2.11.1)", "pyyaml (>=4.2b4)", "qualname", "sphinx (>=1.7.1)", "sphinx-autoapi (>=1.7.0)", "sphinx-rtd-theme (>=0.1.9)", "sphinxcontrib-images (>=0.9.1)", "tox (>=4.2.8)", "tox-gh-actions (>=2.2.0)", "twine (>=3.3.0)", "twisted (>=22.10.0)", "txaio (>=20.4.1)", "watchdog (>=0.8.3)", "wheel (>=0.36.2)", "yapf (==0.29.0)"] +encryption = ["pynacl (>=1.4.0)", "pyopenssl (>=20.0.1)", "pytrie (>=0.4.0)", "qrcode (>=7.3.1)", "service-identity (>=18.1.0)"] +nvx = ["cffi (>=1.14.5)"] +scram = ["argon2-cffi (>=20.1.0)", "cffi (>=1.14.5)", "passlib (>=1.7.4)"] +serialization = ["cbor2 (>=5.2.0)", "flatbuffers (>=22.12.6)", "msgpack (>=1.0.2)", "py-ubjson (>=0.16.1)", "u-msgpack-python (>=2.1)", "ujson (>=4.0.2)"] +twisted = ["attrs (>=20.3.0)", "twisted (>=24.3.0)", "zope.interface (>=5.2.0)"] +ui = ["PyGObject (>=3.40.0)"] +xbr = ["base58 (>=2.1.0)", "bitarray (>=2.7.5)", "cbor2 (>=5.2.0)", "click (>=8.1.2)", "ecdsa (>=0.16.1)", "eth-abi (>=4.0.0)", "hkdf (>=0.0.3)", "jinja2 (>=2.11.3)", "mnemonic (>=0.19)", "py-ecc (>=5.1.0)", "py-eth-sig-utils (>=0.4.0)", "py-multihash (>=2.0.1)", "rlp (>=2.0.1)", "spake2 (>=0.8)", "twisted (>=20.3.0)", "web3[ipfs] (>=6.0.0)", "xbr (>=21.2.1)", "yapf (==0.29.0)", "zlmdb (>=21.2.1)"] + +[[package]] +name = "automat" +version = "24.8.1" +description = "Self-service finite-state machines for the programmer on the go." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Automat-24.8.1-py3-none-any.whl", hash = "sha256:bf029a7bc3da1e2c24da2343e7598affaa9f10bf0ab63ff808566ce90551e02a"}, + {file = "automat-24.8.1.tar.gz", hash = "sha256:b34227cf63f6325b8ad2399ede780675083e439b20c323d376373d8ee6306d88"}, +] + +[package.dependencies] +typing-extensions = {version = "*", markers = "python_version < \"3.10\""} + +[package.extras] +visualize = ["Twisted (>=16.1.1)", "graphviz (>0.5.1)"] + +[[package]] +name = "azure-common" +version = "1.1.28" +description = "Microsoft Azure Client Library for Python (Common)" +optional = false +python-versions = "*" +files = [ + {file = "azure-common-1.1.28.zip", hash = "sha256:4ac0cd3214e36b6a1b6a442686722a5d8cc449603aa833f3f0f40bda836704a3"}, + {file = "azure_common-1.1.28-py2.py3-none-any.whl", hash = "sha256:5c12d3dcf4ec20599ca6b0d3e09e86e146353d443e7fcc050c9a19c1f9df20ad"}, +] + +[[package]] +name = "azure-storage-blob" +version = "2.1.0" +description = "Microsoft Azure Storage Blob Client Library for Python" +optional = false +python-versions = "*" +files = [ + {file = "azure-storage-blob-2.1.0.tar.gz", hash = "sha256:b90323aad60f207f9f90a0c4cf94c10acc313c20b39403398dfba51f25f7b454"}, + {file = "azure_storage_blob-2.1.0-py2.py3-none-any.whl", hash = "sha256:a8e91a51d4f62d11127c7fd8ba0077385c5b11022f0269f8a2a71b9fc36bef31"}, +] + +[package.dependencies] +azure-common = ">=1.1.5" +azure-storage-common = ">=2.1,<3.0" + +[[package]] +name = "azure-storage-common" +version = "2.1.0" +description = "Microsoft Azure Storage Common Client Library for Python" +optional = false +python-versions = "*" +files = [ + {file = "azure-storage-common-2.1.0.tar.gz", hash = "sha256:ccedef5c67227bc4d6670ffd37cec18fb529a1b7c3a5e53e4096eb0cf23dc73f"}, + {file = "azure_storage_common-2.1.0-py2.py3-none-any.whl", hash = "sha256:b01a491a18839b9d05a4fe3421458a0ddb5ab9443c14e487f40d16f9a1dc2fbe"}, +] + +[package.dependencies] +azure-common = ">=1.1.5" +cryptography = "*" +python-dateutil = "*" +requests = "*" + +[[package]] +name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +optional = false +python-versions = "*" +files = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] + +[[package]] +name = "billiard" +version = "3.6.4.0" +description = "Python multiprocessing fork with improvements and bugfixes" +optional = false +python-versions = "*" +files = [ + {file = "billiard-3.6.4.0-py3-none-any.whl", hash = "sha256:87103ea78fa6ab4d5c751c4909bcff74617d985de7fa8b672cf8618afd5a875b"}, + {file = "billiard-3.6.4.0.tar.gz", hash = "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547"}, +] + +[[package]] +name = "black" +version = "21.12b0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.6.2" +files = [ + {file = "black-21.12b0-py3-none-any.whl", hash = "sha256:a615e69ae185e08fdd73e4715e260e2479c861b5740057fde6e8b4e3b7dd589f"}, + {file = "black-21.12b0.tar.gz", hash = "sha256:77b80f693a569e2e527958459634f18df9b0ba2625ba4e0c2d5da5be42e6f2b3"}, +] + +[package.dependencies] +click = ">=7.1.2" +mypy-extensions = ">=0.4.3" +pathspec = ">=0.9.0,<1" +platformdirs = ">=2" +tomli = ">=0.2.6,<2.0.0" +typing-extensions = ">=3.10.0.0" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +python2 = ["typed-ast (>=1.4.3)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "bleach" +version = "3.1.4" +description = "An easy safelist-based HTML-sanitizing tool." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "bleach-3.1.4-py2.py3-none-any.whl", hash = "sha256:cc8da25076a1fe56c3ac63671e2194458e0c4d9c7becfd52ca251650d517903c"}, + {file = "bleach-3.1.4.tar.gz", hash = "sha256:e78e426105ac07026ba098f04de8abe9b6e3e98b5befbf89b51a5ef0a4292b03"}, +] + +[package.dependencies] +six = ">=1.9.0" +webencodings = "*" + +[[package]] +name = "blessed" +version = "1.20.0" +description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities." +optional = false +python-versions = ">=2.7" +files = [ + {file = "blessed-1.20.0-py2.py3-none-any.whl", hash = "sha256:0c542922586a265e699188e52d5f5ac5ec0dd517e5a1041d90d2bbf23f906058"}, + {file = "blessed-1.20.0.tar.gz", hash = "sha256:2cdd67f8746e048f00df47a2880f4d6acbcdb399031b604e34ba8f71d5787680"}, +] + +[package.dependencies] +jinxed = {version = ">=1.1.0", markers = "platform_system == \"Windows\""} +six = ">=1.9.0" +wcwidth = ">=0.1.4" + +[[package]] +name = "blessings" +version = "1.7" +description = "A thin, practical wrapper around terminal coloring, styling, and positioning" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "blessings-1.7-py2-none-any.whl", hash = "sha256:caad5211e7ba5afe04367cdd4cfc68fa886e2e08f6f35e76b7387d2109ccea6e"}, + {file = "blessings-1.7-py3-none-any.whl", hash = "sha256:b1fdd7e7a675295630f9ae71527a8ebc10bfefa236b3d6aa4932ee4462c17ba3"}, + {file = "blessings-1.7.tar.gz", hash = "sha256:98e5854d805f50a5b58ac2333411b0482516a8210f23f43308baeb58d77c157d"}, +] + +[package.dependencies] +six = "*" + +[[package]] +name = "boto3" +version = "1.9.68" +description = "The AWS SDK for Python" +optional = false +python-versions = "*" +files = [ + {file = "boto3-1.9.68-py2.py3-none-any.whl", hash = "sha256:09d1ae44f15659e425b7526de452bfa3840e12dd3e08dd0b067a78f619e997b5"}, + {file = "boto3-1.9.68.tar.gz", hash = "sha256:88233f9bdc1e6ee58c9489cef6c573bf7e660acfcc1597bc3ff765a065e1a0f0"}, +] + +[package.dependencies] +botocore = ">=1.12.68,<1.13.0" +jmespath = ">=0.7.1,<1.0.0" +s3transfer = ">=0.1.10,<0.2.0" + +[[package]] +name = "botocore" +version = "1.12.253" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = "*" +files = [ + {file = "botocore-1.12.253-py2.py3-none-any.whl", hash = "sha256:dc080aed4f9b220a9e916ca29ca97a9d37e8e1d296fe89cbaeef929bf0c8066b"}, + {file = "botocore-1.12.253.tar.gz", hash = "sha256:3baf129118575602ada9926f5166d82d02273c250d0feb313fc270944b27c48b"}, +] + +[package.dependencies] +docutils = ">=0.10,<0.16" +jmespath = ">=0.7.1,<1.0.0" +python-dateutil = {version = ">=2.1,<3.0.0", markers = "python_version >= \"2.7\""} +urllib3 = {version = ">=1.20,<1.26", markers = "python_version >= \"3.4\""} + +[[package]] +name = "bpython" +version = "0.21" +description = "Fancy Interface to the Python Interpreter" +optional = false +python-versions = ">=3.6" +files = [ + {file = "bpython-0.21-py3-none-any.whl", hash = "sha256:64a2032052c629f0fc2d215cdcf3cbdc005d9001a4e8c11b2126e80899be77fb"}, + {file = "bpython-0.21.tar.gz", hash = "sha256:88aa9b89974f6a7726499a2608fa7ded216d84c69e78114ab2ef996a45709487"}, +] + +[package.dependencies] +curtsies = ">=0.3.5" +cwcwidth = "*" +greenlet = "*" +pygments = "*" +pyxdg = "*" +requests = "*" + +[package.extras] +jedi = ["jedi (>=0.16)"] +urwid = ["urwid"] +watch = ["watchdog"] + +[[package]] +name = "cachetools" +version = "5.5.0" +description = "Extensible memoizing collections and decorators" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, + {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, +] + +[[package]] +name = "celery" +version = "4.4.7" +description = "Distributed Task Queue." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "celery-4.4.7-py2.py3-none-any.whl", hash = "sha256:a92e1d56e650781fb747032a3997d16236d037c8199eacd5217d1a72893bca45"}, + {file = "celery-4.4.7.tar.gz", hash = "sha256:d220b13a8ed57c78149acf82c006785356071844afe0b27012a4991d44026f9f"}, +] + +[package.dependencies] +billiard = ">=3.6.3.0,<4.0" +kombu = ">=4.6.10,<4.7" +pytz = ">0.0-dev" +vine = "1.3.0" + +[package.extras] +arangodb = ["pyArango (>=1.3.2)"] +auth = ["cryptography"] +azureblockblob = ["azure-common (==1.1.5)", "azure-storage (==0.36.0)", "azure-storage-common (==1.1.0)"] +brotli = ["brotli (>=1.0.0)", "brotlipy (>=0.7.0)"] +cassandra = ["cassandra-driver (<3.21.0)"] +consul = ["python-consul"] +cosmosdbsql = ["pydocumentdb (==2.3.2)"] +couchbase = ["couchbase (<3.0.0)", "couchbase-cffi (<3.0.0)"] +couchdb = ["pycouchdb"] +django = ["Django (>=1.11)"] +dynamodb = ["boto3 (>=1.9.178)"] +elasticsearch = ["elasticsearch"] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent"] +librabbitmq = ["librabbitmq (>=1.5.0)"] +lzma = ["backports.lzma"] +memcache = ["pylibmc"] +mongodb = ["pymongo[srv] (>=3.3.0)"] +msgpack = ["msgpack"] +pymemcache = ["python-memcached"] +pyro = ["pyro4"] +redis = ["redis (>=3.2.0)"] +riak = ["riak (>=2.0)"] +s3 = ["boto3 (>=1.9.125)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +solar = ["ephem"] +sqlalchemy = ["sqlalchemy"] +sqs = ["boto3 (>=1.9.125)", "pycurl (==7.43.0.5)"] +tblib = ["tblib (>=1.3.0)", "tblib (>=1.5.0)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=1.3.1)"] +zstd = ["zstandard"] + +[[package]] +name = "certifi" +version = "2024.8.30" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.6" +files = [ + {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, + {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, +] + +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "channels" +version = "2.4.0" +description = "Brings async, event-driven capabilities to Django. Django 2.2 and up only." +optional = false +python-versions = ">=3.5" +files = [ + {file = "channels-2.4.0-py2.py3-none-any.whl", hash = "sha256:80a5ad1962ae039a3dcc0a5cb5212413e66e2f11ad9e9db8004834436daf3400"}, + {file = "channels-2.4.0.tar.gz", hash = "sha256:08e756406d7165cb32f6fc3090c0643f41ca9f7e0f7fada0b31194662f20f414"}, +] + +[package.dependencies] +asgiref = ">=3.2,<4.0" +daphne = ">=2.3,<3.0" +Django = ">=2.2" + +[package.extras] +tests = ["async-generator (>=1.10,<2.0)", "async-timeout (>=3.0,<4.0)", "coverage (>=4.5,<5.0)", "pytest (>=4.4,<5.0)", "pytest-asyncio (>=0.10,<1.0)", "pytest-django (>=3.4,<4.0)"] + +[[package]] +name = "channels-redis" +version = "4.0.0" +description = "Redis-backed ASGI channel layer implementation" +optional = false +python-versions = ">=3.7" +files = [ + {file = "channels_redis-4.0.0-py3-none-any.whl", hash = "sha256:81b59d68f53313e1aa891f23591841b684abb936b42e4d1a966d9e4dc63a95ec"}, + {file = "channels_redis-4.0.0.tar.gz", hash = "sha256:122414f29f525f7b9e0c9d59cdcfc4dc1b0eecba16fbb6a1c23f1d9b58f49dcb"}, +] + +[package.dependencies] +asgiref = ">=3.2.10,<4" +channels = "*" +msgpack = ">=1.0,<2.0" +redis = ">=4.2.0" + +[package.extras] +cryptography = ["cryptography (>=1.3.0)"] +tests = ["async-timeout", "cryptography (>=1.3.0)", "pytest", "pytest-asyncio"] + +[[package]] +name = "charset-normalizer" +version = "3.4.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, + {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, + {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, + {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, + {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, + {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, + {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, + {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, + {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, + {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +] + +[[package]] +name = "click" +version = "7.1.2" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "constantly" +version = "23.10.4" +description = "Symbolic constants in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "constantly-23.10.4-py3-none-any.whl", hash = "sha256:3fd9b4d1c3dc1ec9757f3c52aef7e53ad9323dbe39f51dfd4c43853b68dfa3f9"}, + {file = "constantly-23.10.4.tar.gz", hash = "sha256:aa92b70a33e2ac0bb33cd745eb61776594dc48764b06c35e0efd050b7f1c7cbd"}, +] + +[[package]] +name = "coreapi" +version = "2.3.3" +description = "Python client library for Core API." +optional = false +python-versions = "*" +files = [ + {file = "coreapi-2.3.3-py2.py3-none-any.whl", hash = "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3"}, + {file = "coreapi-2.3.3.tar.gz", hash = "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb"}, +] + +[package.dependencies] +coreschema = "*" +itypes = "*" +requests = "*" +uritemplate = "*" + +[[package]] +name = "coreschema" +version = "0.0.4" +description = "Core Schema." +optional = false +python-versions = "*" +files = [ + {file = "coreschema-0.0.4-py2-none-any.whl", hash = "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f"}, + {file = "coreschema-0.0.4.tar.gz", hash = "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607"}, +] + +[package.dependencies] +jinja2 = "*" + +[[package]] +name = "cryptography" +version = "44.0.0" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +files = [ + {file = "cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543"}, + {file = "cryptography-44.0.0-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:60eb32934076fa07e4316b7b2742fa52cbb190b42c2df2863dbc4230a0a9b385"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e"}, + {file = "cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e"}, + {file = "cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053"}, + {file = "cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd"}, + {file = "cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c"}, + {file = "cryptography-44.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9abcc2e083cbe8dde89124a47e5e53ec38751f0d7dfd36801008f316a127d7ba"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64"}, + {file = "cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285"}, + {file = "cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417"}, + {file = "cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:37d76e6863da3774cd9db5b409a9ecfd2c71c981c38788d3fcfaf177f447b731"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f677e1268c4e23420c3acade68fac427fffcb8d19d7df95ed7ad17cdef8404f4"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f5e7cb1e5e56ca0933b4873c0220a78b773b24d40d186b6738080b73d3d0a756"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:8b3e6eae66cf54701ee7d9c83c30ac0a1e3fa17be486033000f2a73a12ab507c"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:be4ce505894d15d5c5037167ffb7f0ae90b7be6f2a98f9a5c3442395501c32fa"}, + {file = "cryptography-44.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:62901fb618f74d7d81bf408c8719e9ec14d863086efe4185afd07c352aee1d2c"}, + {file = "cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02"}, +] + +[package.dependencies] +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==44.0.0)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + +[[package]] +name = "curtsies" +version = "0.4.2" +description = "Curses-like terminal wrapper, with colored strings!" +optional = false +python-versions = ">=3.7" +files = [ + {file = "curtsies-0.4.2-py3-none-any.whl", hash = "sha256:f24d676a8c4711fb9edba1ab7e6134bc52305a222980b3b717bb303f5e94cec6"}, + {file = "curtsies-0.4.2.tar.gz", hash = "sha256:6ebe33215bd7c92851a506049c720cca4cf5c192c1665c1d7a98a04c4702760e"}, +] + +[package.dependencies] +blessed = ">=1.5" +cwcwidth = "*" + +[[package]] +name = "cwcwidth" +version = "0.1.9" +description = "Python bindings for wc(s)width" +optional = false +python-versions = ">=3.8" +files = [ + {file = "cwcwidth-0.1.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:704f0d6888aa5e81e76d9f76709385f9f55aca8c450ee82cc722054814a7791f"}, + {file = "cwcwidth-0.1.9-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0633158205b50f253ad04e301156807e309a9fb9479a520418e010da6df13604"}, + {file = "cwcwidth-0.1.9-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a5407d0933c3aab8ee92cffd9e4f09010f25af10ebdfa19776748402bba9261"}, + {file = "cwcwidth-0.1.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:72490e07dfbc599fdf6efe26a13cfbf725f0513b181c3386d65bfd84f6175924"}, + {file = "cwcwidth-0.1.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf71151ae06e95f266bef91392c1562539b2eed847fd1f00f7b5b4ca3fd41a67"}, + {file = "cwcwidth-0.1.9-cp310-cp310-win32.whl", hash = "sha256:3e3c186b5c171d85f2b7f093e7efb33fd9b6e55b791ff75a0f101b18ec0433cd"}, + {file = "cwcwidth-0.1.9-cp310-cp310-win_amd64.whl", hash = "sha256:ae17e493ffc18497c2602f8f42a0d8e490ea42ab3ccfbe5e4a6069a6d24f3b36"}, + {file = "cwcwidth-0.1.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b1c3eb0a8c1b25c4a17b6b9bbf7d25ce9df3ea43b6f87903c51bc12434a2cc29"}, + {file = "cwcwidth-0.1.9-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c8752815ce4e40e7b34b7fe039276a5fbfb1b077131614381b13ef3b7bb21ff"}, + {file = "cwcwidth-0.1.9-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:368ace13080dbaacdc247370d8a965a749b124aa50d0b1b6eb87601826db870f"}, + {file = "cwcwidth-0.1.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:ca9a653661e152a426bdb51a272f36bc79f9830e6a7169abe8110ec367c3518c"}, + {file = "cwcwidth-0.1.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f96386cc29e6eef8ef066d7dd3c767c5119d66506dabea20dd344dabb3f2d225"}, + {file = "cwcwidth-0.1.9-cp311-cp311-win32.whl", hash = "sha256:f6ba88970ec12fdbed5554beb1b9a25d8271fc3d0d9e60639db700a79bed1863"}, + {file = "cwcwidth-0.1.9-cp311-cp311-win_amd64.whl", hash = "sha256:aa6725e7b3571fdf6ce7c02d1dd2d69e00d166bb6df44e46ab215067028b3a03"}, + {file = "cwcwidth-0.1.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:42de102d5191fc68ef3ff6530f60c4895148ddc21aa0acaaf4612e5f7f0c38c4"}, + {file = "cwcwidth-0.1.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:877e48c615b3fec88b7e640f9cf9d96704497657fb5aad2b7c0b0c59ecabff69"}, + {file = "cwcwidth-0.1.9-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdbaf0a8dad20eb685df11a195a2449fe230b08a5b356d036c8d7e59d4128a88"}, + {file = "cwcwidth-0.1.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f6e0e023c4b127c47fd4c44cf537be209b9a28d8725f4f576f4d63744a23aa38"}, + {file = "cwcwidth-0.1.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b4f7d24236ce3c9d3b5e07fd75d232452f19bdddb6ae8bbfdcb97b6cb02835e8"}, + {file = "cwcwidth-0.1.9-cp312-cp312-win32.whl", hash = "sha256:ba9da6c911bf108334426890bc9f57b839a38e1afc4383a41bd70adbce470db3"}, + {file = "cwcwidth-0.1.9-cp312-cp312-win_amd64.whl", hash = "sha256:40466f16e85c338e8fc3eee87a8c9ca23416cc68b3049f68cb4cead5fb8b71b3"}, + {file = "cwcwidth-0.1.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:167f59c3c1e1d8e231a1abd666af4e73dd8a94917efb6522e9b610ac4587903a"}, + {file = "cwcwidth-0.1.9-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:afc745f18c9e3c38851a931c0c0a7e479d6494911ba1353f998d707f95a895b4"}, + {file = "cwcwidth-0.1.9-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8d55c47cbec4796e89cfedc89c52e6c4c2faeb77489a763415b9f76d8fc14db"}, + {file = "cwcwidth-0.1.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:c858842849ce2cfdf207095253da83831d9407771c8073f6b75f24d3faf1a1eb"}, + {file = "cwcwidth-0.1.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cc049ce273f32b632f5ead649b2120f8b2b78035d7b069fdc460c4be9affddb5"}, + {file = "cwcwidth-0.1.9-cp38-cp38-win32.whl", hash = "sha256:1bafe978a5b7915848244a952829e3f8757c0cebef581c8250da6064c906c38c"}, + {file = "cwcwidth-0.1.9-cp38-cp38-win_amd64.whl", hash = "sha256:024d1b21e6123bf30a849e60eea3482f556bbd00d39215f86c904e5bd81fc1b6"}, + {file = "cwcwidth-0.1.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d367da5e6fb538388817bf5b2d6dd4db90e5e631d99c34055589d007b5c94bc"}, + {file = "cwcwidth-0.1.9-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad721d9dbc14eafd06176e4f5594942336b1e813de2a5ab7bd64254393c5713f"}, + {file = "cwcwidth-0.1.9-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:711ace9796cb6767ff29095ff5b0ec4619e7297854eb4b91ba99154590eddcc9"}, + {file = "cwcwidth-0.1.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:945615a7b8cdcbcd8e06d399f96a2b09440c3a4c5cb3c2d0109f00d80da27a12"}, + {file = "cwcwidth-0.1.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ffaf706abe400282f299463594d8887566e2a280cd0255110bd4397cc7be2910"}, + {file = "cwcwidth-0.1.9-cp39-cp39-win32.whl", hash = "sha256:03093cac6f8e4326b1c30169e024fe2894f76c6ffddf6464e489bb33cb3a2897"}, + {file = "cwcwidth-0.1.9-cp39-cp39-win_amd64.whl", hash = "sha256:0ddef2c504e6f4fd6122b46d55061f487add1ebb86596ae70ffc2a8b8955c8bc"}, + {file = "cwcwidth-0.1.9.tar.gz", hash = "sha256:f19d11a0148d4a8cacd064c96e93bca8ce3415a186ae8204038f45e108db76b8"}, +] + +[[package]] +name = "daphne" +version = "2.5.0" +description = "Django ASGI (HTTP/WebSocket) server" +optional = false +python-versions = "*" +files = [ + {file = "daphne-2.5.0-py2.py3-none-any.whl", hash = "sha256:aa64840015709bbc9daa3c4464a4a4d437937d6cda10a9b51e913eb319272553"}, + {file = "daphne-2.5.0.tar.gz", hash = "sha256:1ca46d7419103958bbc9576fb7ba3b25b053006e22058bc97084ee1a7d44f4ba"}, +] + +[package.dependencies] +asgiref = ">=3.2,<4.0" +autobahn = ">=0.18" +twisted = {version = ">=18.7", extras = ["tls"]} + +[package.extras] +tests = ["hypothesis (==4.23)", "pytest (>=3.10,<4.0)", "pytest-asyncio (>=0.8,<1.0)"] + +[[package]] +name = "decorator" +version = "5.1.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.5" +files = [ + {file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"}, + {file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"}, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] + +[[package]] +name = "dj-database-url" +version = "0.4.2" +description = "Use Database URLs in your Django Application." +optional = false +python-versions = "*" +files = [ + {file = "dj-database-url-0.4.2.tar.gz", hash = "sha256:a6832d8445ee9d788c5baa48aef8130bf61fdc442f7d9a548424d25cd85c9f08"}, + {file = "dj_database_url-0.4.2-py2.py3-none-any.whl", hash = "sha256:e16d94c382ea0564c48038fa7fe8d9c890ef1ab1a8ec4cb48e732c124b9482fd"}, +] + +[[package]] +name = "django" +version = "2.2.28" +description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.5" +files = [ + {file = "Django-2.2.28-py3-none-any.whl", hash = "sha256:365429d07c1336eb42ba15aa79f45e1c13a0b04d5c21569e7d596696418a6a45"}, + {file = "Django-2.2.28.tar.gz", hash = "sha256:0200b657afbf1bc08003845ddda053c7641b9b24951e52acd51f6abda33a7413"}, +] + +[package.dependencies] +pytz = "*" +sqlparse = ">=0.2.2" + +[package.extras] +argon2 = ["argon2-cffi (>=16.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-ajax-selects" +version = "2.0.0" +description = "Edit ForeignKey, ManyToManyField and CharField in Django Admin using jQuery UI AutoComplete." +optional = false +python-versions = "*" +files = [ + {file = "django-ajax-selects-2.0.0.tar.gz", hash = "sha256:f87325b25cae6e9e53b1290356e8d0e17587de57d3d19682ab3912b6bde68cb5"}, +] + +[[package]] +name = "django-cors-middleware" +version = "1.5.0" +description = "django-cors-middleware is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS). Fork of django-cors-headers." +optional = false +python-versions = "*" +files = [ + {file = "django-cors-middleware-1.5.0.tar.gz", hash = "sha256:856dbe4d7aae65844ccc68acb49c6da7dbf7cbacaf5bcf37019f4c0c60b3be84"}, + {file = "django_cors_middleware-1.5.0-py3-none-any.whl", hash = "sha256:5bbdea85e22909d596e26f6e0dbc174d5521429fa3943ae02a2c6c48e76c88c7"}, +] + +[[package]] +name = "django-debug-toolbar" +version = "3.2" +description = "A configurable set of panels that display various debug information about the current request/response." +optional = false +python-versions = ">=3.6" +files = [ + {file = "django-debug-toolbar-3.2.tar.gz", hash = "sha256:84e2607d900dbd571df0a2acf380b47c088efb787dce9805aefeb407341961d2"}, + {file = "django_debug_toolbar-3.2-py3-none-any.whl", hash = "sha256:9e5a25d0c965f7e686f6a8ba23613ca9ca30184daa26487706d4829f5cfb697a"}, +] + +[package.dependencies] +Django = ">=2.2" +sqlparse = ">=0.2.0" + +[[package]] +name = "django-enforce-host" +version = "1.0.1" +description = "Middleware to redirect requests to a canonical hostname" +optional = false +python-versions = "*" +files = [ + {file = "django-enforce-host-1.0.1.tar.gz", hash = "sha256:40c4b4830e7fc27710c1606e8f3a91e82f8f1d5ae6c26aaec76f453b1407fc3d"}, +] + +[[package]] +name = "django-extensions" +version = "2.2.6" +description = "Extensions for Django" +optional = false +python-versions = "*" +files = [ + {file = "django-extensions-2.2.6.tar.gz", hash = "sha256:936e8e3962024d3c75ea54f4e0248002404ca7ca7fb698430e60b06b5555b4e7"}, + {file = "django_extensions-2.2.6-py2.py3-none-any.whl", hash = "sha256:4524eca892d23fa6e93b0620901983b287ff5dc806f1b978d6a98541f06b9471"}, +] + +[package.dependencies] +six = ">=1.2" + +[[package]] +name = "django-extra-fields" +version = "0.9" +description = "Additional fields for Django Rest Framework." +optional = false +python-versions = "*" +files = [ + {file = "django-extra-fields-0.9.tar.gz", hash = "sha256:a182e6316fee50726a4cc92224234c08cfdef5499f7bf47727918e3d1a10d0e5"}, +] + +[[package]] +name = "django-filter" +version = "2.4.0" +description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." +optional = false +python-versions = ">=3.5" +files = [ + {file = "django-filter-2.4.0.tar.gz", hash = "sha256:84e9d5bb93f237e451db814ed422a3a625751cbc9968b484ecc74964a8696b06"}, + {file = "django_filter-2.4.0-py3-none-any.whl", hash = "sha256:e00d32cebdb3d54273c48f4f878f898dced8d5dfaad009438fe61ebdf535ace1"}, +] + +[package.dependencies] +Django = ">=2.2" + +[[package]] +name = "django-oauth-toolkit" +version = "1.0.0" +description = "OAuth2 Provider for Django" +optional = false +python-versions = "*" +files = [ + {file = "django_oauth_toolkit-1.0.0-py2.py3-none-any.whl", hash = "sha256:23ca6ffc06167cf9a1e94f98e7662b5b4724e2b9acc607373b8811ba62adbb23"}, +] + +[package.dependencies] +django = ">=1.10" +oauthlib = ">=2.0.1" +requests = ">=2.13.0" + +[[package]] +name = "django-querycount" +version = "0.7.0" +description = "Middleware that Prints the number of DB queries to the runserver console." +optional = false +python-versions = "*" +files = [ + {file = "django-querycount-0.7.0.tar.gz", hash = "sha256:8f5123d78716ff0704f2373e746a7200b8d8417798ce4a99bf2de87e3768f9ce"}, +] + +[[package]] +name = "django-redis" +version = "4.12.1" +description = "Full featured redis cache backend for Django." +optional = false +python-versions = ">=3.5" +files = [ + {file = "django-redis-4.12.1.tar.gz", hash = "sha256:306589c7021e6468b2656edc89f62b8ba67e8d5a1c8877e2688042263daa7a63"}, + {file = "django_redis-4.12.1-py3-none-any.whl", hash = "sha256:1133b26b75baa3664164c3f44b9d5d133d1b8de45d94d79f38d1adc5b1d502e5"}, +] + +[package.dependencies] +Django = ">=2.2" +redis = ">=3.0.0" + +[[package]] +name = "django-storages" +version = "1.7.2" +description = "Support for many storage backends in Django" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "django-storages-1.7.2.tar.gz", hash = "sha256:f3b3def96493d3ccde37b864cea376472baf6e8a596504b209278801c510b807"}, + {file = "django_storages-1.7.2-py2.py3-none-any.whl", hash = "sha256:87287b7ad2e789cd603373439994e1ac6f94d9dc2e5f8173d2a87aa3ed458bd9"}, +] + +[package.dependencies] +azure-storage-blob = {version = ">=1.3.1", optional = true, markers = "extra == \"azure\""} +Django = ">=1.11" +google-cloud-storage = {version = ">=0.22.0", optional = true, markers = "extra == \"google\""} + +[package.extras] +azure = ["azure-storage-blob (>=1.3.1)"] +boto = ["boto (>=2.32.0)"] +boto3 = ["boto3 (>=1.4.4)"] +dropbox = ["dropbox (>=7.2.1)"] +google = ["google-cloud-storage (>=0.22.0)"] +libcloud = ["apache-libcloud"] +sftp = ["paramiko"] + +[[package]] +name = "django-su" +version = "0.9.0" +description = "Login as any user from the Django admin interface, then switch back when done" +optional = false +python-versions = "*" +files = [ + {file = "django-su-0.9.0.tar.gz", hash = "sha256:8f624aed76bd93febe75554b5f14230be56a741d1cf8375673232f0e161af6c5"}, + {file = "django_su-0.9.0-py2.py3-none-any.whl", hash = "sha256:e51fec387e12c30c0e81abe2ccfa0a1927be2340cea329b7667478156f50b542"}, +] + +[package.dependencies] +django = ">=1.11" + +[[package]] +name = "djangorestframework" +version = "3.9.1" +description = "Web APIs for Django, made easy." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "djangorestframework-3.9.1-py2.py3-none-any.whl", hash = "sha256:a4138613b67e3a223be6c97f53b13d759c5b90d2b433bad670b8ebf95402075f"}, + {file = "djangorestframework-3.9.1.tar.gz", hash = "sha256:79c6efbb2514bc50cf25906d7c0a5cfead714c7af667ff4bd110312cd380ae66"}, +] + +[[package]] +name = "djangorestframework-csv" +version = "2.1.0" +description = "CSV Tools for Django REST Framework" +optional = false +python-versions = "*" +files = [ + {file = "djangorestframework-csv-2.1.0.tar.gz", hash = "sha256:2f008b20a44f2d3c37835ea5b5ddfe19f54394f07b9cb267c616a917a7f7e27c"}, +] + +[package.dependencies] +djangorestframework = "*" +six = "*" +unicodecsv = "*" + +[[package]] +name = "docutils" +version = "0.15.2" +description = "Docutils -- Python Documentation Utilities" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "docutils-0.15.2-py2-none-any.whl", hash = "sha256:9e4d7ecfc600058e07ba661411a2b7de2fd0fafa17d1a7f7361cd47b1175c827"}, + {file = "docutils-0.15.2-py3-none-any.whl", hash = "sha256:6c4f696463b79f1fb8ba0c594b63840ebd41f059e92b31957c46b74a4599b6d0"}, + {file = "docutils-0.15.2.tar.gz", hash = "sha256:a2aeea129088da402665e92e0b25b04b073c04b2dce4ab65caaa38b7ce2e1a99"}, +] + +[[package]] +name = "drf-extensions" +version = "0.4.0" +description = "Extensions for Django REST Framework" +optional = false +python-versions = "*" +files = [ + {file = "drf-extensions-0.4.0.tar.gz", hash = "sha256:11223bc2e173233f4a108456df6433edebc895f65be0dcaa2a76f082fa3b91c3"}, + {file = "drf_extensions-0.4.0-py2.py3-none-any.whl", hash = "sha256:6638ced63fabfefaa18b81e288a9f459234825ca734ab8dc9942d788b6ee7af4"}, +] + +[package.dependencies] +djangorestframework = ">=3.8.1" + +[[package]] +name = "drf-writable-nested" +version = "0.5.4" +description = "Writable nested helpers for django-rest-framework's serializers" +optional = false +python-versions = "*" +files = [ + {file = "drf-writable-nested-0.5.4.tar.gz", hash = "sha256:79839ac42a7bf393dcc03fbe4a8c51966c28f423aee8e2de1b27124fd2c4f94a"}, +] + +[[package]] +name = "drf-yasg" +version = "1.11.0" +description = "Automated generation of real Swagger/OpenAPI 2.0 schemas from Django Rest Framework code." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "drf-yasg-1.11.0.tar.gz", hash = "sha256:24a02bbf56361ae0e304744f9c4aa96544270decd9d79b37d10dfd6dd04e5f22"}, + {file = "drf_yasg-1.11.0-py2.py3-none-any.whl", hash = "sha256:b07192a6697ced6da49c5b016f1805960b0a7a1682fb21e86045a6bb572ffa99"}, +] + +[package.dependencies] +coreapi = ">=2.3.3" +coreschema = ">=0.0.4" +Django = ">=1.11.7" +djangorestframework = ">=3.7.7" +flex = {version = ">=6.11.1", optional = true, markers = "extra == \"validation\""} +inflection = ">=0.3.1" +"ruamel.yaml" = ">=0.15.34" +six = ">=1.10.0" +swagger-spec-validator = {version = ">=2.1.0", optional = true, markers = "extra == \"validation\""} +uritemplate = ">=3.0.0" + +[package.extras] +validation = ["flex (>=6.11.1)", "swagger-spec-validator (>=2.1.0)"] + +[[package]] +name = "executing" +version = "2.1.0" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.8" +files = [ + {file = "executing-2.1.0-py2.py3-none-any.whl", hash = "sha256:8d63781349375b5ebccc3142f4b30350c0cd9c79f921cde38be2be4637e98eaf"}, + {file = "executing-2.1.0.tar.gz", hash = "sha256:8ea27ddd260da8150fa5a708269c4a10e76161e2496ec3e587da9e3c0fe4b9ab"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"] + +[[package]] +name = "factory-boy" +version = "2.11.1" +description = "A versatile test fixtures replacement based on thoughtbot's factory_bot for Ruby." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "factory_boy-2.11.1-py2.py3-none-any.whl", hash = "sha256:d552cb872b310ae78bd7429bf318e42e1e903b1a109e899a523293dfa762ea4f"}, + {file = "factory_boy-2.11.1.tar.gz", hash = "sha256:6f25cc4761ac109efd503f096e2ad99421b1159f01a29dbb917359dcd68e08ca"}, +] + +[package.dependencies] +Faker = ">=0.7.0" + +[[package]] +name = "faker" +version = "33.1.0" +description = "Faker is a Python package that generates fake data for you." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Faker-33.1.0-py3-none-any.whl", hash = "sha256:d30c5f0e2796b8970de68978365247657486eb0311c5abe88d0b895b68dff05d"}, + {file = "faker-33.1.0.tar.gz", hash = "sha256:1c925fc0e86a51fc46648b504078c88d0cd48da1da2595c4e712841cab43a1e4"}, +] + +[package.dependencies] +python-dateutil = ">=2.4" +typing-extensions = "*" + +[[package]] +name = "flake8" +version = "3.8.4" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +files = [ + {file = "flake8-3.8.4-py2.py3-none-any.whl", hash = "sha256:749dbbd6bfd0cf1318af27bf97a14e28e5ff548ef8e5b1566ccfb25a11e7c839"}, + {file = "flake8-3.8.4.tar.gz", hash = "sha256:aadae8761ec651813c24be05c6f7b4680857ef6afaae4651a4eccaef97ce6c3b"}, +] + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.6.0a1,<2.7.0" +pyflakes = ">=2.2.0,<2.3.0" + +[[package]] +name = "flex" +version = "6.12.0" +description = "Swagger Schema validation." +optional = false +python-versions = "*" +files = [ + {file = "flex-6.12.0-py3-none-any.whl", hash = "sha256:b12403eba2a0499fbbc9f39ea846a3a580c05620efd386e042055a26ec4c37b0"}, + {file = "flex-6.12.0.tar.gz", hash = "sha256:c7713c55efca07aef81c93db639590b5df2c5bf1f2e29c089e57d3137330109e"}, +] + +[package.dependencies] +click = ">=3.3" +jsonpointer = ">=1.7" +PyYAML = ">=3.11" +requests = ">=2.4.3" +rfc3987 = ">=1.3.4" +six = ">=1.7.3" +strict-rfc3339 = ">=0.7" +validate-email = ">=1.2" + +[[package]] +name = "google-api-core" +version = "2.23.0" +description = "Google API client core library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_api_core-2.23.0-py3-none-any.whl", hash = "sha256:c20100d4c4c41070cf365f1d8ddf5365915291b5eb11b83829fbd1c999b5122f"}, + {file = "google_api_core-2.23.0.tar.gz", hash = "sha256:2ceb087315e6af43f256704b871d99326b1f12a9d6ce99beaedec99ba26a0ace"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.dev0" +googleapis-common-protos = ">=1.56.2,<2.0.dev0" +proto-plus = ">=1.22.3,<2.0.0dev" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +requests = ">=2.18.0,<3.0.0.dev0" + +[package.extras] +async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.dev0)"] +grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] + +[[package]] +name = "google-auth" +version = "2.36.0" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_auth-2.36.0-py2.py3-none-any.whl", hash = "sha256:51a15d47028b66fd36e5c64a82d2d57480075bccc7da37cde257fc94177a61fb"}, + {file = "google_auth-2.36.0.tar.gz", hash = "sha256:545e9618f2df0bcbb7dcbc45a546485b1212624716975a1ea5ae8149ce769ab1"}, +] + +[package.dependencies] +cachetools = ">=2.0.0,<6.0" +pyasn1-modules = ">=0.2.1" +rsa = ">=3.1.4,<5" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +enterprise-cert = ["cryptography", "pyopenssl"] +pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0.dev0)"] + +[[package]] +name = "google-cloud-core" +version = "2.4.1" +description = "Google Cloud API client core library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google-cloud-core-2.4.1.tar.gz", hash = "sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073"}, + {file = "google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61"}, +] + +[package.dependencies] +google-api-core = ">=1.31.6,<2.0.dev0 || >2.3.0,<3.0.0dev" +google-auth = ">=1.25.0,<3.0dev" + +[package.extras] +grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] + +[[package]] +name = "google-cloud-storage" +version = "2.18.2" +description = "Google Cloud Storage API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_storage-2.18.2-py2.py3-none-any.whl", hash = "sha256:97a4d45c368b7d401ed48c4fdfe86e1e1cb96401c9e199e419d289e2c0370166"}, + {file = "google_cloud_storage-2.18.2.tar.gz", hash = "sha256:aaf7acd70cdad9f274d29332673fcab98708d0e1f4dceb5a5356aaef06af4d99"}, +] + +[package.dependencies] +google-api-core = ">=2.15.0,<3.0.0dev" +google-auth = ">=2.26.1,<3.0dev" +google-cloud-core = ">=2.3.0,<3.0dev" +google-crc32c = ">=1.0,<2.0dev" +google-resumable-media = ">=2.7.2" +requests = ">=2.18.0,<3.0.0dev" + +[package.extras] +protobuf = ["protobuf (<6.0.0dev)"] +tracing = ["opentelemetry-api (>=1.1.0)"] + +[[package]] +name = "google-crc32c" +version = "1.6.0" +description = "A python wrapper of the C library 'Google CRC32C'" +optional = false +python-versions = ">=3.9" +files = [ + {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5bcc90b34df28a4b38653c36bb5ada35671ad105c99cfe915fb5bed7ad6924aa"}, + {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d9e9913f7bd69e093b81da4535ce27af842e7bf371cde42d1ae9e9bd382dc0e9"}, + {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a184243544811e4a50d345838a883733461e67578959ac59964e43cca2c791e7"}, + {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:236c87a46cdf06384f614e9092b82c05f81bd34b80248021f729396a78e55d7e"}, + {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebab974b1687509e5c973b5c4b8b146683e101e102e17a86bd196ecaa4d099fc"}, + {file = "google_crc32c-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:50cf2a96da226dcbff8671233ecf37bf6e95de98b2a2ebadbfdf455e6d05df42"}, + {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f7a1fc29803712f80879b0806cb83ab24ce62fc8daf0569f2204a0cfd7f68ed4"}, + {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:40b05ab32a5067525670880eb5d169529089a26fe35dce8891127aeddc1950e8"}, + {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e4b426c3702f3cd23b933436487eb34e01e00327fac20c9aebb68ccf34117d"}, + {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51c4f54dd8c6dfeb58d1df5e4f7f97df8abf17a36626a217f169893d1d7f3e9f"}, + {file = "google_crc32c-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:bb8b3c75bd157010459b15222c3fd30577042a7060e29d42dabce449c087f2b3"}, + {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ed767bf4ba90104c1216b68111613f0d5926fb3780660ea1198fc469af410e9d"}, + {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:62f6d4a29fea082ac4a3c9be5e415218255cf11684ac6ef5488eea0c9132689b"}, + {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c87d98c7c4a69066fd31701c4e10d178a648c2cac3452e62c6b24dc51f9fcc00"}, + {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd5e7d2445d1a958c266bfa5d04c39932dc54093fa391736dbfdb0f1929c1fb3"}, + {file = "google_crc32c-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:7aec8e88a3583515f9e0957fe4f5f6d8d4997e36d0f61624e70469771584c760"}, + {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e2806553238cd076f0a55bddab37a532b53580e699ed8e5606d0de1f856b5205"}, + {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:bb0966e1c50d0ef5bc743312cc730b533491d60585a9a08f897274e57c3f70e0"}, + {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:386122eeaaa76951a8196310432c5b0ef3b53590ef4c317ec7588ec554fec5d2"}, + {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2952396dc604544ea7476b33fe87faedc24d666fb0c2d5ac971a2b9576ab871"}, + {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35834855408429cecf495cac67ccbab802de269e948e27478b1e47dfb6465e57"}, + {file = "google_crc32c-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:d8797406499f28b5ef791f339594b0b5fdedf54e203b5066675c406ba69d705c"}, + {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48abd62ca76a2cbe034542ed1b6aee851b6f28aaca4e6551b5599b6f3ef175cc"}, + {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18e311c64008f1f1379158158bb3f0c8d72635b9eb4f9545f8cf990c5668e59d"}, + {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05e2d8c9a2f853ff116db9706b4a27350587f341eda835f46db3c0a8c8ce2f24"}, + {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ca8145b060679ec9176e6de4f89b07363d6805bd4760631ef254905503598d"}, + {file = "google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc"}, +] + +[package.extras] +testing = ["pytest"] + +[[package]] +name = "google-resumable-media" +version = "2.7.2" +description = "Utilities for Google Media Downloads and Resumable Uploads" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa"}, + {file = "google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0"}, +] + +[package.dependencies] +google-crc32c = ">=1.0,<2.0dev" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0dev)", "google-auth (>=1.22.0,<2.0dev)"] +requests = ["requests (>=2.18.0,<3.0.0dev)"] + +[[package]] +name = "googleapis-common-protos" +version = "1.66.0" +description = "Common protobufs used in Google APIs" +optional = false +python-versions = ">=3.7" +files = [ + {file = "googleapis_common_protos-1.66.0-py2.py3-none-any.whl", hash = "sha256:d7abcd75fabb2e0ec9f74466401f6c119a0b498e27370e9be4c94cb7e382b8ed"}, + {file = "googleapis_common_protos-1.66.0.tar.gz", hash = "sha256:c3e7b33d15fdca5374cc0a7346dd92ffa847425cc4ea941d970f13680052ec8c"}, +] + +[package.dependencies] +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] + +[[package]] +name = "greenlet" +version = "3.1.1" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.7" +files = [ + {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6"}, + {file = "greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80"}, + {file = "greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395"}, + {file = "greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39"}, + {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, + {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, + {file = "greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c"}, + {file = "greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de"}, + {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa"}, + {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af"}, + {file = "greenlet-3.1.1-cp37-cp37m-win32.whl", hash = "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798"}, + {file = "greenlet-3.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef"}, + {file = "greenlet-3.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1"}, + {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd"}, + {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7"}, + {file = "greenlet-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef"}, + {file = "greenlet-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d"}, + {file = "greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e"}, + {file = "greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c"}, + {file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"}, + {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil"] + +[[package]] +name = "gunicorn" +version = "22.0.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.7" +files = [ + {file = "gunicorn-22.0.0-py3-none-any.whl", hash = "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9"}, + {file = "gunicorn-22.0.0.tar.gz", hash = "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +eventlet = ["eventlet (>=0.24.1,!=0.36.0)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +testing = ["coverage", "eventlet", "gevent", "pytest", "pytest-cov"] +tornado = ["tornado (>=0.2)"] + +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + +[[package]] +name = "httptools" +version = "0.1.2" +description = "A collection of framework independent HTTP protocol utils." +optional = false +python-versions = "*" +files = [ + {file = "httptools-0.1.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:1e35aa179b67086cc600a984924a88589b90793c9c1b260152ca4908786e09df"}, + {file = "httptools-0.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c4111a0a8a00eff1e495d43ea5230aaf64968a48ddba8ea2d5f982efae827404"}, + {file = "httptools-0.1.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:dce59ee45dd6ee6c434346a5ac527c44014326f560866b4b2f414a692ee1aca8"}, + {file = "httptools-0.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f759717ca1b2ef498c67ba4169c2b33eecf943a89f5329abcff8b89d153eb500"}, + {file = "httptools-0.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:08b79e09114e6ab5c3dbf560bba2cb2257ea38cdaeaf99b7cb80d8f92622fcd9"}, + {file = "httptools-0.1.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:8fcca4b7efe353b13a24017211334c57d055a6e132c7adffed13a10d28efca57"}, + {file = "httptools-0.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:aebdf0bd7bf7c90ae6b3be458692bf6e9e5b610b501f9f74c7979015a51db4c4"}, + {file = "httptools-0.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:fbf7ecd31c39728f251b1c095fd27c84e4d21f60a1d079a0333472ff3ae59d34"}, + {file = "httptools-0.1.2-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:c1c63d860749841024951b0a78e4dec6f543d23751ef061d6ab60064c7b8b524"}, + {file = "httptools-0.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fb7199b8fb0c50a22e77260bb59017e0c075fa80cb03bb2c8692de76e7bb7fe7"}, + {file = "httptools-0.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bda99a5723e7eab355ce57435c70853fc137a65aebf2f1cd4d15d96e2956da7b"}, + {file = "httptools-0.1.2-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:851026bd63ec0af7e7592890d97d15c92b62d9e17094353f19a52c8e2b33710a"}, + {file = "httptools-0.1.2-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:31629e1f1b89959f8c0927bad12184dc07977dcf71e24f4772934aa490aa199b"}, + {file = "httptools-0.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:9abd788465aa46a0f288bd3a99e53edd184177d6379e2098fd6097bb359ad9d6"}, + {file = "httptools-0.1.2.tar.gz", hash = "sha256:07659649fe6b3948b6490825f89abe5eb1cec79ebfaaa0b4bf30f3f33f3c2ba8"}, +] + +[package.extras] +test = ["Cython (==0.29.22)"] + +[[package]] +name = "hyperlink" +version = "21.0.0" +description = "A featureful, immutable, and correct URL for Python." +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "hyperlink-21.0.0-py2.py3-none-any.whl", hash = "sha256:e6b14c37ecb73e89c77d78cdb4c2cc8f3fb59a885c5b3f819ff4ed80f25af1b4"}, + {file = "hyperlink-21.0.0.tar.gz", hash = "sha256:427af957daa58bc909471c6c40f74c5450fa123dd093fc53efd2e91d2705a56b"}, +] + +[package.dependencies] +idna = ">=2.5" + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "importlib-resources" +version = "6.4.5" +description = "Read resources from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, + {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, +] + +[package.dependencies] +zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] +type = ["pytest-mypy"] + +[[package]] +name = "incremental" +version = "24.7.2" +description = "A small library that versions your Python projects." +optional = false +python-versions = ">=3.8" +files = [ + {file = "incremental-24.7.2-py3-none-any.whl", hash = "sha256:8cb2c3431530bec48ad70513931a760f446ad6c25e8333ca5d95e24b0ed7b8fe"}, + {file = "incremental-24.7.2.tar.gz", hash = "sha256:fb4f1d47ee60efe87d4f6f0ebb5f70b9760db2b2574c59c8e8912be4ebd464c9"}, +] + +[package.dependencies] +setuptools = ">=61.0" +tomli = {version = "*", markers = "python_version < \"3.11\""} + +[package.extras] +scripts = ["click (>=6.0)"] + +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" +optional = false +python-versions = ">=3.5" +files = [ + {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, + {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "ipdb" +version = "0.13.0" +description = "IPython-enabled pdb" +optional = false +python-versions = ">=2.7" +files = [ + {file = "ipdb-0.13.0.tar.gz", hash = "sha256:b90f1f661028af17c5043b4ea4534bc2f303d1f23b0c762a08923c7c454d7a59"}, +] + +[package.dependencies] +ipython = {version = ">=5.1.0", markers = "python_version >= \"3.4\""} +setuptools = "*" + +[[package]] +name = "ipython" +version = "8.0.1" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.8" +files = [ + {file = "ipython-8.0.1-py3-none-any.whl", hash = "sha256:c503a0dd6ccac9c8c260b211f2dd4479c042b49636b097cc9a0d55fe62dff64c"}, + {file = "ipython-8.0.1.tar.gz", hash = "sha256:ab564d4521ea8ceaac26c3a2c6e5ddbca15c8848fd5a5cc325f960da88d42974"}, +] + +[package.dependencies] +appnope = {version = "*", markers = "sys_platform == \"darwin\""} +backcall = "*" +black = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +pickleshare = "*" +prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" +pygments = "*" +setuptools = ">=18.5" +stack-data = "*" +traitlets = ">=5" + +[package.extras] +all = ["Sphinx (>=1.3)", "curio", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.19)", "pandas", "pygments", "pytest", "pytest-asyncio", "qtconsole", "testpath", "trio"] +doc = ["Sphinx (>=1.3)"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pygments", "pytest", "pytest-asyncio", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.19)", "pandas", "pygments", "pytest", "testpath", "trio"] + +[[package]] +name = "itypes" +version = "1.2.0" +description = "Simple immutable types for python." +optional = false +python-versions = "*" +files = [ + {file = "itypes-1.2.0-py2.py3-none-any.whl", hash = "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6"}, + {file = "itypes-1.2.0.tar.gz", hash = "sha256:af886f129dea4a2a1e3d36595a2d139589e4dd287f5cab0b40e799ee81570ff1"}, +] + +[[package]] +name = "jedi" +version = "0.19.2" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +files = [ + {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, + {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, +] + +[package.dependencies] +parso = ">=0.8.4,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"] + +[[package]] +name = "jinja2" +version = "3.1.4" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, + {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jinxed" +version = "1.3.0" +description = "Jinxed Terminal Library" +optional = false +python-versions = "*" +files = [ + {file = "jinxed-1.3.0-py2.py3-none-any.whl", hash = "sha256:b993189f39dc2d7504d802152671535b06d380b26d78070559551cbf92df4fc5"}, + {file = "jinxed-1.3.0.tar.gz", hash = "sha256:1593124b18a41b7a3da3b078471442e51dbad3d77b4d4f2b0c26ab6f7d660dbf"}, +] + +[package.dependencies] +ansicon = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "jmespath" +version = "0.10.0" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"}, + {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"}, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +description = "Identify specific nodes in a JSON document (RFC 6901)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942"}, + {file = "jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef"}, +] + +[[package]] +name = "jsonschema" +version = "4.23.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, + {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2024.10.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +files = [ + {file = "jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf"}, + {file = "jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + +[[package]] +name = "kombu" +version = "4.6.11" +description = "Messaging library for Python." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "kombu-4.6.11-py2.py3-none-any.whl", hash = "sha256:be48cdffb54a2194d93ad6533d73f69408486483d189fe9f5990ee24255b0e0a"}, + {file = "kombu-4.6.11.tar.gz", hash = "sha256:ca1b45faac8c0b18493d02a8571792f3c40291cf2bcf1f55afed3d8f3aa7ba74"}, +] + +[package.dependencies] +amqp = ">=2.6.0,<2.7" + +[package.extras] +azureservicebus = ["azure-servicebus (>=0.21.1)"] +azurestoragequeues = ["azure-storage-queue"] +consul = ["python-consul (>=0.6.0)"] +librabbitmq = ["librabbitmq (>=1.5.2)"] +mongodb = ["pymongo (>=3.3.0)"] +msgpack = ["msgpack"] +pyro = ["pyro4"] +qpid = ["qpid-python (>=0.26)", "qpid-tools (>=0.26)"] +redis = ["redis (>=3.3.11)"] +slmq = ["softlayer-messaging (>=1.0.3)"] +sqlalchemy = ["sqlalchemy"] +sqs = ["boto3 (>=1.4.4)", "pycurl (==7.43.0.2)"] +yaml = ["PyYAML (>=3.10)"] +zookeeper = ["kazoo (>=1.3.1)"] + +[[package]] +name = "markdown" +version = "2.6.11" +description = "Python implementation of Markdown." +optional = false +python-versions = "*" +files = [ + {file = "Markdown-2.6.11-py2.py3-none-any.whl", hash = "sha256:9ba587db9daee7ec761cfc656272be6aabe2ed300fece21208e4aab2e457bc8f"}, + {file = "Markdown-2.6.11.tar.gz", hash = "sha256:a856869c7ff079ad84a3e19cd87a64998350c2b94e9e08e44270faef33400f81"}, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.8" +files = [ + {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, + {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, +] + +[package.dependencies] +traitlets = "*" + +[[package]] +name = "mccabe" +version = "0.6.1" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = "*" +files = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] + +[[package]] +name = "msgpack" +version = "1.1.0" +description = "MessagePack serializer" +optional = false +python-versions = ">=3.8" +files = [ + {file = "msgpack-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7ad442d527a7e358a469faf43fda45aaf4ac3249c8310a82f0ccff9164e5dccd"}, + {file = "msgpack-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:74bed8f63f8f14d75eec75cf3d04ad581da6b914001b474a5d3cd3372c8cc27d"}, + {file = "msgpack-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:914571a2a5b4e7606997e169f64ce53a8b1e06f2cf2c3a7273aa106236d43dd5"}, + {file = "msgpack-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c921af52214dcbb75e6bdf6a661b23c3e6417f00c603dd2070bccb5c3ef499f5"}, + {file = "msgpack-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8ce0b22b890be5d252de90d0e0d119f363012027cf256185fc3d474c44b1b9e"}, + {file = "msgpack-1.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:73322a6cc57fcee3c0c57c4463d828e9428275fb85a27aa2aa1a92fdc42afd7b"}, + {file = "msgpack-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1f3c3d21f7cf67bcf2da8e494d30a75e4cf60041d98b3f79875afb5b96f3a3f"}, + {file = "msgpack-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64fc9068d701233effd61b19efb1485587560b66fe57b3e50d29c5d78e7fef68"}, + {file = "msgpack-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:42f754515e0f683f9c79210a5d1cad631ec3d06cea5172214d2176a42e67e19b"}, + {file = "msgpack-1.1.0-cp310-cp310-win32.whl", hash = "sha256:3df7e6b05571b3814361e8464f9304c42d2196808e0119f55d0d3e62cd5ea044"}, + {file = "msgpack-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:685ec345eefc757a7c8af44a3032734a739f8c45d1b0ac45efc5d8977aa4720f"}, + {file = "msgpack-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d364a55082fb2a7416f6c63ae383fbd903adb5a6cf78c5b96cc6316dc1cedc7"}, + {file = "msgpack-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79ec007767b9b56860e0372085f8504db5d06bd6a327a335449508bbee9648fa"}, + {file = "msgpack-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6ad622bf7756d5a497d5b6836e7fc3752e2dd6f4c648e24b1803f6048596f701"}, + {file = "msgpack-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e59bca908d9ca0de3dc8684f21ebf9a690fe47b6be93236eb40b99af28b6ea6"}, + {file = "msgpack-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1da8f11a3dd397f0a32c76165cf0c4eb95b31013a94f6ecc0b280c05c91b59"}, + {file = "msgpack-1.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:452aff037287acb1d70a804ffd022b21fa2bb7c46bee884dbc864cc9024128a0"}, + {file = "msgpack-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8da4bf6d54ceed70e8861f833f83ce0814a2b72102e890cbdfe4b34764cdd66e"}, + {file = "msgpack-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:41c991beebf175faf352fb940bf2af9ad1fb77fd25f38d9142053914947cdbf6"}, + {file = "msgpack-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a52a1f3a5af7ba1c9ace055b659189f6c669cf3657095b50f9602af3a3ba0fe5"}, + {file = "msgpack-1.1.0-cp311-cp311-win32.whl", hash = "sha256:58638690ebd0a06427c5fe1a227bb6b8b9fdc2bd07701bec13c2335c82131a88"}, + {file = "msgpack-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd2906780f25c8ed5d7b323379f6138524ba793428db5d0e9d226d3fa6aa1788"}, + {file = "msgpack-1.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d46cf9e3705ea9485687aa4001a76e44748b609d260af21c4ceea7f2212a501d"}, + {file = "msgpack-1.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5dbad74103df937e1325cc4bfeaf57713be0b4f15e1c2da43ccdd836393e2ea2"}, + {file = "msgpack-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58dfc47f8b102da61e8949708b3eafc3504509a5728f8b4ddef84bd9e16ad420"}, + {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676e5be1b472909b2ee6356ff425ebedf5142427842aa06b4dfd5117d1ca8a2"}, + {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17fb65dd0bec285907f68b15734a993ad3fc94332b5bb21b0435846228de1f39"}, + {file = "msgpack-1.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a51abd48c6d8ac89e0cfd4fe177c61481aca2d5e7ba42044fd218cfd8ea9899f"}, + {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2137773500afa5494a61b1208619e3871f75f27b03bcfca7b3a7023284140247"}, + {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:398b713459fea610861c8a7b62a6fec1882759f308ae0795b5413ff6a160cf3c"}, + {file = "msgpack-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f5fd2f6bb2a7914922d935d3b8bb4a7fff3a9a91cfce6d06c13bc42bec975b"}, + {file = "msgpack-1.1.0-cp312-cp312-win32.whl", hash = "sha256:ad33e8400e4ec17ba782f7b9cf868977d867ed784a1f5f2ab46e7ba53b6e1e1b"}, + {file = "msgpack-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:115a7af8ee9e8cddc10f87636767857e7e3717b7a2e97379dc2054712693e90f"}, + {file = "msgpack-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:071603e2f0771c45ad9bc65719291c568d4edf120b44eb36324dcb02a13bfddf"}, + {file = "msgpack-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0f92a83b84e7c0749e3f12821949d79485971f087604178026085f60ce109330"}, + {file = "msgpack-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1964df7b81285d00a84da4e70cb1383f2e665e0f1f2a7027e683956d04b734"}, + {file = "msgpack-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59caf6a4ed0d164055ccff8fe31eddc0ebc07cf7326a2aaa0dbf7a4001cd823e"}, + {file = "msgpack-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0907e1a7119b337971a689153665764adc34e89175f9a34793307d9def08e6ca"}, + {file = "msgpack-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65553c9b6da8166e819a6aa90ad15288599b340f91d18f60b2061f402b9a4915"}, + {file = "msgpack-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a946a8992941fea80ed4beae6bff74ffd7ee129a90b4dd5cf9c476a30e9708d"}, + {file = "msgpack-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4b51405e36e075193bc051315dbf29168d6141ae2500ba8cd80a522964e31434"}, + {file = "msgpack-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4c01941fd2ff87c2a934ee6055bda4ed353a7846b8d4f341c428109e9fcde8c"}, + {file = "msgpack-1.1.0-cp313-cp313-win32.whl", hash = "sha256:7c9a35ce2c2573bada929e0b7b3576de647b0defbd25f5139dcdaba0ae35a4cc"}, + {file = "msgpack-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:bce7d9e614a04d0883af0b3d4d501171fbfca038f12c77fa838d9f198147a23f"}, + {file = "msgpack-1.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c40ffa9a15d74e05ba1fe2681ea33b9caffd886675412612d93ab17b58ea2fec"}, + {file = "msgpack-1.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1ba6136e650898082d9d5a5217d5906d1e138024f836ff48691784bbe1adf96"}, + {file = "msgpack-1.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0856a2b7e8dcb874be44fea031d22e5b3a19121be92a1e098f46068a11b0870"}, + {file = "msgpack-1.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:471e27a5787a2e3f974ba023f9e265a8c7cfd373632247deb225617e3100a3c7"}, + {file = "msgpack-1.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:646afc8102935a388ffc3914b336d22d1c2d6209c773f3eb5dd4d6d3b6f8c1cb"}, + {file = "msgpack-1.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:13599f8829cfbe0158f6456374e9eea9f44eee08076291771d8ae93eda56607f"}, + {file = "msgpack-1.1.0-cp38-cp38-win32.whl", hash = "sha256:8a84efb768fb968381e525eeeb3d92857e4985aacc39f3c47ffd00eb4509315b"}, + {file = "msgpack-1.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:879a7b7b0ad82481c52d3c7eb99bf6f0645dbdec5134a4bddbd16f3506947feb"}, + {file = "msgpack-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:53258eeb7a80fc46f62fd59c876957a2d0e15e6449a9e71842b6d24419d88ca1"}, + {file = "msgpack-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e7b853bbc44fb03fbdba34feb4bd414322180135e2cb5164f20ce1c9795ee48"}, + {file = "msgpack-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3e9b4936df53b970513eac1758f3882c88658a220b58dcc1e39606dccaaf01c"}, + {file = "msgpack-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46c34e99110762a76e3911fc923222472c9d681f1094096ac4102c18319e6468"}, + {file = "msgpack-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a706d1e74dd3dea05cb54580d9bd8b2880e9264856ce5068027eed09680aa74"}, + {file = "msgpack-1.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:534480ee5690ab3cbed89d4c8971a5c631b69a8c0883ecfea96c19118510c846"}, + {file = "msgpack-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8cf9e8c3a2153934a23ac160cc4cba0ec035f6867c8013cc6077a79823370346"}, + {file = "msgpack-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3180065ec2abbe13a4ad37688b61b99d7f9e012a535b930e0e683ad6bc30155b"}, + {file = "msgpack-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c5a91481a3cc573ac8c0d9aace09345d989dc4a0202b7fcb312c88c26d4e71a8"}, + {file = "msgpack-1.1.0-cp39-cp39-win32.whl", hash = "sha256:f80bc7d47f76089633763f952e67f8214cb7b3ee6bfa489b3cb6a84cfac114cd"}, + {file = "msgpack-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:4d1b7ff2d6146e16e8bd665ac726a89c74163ef8cd39fa8c1087d4e52d3a2325"}, + {file = "msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "oauthlib" +version = "3.2.2" +description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" +optional = false +python-versions = ">=3.6" +files = [ + {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, + {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, +] + +[package.extras] +rsa = ["cryptography (>=3.0.0)"] +signals = ["blinker (>=1.4.0)"] +signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] + +[[package]] +name = "oyaml" +version = "0.7" +description = "Ordered YAML: drop-in replacement for PyYAML which preserves dict ordering" +optional = false +python-versions = "*" +files = [ + {file = "oyaml-0.7-py2.py3-none-any.whl", hash = "sha256:1a81fbb1d5c3158bf6410577f11daf2b741a1b4eea2a47064e7ecd1fb2699425"}, + {file = "oyaml-0.7.tar.gz", hash = "sha256:a0359138057aba8650f81d4456c553f145773c4a172d27c606429ca45e31f8d9"}, +] + +[package.dependencies] +pyyaml = "*" + +[[package]] +name = "packaging" +version = "24.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, + {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, +] + +[[package]] +name = "parso" +version = "0.8.4" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18"}, + {file = "parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +description = "Pexpect allows easy control of interactive console applications." +optional = false +python-versions = "*" +files = [ + {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, + {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, +] + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +optional = false +python-versions = "*" +files = [ + {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, + {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, +] + +[[package]] +name = "pillow" +version = "10.3.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pillow-10.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45"}, + {file = "pillow-10.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475"}, + {file = "pillow-10.3.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf"}, + {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3"}, + {file = "pillow-10.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5"}, + {file = "pillow-10.3.0-cp310-cp310-win32.whl", hash = "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2"}, + {file = "pillow-10.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f"}, + {file = "pillow-10.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b"}, + {file = "pillow-10.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795"}, + {file = "pillow-10.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451"}, + {file = "pillow-10.3.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd"}, + {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad"}, + {file = "pillow-10.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c"}, + {file = "pillow-10.3.0-cp311-cp311-win32.whl", hash = "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09"}, + {file = "pillow-10.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d"}, + {file = "pillow-10.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f"}, + {file = "pillow-10.3.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84"}, + {file = "pillow-10.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462"}, + {file = "pillow-10.3.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a"}, + {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef"}, + {file = "pillow-10.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3"}, + {file = "pillow-10.3.0-cp312-cp312-win32.whl", hash = "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d"}, + {file = "pillow-10.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b"}, + {file = "pillow-10.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a"}, + {file = "pillow-10.3.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b"}, + {file = "pillow-10.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d"}, + {file = "pillow-10.3.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd"}, + {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d"}, + {file = "pillow-10.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3"}, + {file = "pillow-10.3.0-cp38-cp38-win32.whl", hash = "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b"}, + {file = "pillow-10.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999"}, + {file = "pillow-10.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936"}, + {file = "pillow-10.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57"}, + {file = "pillow-10.3.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8"}, + {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9"}, + {file = "pillow-10.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb"}, + {file = "pillow-10.3.0-cp39-cp39-win32.whl", hash = "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572"}, + {file = "pillow-10.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb"}, + {file = "pillow-10.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_10_10_x86_64.whl", hash = "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3"}, + {file = "pillow-10.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a"}, + {file = "pillow-10.3.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591"}, + {file = "pillow-10.3.0.tar.gz", hash = "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"] +typing = ["typing-extensions"] +xmp = ["defusedxml"] + +[[package]] +name = "platformdirs" +version = "4.3.6" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, + {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.11.2)"] + +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "prompt-toolkit" +version = "3.0.48" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e"}, + {file = "prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "proto-plus" +version = "1.25.0" +description = "Beautiful, Pythonic protocol buffers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "proto_plus-1.25.0-py3-none-any.whl", hash = "sha256:c91fc4a65074ade8e458e95ef8bac34d4008daa7cce4a12d6707066fca648961"}, + {file = "proto_plus-1.25.0.tar.gz", hash = "sha256:fbb17f57f7bd05a68b7707e745e26528b0b3c34e378db91eef93912c54982d91"}, +] + +[package.dependencies] +protobuf = ">=3.19.0,<6.0.0dev" + +[package.extras] +testing = ["google-api-core (>=1.31.5)"] + +[[package]] +name = "protobuf" +version = "5.29.0" +description = "" +optional = false +python-versions = ">=3.8" +files = [ + {file = "protobuf-5.29.0-cp310-abi3-win32.whl", hash = "sha256:ea7fb379b257911c8c020688d455e8f74efd2f734b72dc1ea4b4d7e9fd1326f2"}, + {file = "protobuf-5.29.0-cp310-abi3-win_amd64.whl", hash = "sha256:34a90cf30c908f47f40ebea7811f743d360e202b6f10d40c02529ebd84afc069"}, + {file = "protobuf-5.29.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:c931c61d0cc143a2e756b1e7f8197a508de5365efd40f83c907a9febf36e6b43"}, + {file = "protobuf-5.29.0-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:85286a47caf63b34fa92fdc1fd98b649a8895db595cfa746c5286eeae890a0b1"}, + {file = "protobuf-5.29.0-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:0d10091d6d03537c3f902279fcf11e95372bdd36a79556311da0487455791b20"}, + {file = "protobuf-5.29.0-cp38-cp38-win32.whl", hash = "sha256:0cd67a1e5c2d88930aa767f702773b2d054e29957432d7c6a18f8be02a07719a"}, + {file = "protobuf-5.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:e467f81fdd12ded9655cea3e9b83dc319d93b394ce810b556fb0f421d8613e86"}, + {file = "protobuf-5.29.0-cp39-cp39-win32.whl", hash = "sha256:17d128eebbd5d8aee80300aed7a43a48a25170af3337f6f1333d1fac2c6839ac"}, + {file = "protobuf-5.29.0-cp39-cp39-win_amd64.whl", hash = "sha256:6c3009e22717c6cc9e6594bb11ef9f15f669b19957ad4087214d69e08a213368"}, + {file = "protobuf-5.29.0-py3-none-any.whl", hash = "sha256:88c4af76a73183e21061881360240c0cdd3c39d263b4e8fb570aaf83348d608f"}, + {file = "protobuf-5.29.0.tar.gz", hash = "sha256:445a0c02483869ed8513a585d80020d012c6dc60075f96fa0563a724987b1001"}, +] + +[[package]] +name = "psycopg2-binary" +version = "2.8.6" +description = "psycopg2 - Python-PostgreSQL Database Adapter" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +files = [ + {file = "psycopg2-binary-2.8.6.tar.gz", hash = "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-win32.whl", hash = "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27m-win_amd64.whl", hash = "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c"}, + {file = "psycopg2_binary-2.8.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1"}, + {file = "psycopg2_binary-2.8.6-cp34-cp34m-win32.whl", hash = "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2"}, + {file = "psycopg2_binary-2.8.6-cp34-cp34m-win_amd64.whl", hash = "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-win32.whl", hash = "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd"}, + {file = "psycopg2_binary-2.8.6-cp35-cp35m-win_amd64.whl", hash = "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-win32.whl", hash = "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2"}, + {file = "psycopg2_binary-2.8.6-cp36-cp36m-win_amd64.whl", hash = "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-win32.whl", hash = "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67"}, + {file = "psycopg2_binary-2.8.6-cp37-cp37m-win_amd64.whl", hash = "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-win32.whl", hash = "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729"}, + {file = "psycopg2_binary-2.8.6-cp38-cp38-win_amd64.whl", hash = "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-macosx_10_9_x86_64.macosx_10_9_intel.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-win32.whl", hash = "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056"}, + {file = "psycopg2_binary-2.8.6-cp39-cp39-win_amd64.whl", hash = "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6"}, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +description = "Run a subprocess in a pseudo terminal" +optional = false +python-versions = "*" +files = [ + {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, + {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +files = [ + {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, + {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, +] + +[package.extras] +tests = ["pytest"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, + {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.1" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, + {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, +] + +[package.dependencies] +pyasn1 = ">=0.4.6,<0.7.0" + +[[package]] +name = "pycodestyle" +version = "2.6.0" +description = "Python style guide checker" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, +] + +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + +[[package]] +name = "pyflakes" +version = "2.2.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, +] + +[[package]] +name = "pygments" +version = "2.2.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = "*" +files = [ + {file = "Pygments-2.2.0-py2.py3-none-any.whl", hash = "sha256:78f3f434bcc5d6ee09020f92ba487f95ba50f1e3ef83ae96b9d5ffa1bab25c5d"}, + {file = "Pygments-2.2.0.tar.gz", hash = "sha256:dbae1046def0efb574852fab9e90209b23f556367b5a320c0bcb871c77c3e8cc"}, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +description = "JSON Web Token implementation in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, + {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, +] + +[package.extras] +crypto = ["cryptography (>=3.4.0)"] +dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] +docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] +tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] + +[[package]] +name = "pyopenssl" +version = "24.3.0" +description = "Python wrapper module around the OpenSSL library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pyOpenSSL-24.3.0-py3-none-any.whl", hash = "sha256:e474f5a473cd7f92221cc04976e48f4d11502804657a08a989fb3be5514c904a"}, + {file = "pyopenssl-24.3.0.tar.gz", hash = "sha256:49f7a019577d834746bc55c5fce6ecbcec0f2b4ec5ce1cf43a9a173b8138bb36"}, +] + +[package.dependencies] +cryptography = ">=41.0.5,<45" + +[package.extras] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"] +test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"] + +[[package]] +name = "pyrabbit2" +version = "1.0.7" +description = "A Pythonic interface to the RabbitMQ Management HTTP API" +optional = false +python-versions = "*" +files = [ + {file = "pyrabbit2-1.0.7-py3-none-any.whl", hash = "sha256:9c5ac7751781705083f893c6aec0909b2ee0ad28e373e4fdbde6231172d64504"}, + {file = "pyrabbit2-1.0.7.tar.gz", hash = "sha256:d27160cb35c096f0072df57307233d01b117a451236e136604a8e51be6f106c0"}, +] + +[package.dependencies] +requests = "*" + +[[package]] +name = "pytest" +version = "6.2.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pytest-6.2.1-py3-none-any.whl", hash = "sha256:1969f797a1a0dbd8ccf0fecc80262312729afea9c17f1d70ebf85c5e76c6f7c8"}, + {file = "pytest-6.2.1.tar.gz", hash = "sha256:66e419b1899bc27346cb2c993e12c5e5e8daba9073c1fbce33b9807abc95c306"}, +] + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=19.2.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<1.0.0a1" +py = ">=1.8.2" +toml = "*" + +[package.extras] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "pytest-django" +version = "4.1.0" +description = "A Django plugin for pytest." +optional = false +python-versions = ">=3.5" +files = [ + {file = "pytest-django-4.1.0.tar.gz", hash = "sha256:26f02c16d36fd4c8672390deebe3413678d89f30720c16efb8b2a6bf63b9041f"}, + {file = "pytest_django-4.1.0-py3-none-any.whl", hash = "sha256:10e384e6b8912ded92db64c58be8139d9ae23fb8361e5fc139d8e4f8fc601bc2"}, +] + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +testing = ["Django", "django-configurations (>=2.0)"] + +[[package]] +name = "pytest-pythonpath" +version = "0.7.3" +description = "pytest plugin for adding to the PYTHONPATH from command line or configs." +optional = false +python-versions = "*" +files = [ + {file = "pytest-pythonpath-0.7.3.tar.gz", hash = "sha256:63fc546ace7d2c845c1ee289e8f7a6362c2b6bae497d10c716e58e253e801d62"}, +] + +[package.dependencies] +pytest = ">=2.5.2" + +[[package]] +name = "python-dateutil" +version = "2.7.3" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "python-dateutil-2.7.3.tar.gz", hash = "sha256:e27001de32f627c22380a688bcc43ce83504a7bc5da472209b4c70f02829f0b8"}, + {file = "python_dateutil-2.7.3-py2.py3-none-any.whl", hash = "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.8" +files = [ + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python3-openid" +version = "3.2.0" +description = "OpenID support for modern servers and consumers." +optional = false +python-versions = "*" +files = [ + {file = "python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf"}, + {file = "python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b"}, +] + +[package.dependencies] +defusedxml = "*" + +[package.extras] +mysql = ["mysql-connector-python"] +postgresql = ["psycopg2"] + +[[package]] +name = "pytz" +version = "2024.2" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, + {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, +] + +[[package]] +name = "pyxdg" +version = "0.28" +description = "PyXDG contains implementations of freedesktop.org standards in python." +optional = false +python-versions = "*" +files = [ + {file = "pyxdg-0.28-py2.py3-none-any.whl", hash = "sha256:bdaf595999a0178ecea4052b7f4195569c1ff4d344567bccdc12dfdf02d545ab"}, + {file = "pyxdg-0.28.tar.gz", hash = "sha256:3267bb3074e934df202af2ee0868575484108581e6f3cb006af1da35395e88b4"}, +] + +[[package]] +name = "pyyaml" +version = "5.3.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = "*" +files = [ + {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, + {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, + {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, + {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, + {file = "PyYAML-5.3.1-cp39-cp39-win32.whl", hash = "sha256:ad9c67312c84def58f3c04504727ca879cb0013b2517c85a9a253f0cb6380c0a"}, + {file = "PyYAML-5.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:6034f55dab5fea9e53f436aa68fa3ace2634918e8b5994d82f3621c04ff5ed2e"}, + {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, +] + +[[package]] +name = "redis" +version = "5.2.0" +description = "Python client for Redis database and key-value store" +optional = false +python-versions = ">=3.8" +files = [ + {file = "redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897"}, + {file = "redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0"}, +] + +[package.dependencies] +async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} + +[package.extras] +hiredis = ["hiredis (>=3.0.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] + +[[package]] +name = "referencing" +version = "0.35.1" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, + {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" + +[[package]] +name = "requests" +version = "2.32.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +files = [ + {file = "requests-2.32.2-py3-none-any.whl", hash = "sha256:fc06670dd0ed212426dfeb94fc1b983d917c4f9847c863f313c9dfaaffb7c23c"}, + {file = "requests-2.32.2.tar.gz", hash = "sha256:dd951ff5ecf3e3b3aa26b40703ba77495dab41da839ae72ef3c8e5d8e2433289"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +description = "OAuthlib authentication support for Requests." +optional = false +python-versions = ">=3.4" +files = [ + {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, + {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, +] + +[package.dependencies] +oauthlib = ">=3.0.0" +requests = ">=2.0.0" + +[package.extras] +rsa = ["oauthlib[signedtoken] (>=3.0.0)"] + +[[package]] +name = "rfc3987" +version = "1.3.8" +description = "Parsing and validation of URIs (RFC 3986) and IRIs (RFC 3987)" +optional = false +python-versions = "*" +files = [ + {file = "rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53"}, + {file = "rfc3987-1.3.8.tar.gz", hash = "sha256:d3c4d257a560d544e9826b38bc81db676890c79ab9d7ac92b39c7a253d5ca733"}, +] + +[[package]] +name = "rpds-py" +version = "0.22.1" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.9" +files = [ + {file = "rpds_py-0.22.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ab27dd4edd84b13309f268ffcdfc07aef8339135ffab7b6d43f16884307a2a48"}, + {file = "rpds_py-0.22.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9d5b925156a746dc1f5f52376fdd1fbdd3f6ffe1fcd6f5e06f77ca79abb940a3"}, + {file = "rpds_py-0.22.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:201650b309c419143775c15209c620627de3c09a27c7fb58375325aec5cce260"}, + {file = "rpds_py-0.22.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:31264187fc934ff1024a4f56775f33c9252d3f4f3e27ec07d1995a26b52702c3"}, + {file = "rpds_py-0.22.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97c5ffe47ccf92d8b17e10f8a5ce28d015aa1196edc3359684cf31504eae6a14"}, + {file = "rpds_py-0.22.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9ac7280bd045f472b50306d7efeee051b69e3a2dd1b90f46bd7e86e63b1efa2"}, + {file = "rpds_py-0.22.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f941fb86195f97be7f6efe04a21b223f05dfe4d1dfb159999e2f8d101e44cc4"}, + {file = "rpds_py-0.22.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f91bfc39f7a64168e08ab831fa497ec5438c1d6c6e2f9e12848d95ad11ac8523"}, + {file = "rpds_py-0.22.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:effcae2152afe7937a28376dbabb25c770ef99ed4e16a4ffeb8e6a4f7c4f06aa"}, + {file = "rpds_py-0.22.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2177e59c033bf0d1bf7de1ced561205963583caf3242c6c700a723034bfb5f8e"}, + {file = "rpds_py-0.22.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:66f4f48a89cdd30ab3a47335df81c76e9a63799d0d84b29c0618371c66fa37b0"}, + {file = "rpds_py-0.22.1-cp310-cp310-win32.whl", hash = "sha256:b07fa9e634234e84096adfa4be3828c8f26e238679c122824b2b3d7131bec578"}, + {file = "rpds_py-0.22.1-cp310-cp310-win_amd64.whl", hash = "sha256:ca4657e9fd0b1b5376942d403d634ce188f79064f0873aa853ab05b10185ceec"}, + {file = "rpds_py-0.22.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:608c84699b2db09c6a8743845b1a3dad36fae53eaaecb241d45b13dff74405fb"}, + {file = "rpds_py-0.22.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9dae4eb9b5534e09ba6c6ab496a757e5e394b7e7b08767d25ca37e8d36491114"}, + {file = "rpds_py-0.22.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a1f000c5f6e08b298275bae00921e9fbbf2a35dae0a86db2821c058c2201a9"}, + {file = "rpds_py-0.22.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:580ccbf11f02f948add4cb641843030a89f1463d7c0740cbfc9aca91e9dc34b3"}, + {file = "rpds_py-0.22.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96559e05bdf938b2048353e10a7920b98f853cefe4482c2064a718d7d0a50bd7"}, + {file = "rpds_py-0.22.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:128cbaed7ba26116820bcb992405d6a13ea18c8fca1b8c4f59906d858e91e979"}, + {file = "rpds_py-0.22.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:734783dd7da58f76222f458346ddebdb3621686a1a2a667db5049caf0c9956b9"}, + {file = "rpds_py-0.22.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c9ce6b83597d45bec44a2690857ede62fc98223772135f8a7fa90884eb726501"}, + {file = "rpds_py-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bca4428c4a957b78ded3e6e62884ab03f029dce8fa8d34818da0f80f61332b49"}, + {file = "rpds_py-0.22.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ded65691a1d3fd7d2aa89d2c91aa51f941601bb2ce099739909034d957fef4b"}, + {file = "rpds_py-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72407065ad459db9f3d052ea8c51e02534f02533fc61e51cbab3bd94166f086c"}, + {file = "rpds_py-0.22.1-cp311-cp311-win32.whl", hash = "sha256:eb013aa01b404219f28dc973d9e6310fd4db216d7299253dd355629952e0564e"}, + {file = "rpds_py-0.22.1-cp311-cp311-win_amd64.whl", hash = "sha256:8bd9ec1db79a664f4cbb12878693b73416f4d2cb425d3e27eccc1bdfbdc826ef"}, + {file = "rpds_py-0.22.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8ec41049c90d204a6561238a9ad6c7263ebb7009d9759c98b58078d9d2fec9ba"}, + {file = "rpds_py-0.22.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:102be79c4cc47a4aeb5912401185c404cd2601c15a7163bbecff7f1bfe20b669"}, + {file = "rpds_py-0.22.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a603155db408f773637f9e3a712c6e3cbc521aaa8fa2b99f9ba6106c59a2496"}, + {file = "rpds_py-0.22.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dbff9402c2bdf00bf0df9905694b3c292a3847c725651938a72f554351a5fcb"}, + {file = "rpds_py-0.22.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:96b3759d8ab2323324e0a92b2f44834f9d88089b8d1ab6f533b61f4be3411cef"}, + {file = "rpds_py-0.22.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3029f481b31f329b1fdb4ec4b56935d82210ddd9c6f86ea5a87c06f1e97b161"}, + {file = "rpds_py-0.22.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d280b4bf09f719b89fd9aab3b71067acc0d0449b7d1eba99a2ade4939cef8296"}, + {file = "rpds_py-0.22.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c8e97e19aa7b0b0d801a159f932ce4435f1049c8c38e2bb372bb5bee559ce50"}, + {file = "rpds_py-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:50e4b5d291105f7063259fe0125b1af902fb34499444d7c5c521dd8328b00939"}, + {file = "rpds_py-0.22.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d3777c446bb1c5fcd82dc3f8776e1a146cd91e80cc1892f8634575ace438d22f"}, + {file = "rpds_py-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:447ae1104fb32197b9262f772d565d38e834cc2e9edd89350b37b88fed636e70"}, + {file = "rpds_py-0.22.1-cp312-cp312-win32.whl", hash = "sha256:55d371b9d8b0c2a68a50413a8cb01c3c3ce1ea4f768bf77b66669a9a486e101e"}, + {file = "rpds_py-0.22.1-cp312-cp312-win_amd64.whl", hash = "sha256:413a30a99d8683dace3765885920ed27ab662efbb6c98d81db76c397ad1ffd71"}, + {file = "rpds_py-0.22.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:aa2ba0176037c915d8660a4e46581d645e2c22b5373e466bc8640a794d45861a"}, + {file = "rpds_py-0.22.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4ba6c66fbc6015b2f99e7176fec41793cecb00c4cc357cad038dff85e6ac42ab"}, + {file = "rpds_py-0.22.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15fa4ca658f8ad22645d3531682b17e5580832efbfa87304c3e62214c79c1e8a"}, + {file = "rpds_py-0.22.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d7833ef6f5d6cb634f296abfd93452fb3eb44c4e9a6ae95c1021eab704c1cee2"}, + {file = "rpds_py-0.22.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c0467838c90435b80793cde486a318fc916ee57f2af54e4b10c72b20cbdcbaa9"}, + {file = "rpds_py-0.22.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d962e2e89b3a95e3597a34b8c93ced1e98958502c5b8096c9fd69deff279f561"}, + {file = "rpds_py-0.22.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ce729f1dc8a4a190c34b69f75377bddc004079b2963ab722ab91fafe040be6d"}, + {file = "rpds_py-0.22.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8080467df22feca0fc9c46567001777c6fbc2b4a2683a7137420896051874ca1"}, + {file = "rpds_py-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f9eb37d3a60b262a98ab51ee899cac039de9ca0ce68dcf1a6518a09719020b0"}, + {file = "rpds_py-0.22.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:153248f48d6f90a295a502f53ec544a3ffbd21b0bb32f5dca39c4b93a764d6a2"}, + {file = "rpds_py-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a53592cdf98cec3dfcdb24ffec8a4797e7656b65700099af43ec7df023b6de4"}, + {file = "rpds_py-0.22.1-cp313-cp313-win32.whl", hash = "sha256:e8056adcefa2dcb67e8bc91ea5eee26df66e8b297a8cd6ff0903f85c70908fa0"}, + {file = "rpds_py-0.22.1-cp313-cp313-win_amd64.whl", hash = "sha256:a451dba533be77454ebcffc85189108fc05f279100835ac76e7989edacb89156"}, + {file = "rpds_py-0.22.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:2ea23f1525d4f64286dbe0947c929d45c3ffe963b2dbed1d3844a2e4938bda42"}, + {file = "rpds_py-0.22.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3aaa22487477de9618ce3b37f99fbe81219ba96f3c2ca84f576f0ab451b83aba"}, + {file = "rpds_py-0.22.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8954b9ffe60f479a0c0ba40987db2546c735ab02a725ea7fd89342152d4d821d"}, + {file = "rpds_py-0.22.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c8502a02ae3ae67084f5a0bf5a8253b19fa7a887f824e41e016cdb0ac532a06f"}, + {file = "rpds_py-0.22.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a083221b6a4ecdef38a60c95d8d3223d99449cb4da2544e9644958dc16664eb9"}, + {file = "rpds_py-0.22.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:542eb246d5be31b5e0a9c8ddb9539416f9b31f58f75bd4ee328bff2b5c58d6fd"}, + {file = "rpds_py-0.22.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffae97d28ea4f2c613a751d087b75a97fb78311b38cc2e9a2f4587e473ace167"}, + {file = "rpds_py-0.22.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0ff8d5b13ce2357fa8b33a0a2e3775aa71df5bf7c8ba060634c9d15ab12f357"}, + {file = "rpds_py-0.22.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f057a0c546c42964836b209d8de9ea1a4f4b0432006c6343cbe633d8ca14571"}, + {file = "rpds_py-0.22.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:48ee97c7c6027fd423058675b5a39d0b5f7a1648250b671563d5c9f74ff13ff0"}, + {file = "rpds_py-0.22.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:babec324e8654a59122aaa66936a9a483faa03276db9792f51332475c2dddc4a"}, + {file = "rpds_py-0.22.1-cp313-cp313t-win32.whl", hash = "sha256:e69acdbc132c9592c8dc393af85e38e206ca847c7019a953ff625191c3a12312"}, + {file = "rpds_py-0.22.1-cp313-cp313t-win_amd64.whl", hash = "sha256:c783e4ed68200f4e03c125690d23158b1c49c4b186d458a18debc109bbdc3c2e"}, + {file = "rpds_py-0.22.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:2143c3aed85992604d758bbe67da839fb4aab3dd2e1c6dddab5b3ca7162b34a2"}, + {file = "rpds_py-0.22.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f57e2d0f8022783426121b586d7c842ea40ea832a29e28ca36c881b54c74fb28"}, + {file = "rpds_py-0.22.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c0c324879d483504b07f7b18eb1b50567c434263bbe4866ecce33056162668a"}, + {file = "rpds_py-0.22.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c40e02cc4f3e18fd39344edb10eebe04bd11cfd13119606b5771e5ea51630d3"}, + {file = "rpds_py-0.22.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f76c6f319e57007ad52e671ec741d801324760a377e3d4992c9bb8200333ebac"}, + {file = "rpds_py-0.22.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5cae9b415ea8a6a563566dbf46650222eccc5971c7daa16fbee63aef92ae543"}, + {file = "rpds_py-0.22.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b09209cdfcacf5eba9cf80367130532e6c02e695252e1f64d3cfcc2356e6e19f"}, + {file = "rpds_py-0.22.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dbe428d0ac6eacaf05402adbaf137f59ad6063848182d1ff294f95ce0f24005b"}, + {file = "rpds_py-0.22.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:626b9feb01bff049a5aec4804f0c58db12585778b4902e5376a95b01f80a7a16"}, + {file = "rpds_py-0.22.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:ec1ccc2a9f764cd632fb8ab28fdde166250df54fc8d97315a4a6948dc5367639"}, + {file = "rpds_py-0.22.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ef92b1fbe6aa2e7885eb90853cc016b1fc95439a8cc8da6d526880e9e2148695"}, + {file = "rpds_py-0.22.1-cp39-cp39-win32.whl", hash = "sha256:c88535f83f7391cf3a45af990237e3939a6fdfbedaed2571633bfdd0bceb36b0"}, + {file = "rpds_py-0.22.1-cp39-cp39-win_amd64.whl", hash = "sha256:7839b7528faa4d134c183b1f2dd1ee4dc2ca2f899f4f0cfdf00fc04c255262a7"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a0ed14a4162c2c2b21a162c9fcf90057e3e7da18cd171ab344c1e1664f75090e"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:05fdeae9010533e47715c37df83264df0122584e40d691d50cf3607c060952a3"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4659b2e4a5008715099e216050f5c6976e5a4329482664411789968b82e3f17d"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a18aedc032d6468b73ebbe4437129cb30d54fe543cde2f23671ecad76c3aea24"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149b4d875ef9b12a8f5e303e86a32a58f8ef627e57ec97a7d0e4be819069d141"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdaee3947eaaa52dae3ceb9d9f66329e13d8bae35682b1e5dd54612938693934"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36ce951800ed2acc6772fd9f42150f29d567f0423989748052fdb39d9e2b5795"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab784621d3e2a41916e21f13a483602cc989fd45fff637634b9231ba43d4383b"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c2a214bf5b79bd39a9de1c991353aaaacafda83ba1374178309e92be8e67d411"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:85060e96953647871957d41707adb8d7bff4e977042fd0deb4fc1881b98dd2fe"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:c6f3fd617db422c9d4e12cb8d84c984fe07d6d9cb0950cbf117f3bccc6268d05"}, + {file = "rpds_py-0.22.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f2d1b58a0c3a73f0361759642e80260a6d28eee6501b40fe25b82af33ef83f21"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:76eaa4c087a061a2c8a0a92536405069878a8f530c00e84a9eaf332e70f5561f"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:959ae04ed30cde606f3a0320f0a1f4167a107e685ef5209cce28c5080590bd31"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:198067aa6f3d942ff5d0d655bb1e91b59ae85279d47590682cba2834ac1b97d2"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e7e99e2af59c56c59b6c964d612511b8203480d39d1ef83edc56f2cb42a3f5d"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0545928bdf53dfdfcab284468212efefb8a6608ca3b6910c7fb2e5ed8bdc2dc0"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef7282d8a14b60dd515e47060638687710b1d518f4b5e961caad43fb3a3606f9"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe3f245c2f39a5692d9123c174bc48f6f9fe3e96407e67c6d04541a767d99e72"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efb2ad60ca8637d5f9f653f9a9a8d73964059972b6b95036be77e028bffc68a3"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:d8306f27418361b788e3fca9f47dec125457f80122e7e31ba7ff5cdba98343f8"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:4c8dc7331e8cbb1c0ea2bcb550adb1777365944ffd125c69aa1117fdef4887f5"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:776a06cb5720556a549829896a49acebb5bdd96c7bba100191a994053546975a"}, + {file = "rpds_py-0.22.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e4f91d702b9ce1388660b3d4a28aa552614a1399e93f718ed0dacd68f23b3d32"}, + {file = "rpds_py-0.22.1.tar.gz", hash = "sha256:157a023bded0618a1eea54979fe2e0f9309e9ddc818ef4b8fc3b884ff38fedd5"}, +] + +[[package]] +name = "rsa" +version = "4.9" +description = "Pure-Python RSA implementation" +optional = false +python-versions = ">=3.6,<4" +files = [ + {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, + {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, +] + +[package.dependencies] +pyasn1 = ">=0.1.3" + +[[package]] +name = "ruamel-yaml" +version = "0.18.6" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruamel.yaml-0.18.6-py3-none-any.whl", hash = "sha256:57b53ba33def16c4f3d807c0ccbc00f8a6081827e81ba2491691b76882d0c636"}, + {file = "ruamel.yaml-0.18.6.tar.gz", hash = "sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b"}, +] + +[package.dependencies] +"ruamel.yaml.clib" = {version = ">=0.2.7", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.13\""} + +[package.extras] +docs = ["mercurial (>5.7)", "ryd"] +jinja2 = ["ruamel.yaml.jinja2 (>=0.2)"] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.12" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +optional = false +python-versions = ">=3.9" +files = [ + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, + {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, + {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, + {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, + {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:bc5f1e1c28e966d61d2519f2a3d451ba989f9ea0f2307de7bc45baa526de9e45"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a0e060aace4c24dcaf71023bbd7d42674e3b230f7e7b97317baf1e953e5b519"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, + {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, + {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, +] + +[[package]] +name = "s3transfer" +version = "0.1.13" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = "*" +files = [ + {file = "s3transfer-0.1.13-py2.py3-none-any.whl", hash = "sha256:c7a9ec356982d5e9ab2d4b46391a7d6a950e2b04c472419f5fdec70cc0ada72f"}, + {file = "s3transfer-0.1.13.tar.gz", hash = "sha256:90dc18e028989c609146e241ea153250be451e05ecc0c2832565231dacdf59c1"}, +] + +[package.dependencies] +botocore = ">=1.3.0,<2.0.0" + +[[package]] +name = "selenium" +version = "3.141.0" +description = "Python bindings for Selenium" +optional = false +python-versions = "*" +files = [ + {file = "selenium-3.141.0-py2.py3-none-any.whl", hash = "sha256:2d7131d7bc5a5b99a2d9b04aaf2612c411b03b8ca1b1ee8d3de5845a9be2cb3c"}, + {file = "selenium-3.141.0.tar.gz", hash = "sha256:deaf32b60ad91a4611b98d8002757f29e6f2c2d5fcaf202e1c9ad06d6772300d"}, +] + +[package.dependencies] +urllib3 = "*" + +[[package]] +name = "service-identity" +version = "24.2.0" +description = "Service identity verification for pyOpenSSL & cryptography." +optional = false +python-versions = ">=3.8" +files = [ + {file = "service_identity-24.2.0-py3-none-any.whl", hash = "sha256:6b047fbd8a84fd0bb0d55ebce4031e400562b9196e1e0d3e0fe2b8a59f6d4a85"}, + {file = "service_identity-24.2.0.tar.gz", hash = "sha256:b8683ba13f0d39c6cd5d625d2c5f65421d6d707b013b375c355751557cbe8e09"}, +] + +[package.dependencies] +attrs = ">=19.1.0" +cryptography = "*" +pyasn1 = "*" +pyasn1-modules = "*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "idna", "mypy", "pyopenssl", "pytest", "types-pyopenssl"] +docs = ["furo", "myst-parser", "pyopenssl", "sphinx", "sphinx-notfound-page"] +idna = ["idna"] +mypy = ["idna", "mypy", "types-pyopenssl"] +tests = ["coverage[toml] (>=5.0.2)", "pytest"] + +[[package]] +name = "setuptools" +version = "75.6.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +files = [ + {file = "setuptools-75.6.0-py3-none-any.whl", hash = "sha256:ce74b49e8f7110f9bf04883b730f4765b774ef3ef28f722cce7c273d253aaf7d"}, + {file = "setuptools-75.6.0.tar.gz", hash = "sha256:8199222558df7c86216af4f84c30e9b34a61d8ba19366cc914424cdbd28252f6"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.7.0)"] +core = ["importlib_metadata (>=6)", "jaraco.collections", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (>=1.12,<1.14)", "pytest-mypy"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "social-auth-app-django" +version = "3.1.0" +description = "Python Social Authentication, Django integration." +optional = false +python-versions = "*" +files = [ + {file = "social-auth-app-django-3.1.0.tar.gz", hash = "sha256:6d0dd18c2d9e71ca545097d57b44d26f59e624a12833078e8e52f91baf849778"}, + {file = "social_auth_app_django-3.1.0-py2-none-any.whl", hash = "sha256:f151396e5b16e2eee12cd2e211004257826ece24fc4ae97a147df386c1cd7082"}, + {file = "social_auth_app_django-3.1.0-py3-none-any.whl", hash = "sha256:9237e3d7b6f6f59494c3b02e0cce6efc69c9d33ad9d1a064e3b2318bcbe89ae3"}, +] + +[package.dependencies] +six = "*" +social-auth-core = ">=1.2.0" + +[[package]] +name = "social-auth-core" +version = "2.0.0" +description = "Python social authentication made simple." +optional = false +python-versions = "*" +files = [ + {file = "social-auth-core-2.0.0.tar.gz", hash = "sha256:b8a34e7eb71c66e4d68deb007c81d4f74d4e0bc52c1c4f871a7758db0a0c268e"}, + {file = "social_auth_core-2.0.0-py2-none-any.whl", hash = "sha256:744c8b8498cf7e970151e8ea96a8d3e3aa818986ace7e1a0f39295fc193f4529"}, + {file = "social_auth_core-2.0.0-py3-none-any.whl", hash = "sha256:cec8e0a2297a23c0e1cab21bbcfaf32c8378fdb98762f47e315bbc65c3a83c89"}, +] + +[package.dependencies] +defusedxml = {version = ">=0.5.0rc1", markers = "python_version >= \"3.0\""} +oauthlib = ">=1.0.3" +PyJWT = ">=1.4.0" +python3-openid = {version = ">=3.0.10", markers = "python_version >= \"3.0\""} +requests = ">=2.9.1" +requests-oauthlib = ">=0.6.1" +six = ">=1.10.0" + +[package.extras] +all = ["cryptography (>=2.1.1)", "python-jose (>=3.0.0)", "python-saml (>=2.2.0)", "python3-saml (>=1.2.1)"] +azuread = ["cryptography (>=2.1.1)"] +openidconnect = ["python-jose (>=3.0.0)"] +saml = ["python-saml (>=2.2.0)", "python3-saml (>=1.2.1)"] + +[[package]] +name = "sqlparse" +version = "0.5.2" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.8" +files = [ + {file = "sqlparse-0.5.2-py3-none-any.whl", hash = "sha256:e99bc85c78160918c3e1d9230834ab8d80fc06c59d03f8db2618f65f65dda55e"}, + {file = "sqlparse-0.5.2.tar.gz", hash = "sha256:9e37b35e16d1cc652a2545f0997c1deb23ea28fa1f3eefe609eee3063c3b105f"}, +] + +[package.extras] +dev = ["build", "hatch"] +doc = ["sphinx"] + +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + +[[package]] +name = "strict-rfc3339" +version = "0.7" +description = "Strict, simple, lightweight RFC3339 functions" +optional = false +python-versions = "*" +files = [ + {file = "strict-rfc3339-0.7.tar.gz", hash = "sha256:5cad17bedfc3af57b399db0fed32771f18fc54bbd917e85546088607ac5e1277"}, +] + +[[package]] +name = "swagger-spec-validator" +version = "3.0.4" +description = "Validation of Swagger specifications" +optional = false +python-versions = ">=3.8" +files = [ + {file = "swagger_spec_validator-3.0.4-py2.py3-none-any.whl", hash = "sha256:1a2a4f4f7076479ae7835d892dd53952ccca9414efa172c440c775cf0ac01f48"}, + {file = "swagger_spec_validator-3.0.4.tar.gz", hash = "sha256:637ac6d865270bfcd07df24605548e6e1f1d9c39adcfd855da37fa3fdebfed4b"}, +] + +[package.dependencies] +importlib-resources = ">=1.3" +jsonschema = "*" +pyyaml = "*" +typing-extensions = "*" + +[[package]] +name = "toml" +version = "0.10.2" +description = "Python Library for Tom's Obvious, Minimal Language" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, + {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, +] + +[[package]] +name = "tomli" +version = "1.2.3" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.6" +files = [ + {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, + {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, +] + +[[package]] +name = "traitlets" +version = "5.14.3" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +files = [ + {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, + {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] + +[[package]] +name = "twisted" +version = "24.7.0" +description = "An asynchronous networking framework written in Python" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "twisted-24.7.0-py3-none-any.whl", hash = "sha256:734832ef98108136e222b5230075b1079dad8a3fc5637319615619a7725b0c81"}, + {file = "twisted-24.7.0.tar.gz", hash = "sha256:5a60147f044187a127ec7da96d170d49bcce50c6fd36f594e60f4587eff4d394"}, +] + +[package.dependencies] +attrs = ">=21.3.0" +automat = ">=0.8.0" +constantly = ">=15.1" +hyperlink = ">=17.1.1" +idna = {version = ">=2.4", optional = true, markers = "extra == \"tls\""} +incremental = ">=24.7.0" +pyopenssl = {version = ">=21.0.0", optional = true, markers = "extra == \"tls\""} +service-identity = {version = ">=18.1.0", optional = true, markers = "extra == \"tls\""} +typing-extensions = ">=4.2.0" +zope-interface = ">=5" + +[package.extras] +all-non-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] +conch = ["appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)"] +dev = ["coverage (>=7.5,<8.0)", "cython-test-exception-raiser (>=1.0.2,<2)", "hypothesis (>=6.56)", "pydoctor (>=23.9.0,<23.10.0)", "pyflakes (>=2.2,<3.0)", "pyhamcrest (>=2)", "python-subunit (>=1.4,<2.0)", "sphinx (>=6,<7)", "sphinx-rtd-theme (>=1.3,<2.0)", "towncrier (>=23.6,<24.0)", "twistedchecker (>=0.7,<1.0)"] +dev-release = ["pydoctor (>=23.9.0,<23.10.0)", "pydoctor (>=23.9.0,<23.10.0)", "sphinx (>=6,<7)", "sphinx (>=6,<7)", "sphinx-rtd-theme (>=1.3,<2.0)", "sphinx-rtd-theme (>=1.3,<2.0)", "towncrier (>=23.6,<24.0)", "towncrier (>=23.6,<24.0)"] +gtk-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pygobject", "pygobject", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] +http2 = ["h2 (>=3.0,<5.0)", "priority (>=1.1.0,<2.0)"] +macos-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyobjc-core", "pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "pyobjc-framework-cocoa", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] +mypy = ["appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "coverage (>=7.5,<8.0)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "idna (>=2.4)", "mypy (>=1.8,<2.0)", "mypy-zope (>=1.0.3,<1.1.0)", "priority (>=1.1.0,<2.0)", "pydoctor (>=23.9.0,<23.10.0)", "pyflakes (>=2.2,<3.0)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "python-subunit (>=1.4,<2.0)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "sphinx (>=6,<7)", "sphinx-rtd-theme (>=1.3,<2.0)", "towncrier (>=23.6,<24.0)", "twistedchecker (>=0.7,<1.0)", "types-pyopenssl", "types-setuptools"] +osx-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyobjc-core", "pyobjc-core", "pyobjc-framework-cfnetwork", "pyobjc-framework-cfnetwork", "pyobjc-framework-cocoa", "pyobjc-framework-cocoa", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)"] +serial = ["pyserial (>=3.0)", "pywin32 (!=226)"] +test = ["cython-test-exception-raiser (>=1.0.2,<2)", "hypothesis (>=6.56)", "pyhamcrest (>=2)"] +tls = ["idna (>=2.4)", "pyopenssl (>=21.0.0)", "service-identity (>=18.1.0)"] +windows-platform = ["appdirs (>=1.4.0)", "appdirs (>=1.4.0)", "bcrypt (>=3.1.3)", "bcrypt (>=3.1.3)", "cryptography (>=3.3)", "cryptography (>=3.3)", "cython-test-exception-raiser (>=1.0.2,<2)", "cython-test-exception-raiser (>=1.0.2,<2)", "h2 (>=3.0,<5.0)", "h2 (>=3.0,<5.0)", "hypothesis (>=6.56)", "hypothesis (>=6.56)", "idna (>=2.4)", "idna (>=2.4)", "priority (>=1.1.0,<2.0)", "priority (>=1.1.0,<2.0)", "pyhamcrest (>=2)", "pyhamcrest (>=2)", "pyopenssl (>=21.0.0)", "pyopenssl (>=21.0.0)", "pyserial (>=3.0)", "pyserial (>=3.0)", "pywin32 (!=226)", "pywin32 (!=226)", "pywin32 (!=226)", "pywin32 (!=226)", "service-identity (>=18.1.0)", "service-identity (>=18.1.0)", "twisted-iocpsupport (>=1.0.2)", "twisted-iocpsupport (>=1.0.2)"] + +[[package]] +name = "txaio" +version = "23.1.1" +description = "Compatibility API between asyncio/Twisted/Trollius" +optional = false +python-versions = ">=3.7" +files = [ + {file = "txaio-23.1.1-py2.py3-none-any.whl", hash = "sha256:aaea42f8aad50e0ecfb976130ada140797e9dcb85fad2cf72b0f37f8cefcb490"}, + {file = "txaio-23.1.1.tar.gz", hash = "sha256:f9a9216e976e5e3246dfd112ad7ad55ca915606b60b84a757ac769bd404ff704"}, +] + +[package.extras] +all = ["twisted (>=20.3.0)", "zope.interface (>=5.2.0)"] +dev = ["pep8 (>=1.6.2)", "pyenchant (>=1.6.6)", "pytest (>=2.6.4)", "pytest-cov (>=1.8.1)", "sphinx (>=1.2.3)", "sphinx-rtd-theme (>=0.1.9)", "sphinxcontrib-spelling (>=2.1.2)", "tox (>=2.1.1)", "tox-gh-actions (>=2.2.0)", "twine (>=1.6.5)", "wheel"] +twisted = ["twisted (>=20.3.0)", "zope.interface (>=5.2.0)"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "unicodecsv" +version = "0.14.1" +description = "Python2's stdlib csv module is nice, but it doesn't support unicode. This module is a drop-in replacement which *does*." +optional = false +python-versions = "*" +files = [ + {file = "unicodecsv-0.14.1.tar.gz", hash = "sha256:018c08037d48649a0412063ff4eda26eaa81eff1546dbffa51fa5293276ff7fc"}, +] + +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +optional = false +python-versions = ">=3.6" +files = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] + +[[package]] +name = "urllib3" +version = "1.24.3" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" +files = [ + {file = "urllib3-1.24.3-py2.py3-none-any.whl", hash = "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb"}, + {file = "urllib3-1.24.3.tar.gz", hash = "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4"}, +] + +[package.extras] +secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "uvicorn" +version = "0.13.3" +description = "The lightning-fast ASGI server." +optional = false +python-versions = "*" +files = [ + {file = "uvicorn-0.13.3-py3-none-any.whl", hash = "sha256:1079c50a06f6338095b4f203e7861dbff318dde5f22f3a324fc6e94c7654164c"}, + {file = "uvicorn-0.13.3.tar.gz", hash = "sha256:ef1e0bb5f7941c6fe324e06443ddac0331e1632a776175f87891c7bd02694355"}, +] + +[package.dependencies] +click = "==7.*" +colorama = {version = ">=0.4", optional = true, markers = "sys_platform == \"win32\" and extra == \"standard\""} +h11 = ">=0.8" +httptools = {version = "==0.1.*", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} +PyYAML = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} +uvloop = {version = ">=0.14.0", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +watchgod = {version = ">=0.6,<0.7", optional = true, markers = "extra == \"standard\""} +websockets = {version = "==8.*", optional = true, markers = "extra == \"standard\""} + +[package.extras] +standard = ["PyYAML (>=5.1)", "colorama (>=0.4)", "httptools (==0.1.*)", "python-dotenv (>=0.13)", "uvloop (>=0.14.0)", "watchgod (>=0.6,<0.7)", "websockets (==8.*)"] + +[[package]] +name = "uvloop" +version = "0.21.0" +description = "Fast implementation of asyncio event loop on top of libuv" +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"}, + {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"}, + {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f38b2e090258d051d68a5b14d1da7203a3c3677321cf32a95a6f4db4dd8b6f26"}, + {file = "uvloop-0.21.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c43e0f13022b998eb9b973b5e97200c8b90823454d4bc06ab33829e09fb9bb"}, + {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:10d66943def5fcb6e7b37310eb6b5639fd2ccbc38df1177262b0640c3ca68c1f"}, + {file = "uvloop-0.21.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:67dd654b8ca23aed0a8e99010b4c34aca62f4b7fce88f39d452ed7622c94845c"}, + {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c0f3fa6200b3108919f8bdabb9a7f87f20e7097ea3c543754cabc7d717d95cf8"}, + {file = "uvloop-0.21.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0878c2640cf341b269b7e128b1a5fed890adc4455513ca710d77d5e93aa6d6a0"}, + {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9fb766bb57b7388745d8bcc53a359b116b8a04c83a2288069809d2b3466c37e"}, + {file = "uvloop-0.21.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a375441696e2eda1c43c44ccb66e04d61ceeffcd76e4929e527b7fa401b90fb"}, + {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:baa0e6291d91649c6ba4ed4b2f982f9fa165b5bbd50a9e203c416a2797bab3c6"}, + {file = "uvloop-0.21.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4509360fcc4c3bd2c70d87573ad472de40c13387f5fda8cb58350a1d7475e58d"}, + {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c"}, + {file = "uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2"}, + {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d"}, + {file = "uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc"}, + {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb"}, + {file = "uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f"}, + {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281"}, + {file = "uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af"}, + {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6"}, + {file = "uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816"}, + {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc"}, + {file = "uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553"}, + {file = "uvloop-0.21.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:17df489689befc72c39a08359efac29bbee8eee5209650d4b9f34df73d22e414"}, + {file = "uvloop-0.21.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc09f0ff191e61c2d592a752423c767b4ebb2986daa9ed62908e2b1b9a9ae206"}, + {file = "uvloop-0.21.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0ce1b49560b1d2d8a2977e3ba4afb2414fb46b86a1b64056bc4ab929efdafbe"}, + {file = "uvloop-0.21.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e678ad6fe52af2c58d2ae3c73dc85524ba8abe637f134bf3564ed07f555c5e79"}, + {file = "uvloop-0.21.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:460def4412e473896ef179a1671b40c039c7012184b627898eea5072ef6f017a"}, + {file = "uvloop-0.21.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:10da8046cc4a8f12c91a1c39d1dd1585c41162a15caaef165c2174db9ef18bdc"}, + {file = "uvloop-0.21.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c097078b8031190c934ed0ebfee8cc5f9ba9642e6eb88322b9958b649750f72b"}, + {file = "uvloop-0.21.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46923b0b5ee7fc0020bef24afe7836cb068f5050ca04caf6b487c513dc1a20b2"}, + {file = "uvloop-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53e420a3afe22cdcf2a0f4846e377d16e718bc70103d7088a4f7623567ba5fb0"}, + {file = "uvloop-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb67cdbc0e483da00af0b2c3cdad4b7c61ceb1ee0f33fe00e09c81e3a6cb75"}, + {file = "uvloop-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:221f4f2a1f46032b403bf3be628011caf75428ee3cc204a22addf96f586b19fd"}, + {file = "uvloop-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d1f581393673ce119355d56da84fe1dd9d2bb8b3d13ce792524e1607139feff"}, + {file = "uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3"}, +] + +[package.extras] +dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] +docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] +test = ["aiohttp (>=3.10.5)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] + +[[package]] +name = "validate-email" +version = "1.3" +description = "Validate_email verify if an email address is valid and really exists." +optional = false +python-versions = "*" +files = [ + {file = "validate_email-1.3.tar.gz", hash = "sha256:784719dc5f780be319cdd185dc85dd93afebdb6ebb943811bc4c7c5f9c72aeaf"}, +] + +[[package]] +name = "vine" +version = "1.3.0" +description = "Promises, promises, promises." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "vine-1.3.0-py2.py3-none-any.whl", hash = "sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af"}, + {file = "vine-1.3.0.tar.gz", hash = "sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87"}, +] + +[[package]] +name = "watchdog" +version = "2.1.1" +description = "Filesystem events monitoring" +optional = false +python-versions = ">=3.6" +files = [ + {file = "watchdog-2.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f3edbe1e15e229d2ba8ff5156908adba80d1ba21a9282d9f72247403280fc799"}, + {file = "watchdog-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c1325b47463fce231d88eb74f330ab0cb4a1bab5defe12c0c80a3a4f197345b4"}, + {file = "watchdog-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5b391bac7edbdf96fb82a381d04829bbc0d1bb259c206b2b283ef8989340240f"}, + {file = "watchdog-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:22c13c19599b0dec7192f8f7d26404d5223cb36c9a450e96430483e685dccd7e"}, + {file = "watchdog-2.1.1-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:604ca364a79c27a694ab10947cd41de81bf229cff507a3156bf2c56c064971a1"}, + {file = "watchdog-2.1.1-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:dca75d12712997c713f76e6d68ff41580598c7df94cedf83f1089342e7709081"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:aa59afc87a892ed92d7d88d09f4b736f1336fc35540b403da7ee00c3be74bd07"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_armv7l.whl", hash = "sha256:a1b3f76e2a0713b406348dd5b9df2aa02bdd741a6ddf54f4c6410b395e077502"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_i686.whl", hash = "sha256:9f1b124fe2d4a1f37b7068f6289c2b1eba44859eb790bf6bd709adff224a5469"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_ppc64.whl", hash = "sha256:a9005f968220b715101d5fcdde5f5deda54f0d1873f618724f547797171f5e97"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:027c532e2fd3367d55fe235510fc304381a6cc88d0dcd619403e57ffbd83c1d2"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_s390x.whl", hash = "sha256:4d83c89ba24bd67b7a7d5752a4ef953ec40db69d4d30582bd1f27d3ecb6b61b0"}, + {file = "watchdog-2.1.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:67c645b1e500cc74d550e9aad4829309c5084dc55e8dc4e1c25d5da23e5be239"}, + {file = "watchdog-2.1.1-py3-none-win32.whl", hash = "sha256:12645d41d7307601b318c48861e776ce7a9fdcad9f74961013ec39037050582c"}, + {file = "watchdog-2.1.1-py3-none-win_amd64.whl", hash = "sha256:16078cd241a95124acd4d8d3efba2140faec9300674b12413cc08be11b825d56"}, + {file = "watchdog-2.1.1-py3-none-win_ia64.whl", hash = "sha256:20d4cabfa2ad7239995d81a0163bc0264a3e104a64f33c6f0a21ad75a0d915d9"}, + {file = "watchdog-2.1.1.tar.gz", hash = "sha256:2894440b4ea95a6ef4c5d152deedbe270cae46092682710b7028a04d6a6980f6"}, +] + +[package.extras] +watchmedo = ["PyYAML (>=3.10)", "argh (>=0.24.1)"] + +[[package]] +name = "watchgod" +version = "0.6" +description = "Simple, modern file watching and code reload in python." +optional = false +python-versions = ">=3.5" +files = [ + {file = "watchgod-0.6-py35.py36.py37-none-any.whl", hash = "sha256:59700dab7445aa8e6067a5b94f37bae90fc367554549b1ed2e9d0f4f38a90d2a"}, + {file = "watchgod-0.6.tar.gz", hash = "sha256:e9cca0ab9c63f17fc85df9fd8bd18156ff00aff04ebe5976cee473f4968c6858"}, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"}, + {file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"}, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = false +python-versions = "*" +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] + +[[package]] +name = "websockets" +version = "8.1" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.6.1" +files = [ + {file = "websockets-8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c"}, + {file = "websockets-8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170"}, + {file = "websockets-8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8"}, + {file = "websockets-8.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb"}, + {file = "websockets-8.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5"}, + {file = "websockets-8.1-cp36-cp36m-win32.whl", hash = "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a"}, + {file = "websockets-8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5"}, + {file = "websockets-8.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989"}, + {file = "websockets-8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d"}, + {file = "websockets-8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779"}, + {file = "websockets-8.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8"}, + {file = "websockets-8.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422"}, + {file = "websockets-8.1-cp37-cp37m-win32.whl", hash = "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc"}, + {file = "websockets-8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308"}, + {file = "websockets-8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092"}, + {file = "websockets-8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485"}, + {file = "websockets-8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1"}, + {file = "websockets-8.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55"}, + {file = "websockets-8.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824"}, + {file = "websockets-8.1-cp38-cp38-win32.whl", hash = "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36"}, + {file = "websockets-8.1-cp38-cp38-win_amd64.whl", hash = "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"}, + {file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"}, +] + +[[package]] +name = "whitenoise" +version = "5.2.0" +description = "Radically simplified static file serving for WSGI applications" +optional = false +python-versions = ">=3.5, <4" +files = [ + {file = "whitenoise-5.2.0-py2.py3-none-any.whl", hash = "sha256:05d00198c777028d72d8b0bbd234db605ef6d60e9410125124002518a48e515d"}, + {file = "whitenoise-5.2.0.tar.gz", hash = "sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7"}, +] + +[package.extras] +brotli = ["Brotli"] + +[[package]] +name = "zipp" +version = "3.21.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.9" +files = [ + {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, + {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +type = ["pytest-mypy"] + +[[package]] +name = "zope-interface" +version = "7.2" +description = "Interfaces for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zope.interface-7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce290e62229964715f1011c3dbeab7a4a1e4971fd6f31324c4519464473ef9f2"}, + {file = "zope.interface-7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:05b910a5afe03256b58ab2ba6288960a2892dfeef01336dc4be6f1b9ed02ab0a"}, + {file = "zope.interface-7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:550f1c6588ecc368c9ce13c44a49b8d6b6f3ca7588873c679bd8fd88a1b557b6"}, + {file = "zope.interface-7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ef9e2f865721553c6f22a9ff97da0f0216c074bd02b25cf0d3af60ea4d6931d"}, + {file = "zope.interface-7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27f926f0dcb058211a3bb3e0e501c69759613b17a553788b2caeb991bed3b61d"}, + {file = "zope.interface-7.2-cp310-cp310-win_amd64.whl", hash = "sha256:144964649eba4c5e4410bb0ee290d338e78f179cdbfd15813de1a664e7649b3b"}, + {file = "zope.interface-7.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1909f52a00c8c3dcab6c4fad5d13de2285a4b3c7be063b239b8dc15ddfb73bd2"}, + {file = "zope.interface-7.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:80ecf2451596f19fd607bb09953f426588fc1e79e93f5968ecf3367550396b22"}, + {file = "zope.interface-7.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:033b3923b63474800b04cba480b70f6e6243a62208071fc148354f3f89cc01b7"}, + {file = "zope.interface-7.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a102424e28c6b47c67923a1f337ede4a4c2bba3965b01cf707978a801fc7442c"}, + {file = "zope.interface-7.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:25e6a61dcb184453bb00eafa733169ab6d903e46f5c2ace4ad275386f9ab327a"}, + {file = "zope.interface-7.2-cp311-cp311-win_amd64.whl", hash = "sha256:3f6771d1647b1fc543d37640b45c06b34832a943c80d1db214a37c31161a93f1"}, + {file = "zope.interface-7.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:086ee2f51eaef1e4a52bd7d3111a0404081dadae87f84c0ad4ce2649d4f708b7"}, + {file = "zope.interface-7.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21328fcc9d5b80768bf051faa35ab98fb979080c18e6f84ab3f27ce703bce465"}, + {file = "zope.interface-7.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6dd02ec01f4468da0f234da9d9c8545c5412fef80bc590cc51d8dd084138a89"}, + {file = "zope.interface-7.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e7da17f53e25d1a3bde5da4601e026adc9e8071f9f6f936d0fe3fe84ace6d54"}, + {file = "zope.interface-7.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cab15ff4832580aa440dc9790b8a6128abd0b88b7ee4dd56abacbc52f212209d"}, + {file = "zope.interface-7.2-cp312-cp312-win_amd64.whl", hash = "sha256:29caad142a2355ce7cfea48725aa8bcf0067e2b5cc63fcf5cd9f97ad12d6afb5"}, + {file = "zope.interface-7.2-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:3e0350b51e88658d5ad126c6a57502b19d5f559f6cb0a628e3dc90442b53dd98"}, + {file = "zope.interface-7.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15398c000c094b8855d7d74f4fdc9e73aa02d4d0d5c775acdef98cdb1119768d"}, + {file = "zope.interface-7.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:802176a9f99bd8cc276dcd3b8512808716492f6f557c11196d42e26c01a69a4c"}, + {file = "zope.interface-7.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb23f58a446a7f09db85eda09521a498e109f137b85fb278edb2e34841055398"}, + {file = "zope.interface-7.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a71a5b541078d0ebe373a81a3b7e71432c61d12e660f1d67896ca62d9628045b"}, + {file = "zope.interface-7.2-cp313-cp313-win_amd64.whl", hash = "sha256:4893395d5dd2ba655c38ceb13014fd65667740f09fa5bb01caa1e6284e48c0cd"}, + {file = "zope.interface-7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d3a8ffec2a50d8ec470143ea3d15c0c52d73df882eef92de7537e8ce13475e8a"}, + {file = "zope.interface-7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:31d06db13a30303c08d61d5fb32154be51dfcbdb8438d2374ae27b4e069aac40"}, + {file = "zope.interface-7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e204937f67b28d2dca73ca936d3039a144a081fc47a07598d44854ea2a106239"}, + {file = "zope.interface-7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:224b7b0314f919e751f2bca17d15aad00ddbb1eadf1cb0190fa8175edb7ede62"}, + {file = "zope.interface-7.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baf95683cde5bc7d0e12d8e7588a3eb754d7c4fa714548adcd96bdf90169f021"}, + {file = "zope.interface-7.2-cp38-cp38-win_amd64.whl", hash = "sha256:7dc5016e0133c1a1ec212fc87a4f7e7e562054549a99c73c8896fa3a9e80cbc7"}, + {file = "zope.interface-7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7bd449c306ba006c65799ea7912adbbfed071089461a19091a228998b82b1fdb"}, + {file = "zope.interface-7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a19a6cc9c6ce4b1e7e3d319a473cf0ee989cbbe2b39201d7c19e214d2dfb80c7"}, + {file = "zope.interface-7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72cd1790b48c16db85d51fbbd12d20949d7339ad84fd971427cf00d990c1f137"}, + {file = "zope.interface-7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:52e446f9955195440e787596dccd1411f543743c359eeb26e9b2c02b077b0519"}, + {file = "zope.interface-7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ad9913fd858274db8dd867012ebe544ef18d218f6f7d1e3c3e6d98000f14b75"}, + {file = "zope.interface-7.2-cp39-cp39-win_amd64.whl", hash = "sha256:1090c60116b3da3bfdd0c03406e2f14a1ff53e5771aebe33fec1edc0a350175d"}, + {file = "zope.interface-7.2.tar.gz", hash = "sha256:8b49f1a3d1ee4cdaf5b32d2e738362c7f5e40ac8b46dd7d1a65e82a4872728fe"}, +] + +[package.dependencies] +setuptools = "*" + +[package.extras] +docs = ["Sphinx", "furo", "repoze.sphinx.autointerface"] +test = ["coverage[toml]", "zope.event", "zope.testing"] +testing = ["coverage[toml]", "zope.event", "zope.testing"] + +[metadata] +lock-version = "2.0" +python-versions = "3.9.20" +content-hash = "a9888830b33f3c14c881e92e942cf2428918f20a18cd820a28758c5e39ada365" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..29968523c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[tool.poetry] +name = "codabench" +version = "0.1.0" +description = "" +authors = ["Codalab"] +readme = "README.md" +package-mode = false + +[tool.poetry.dependencies] +python = "3.9.20" +django = "2.2.28" +django-oauth-toolkit = "1.0.0" +django-cors-middleware = "1.5.0" +social-auth-core = "2.0.0" +social-auth-app-django = "3.1.0" +six = "1.16.0" +django-extensions = "2.2.6" +channels = "2.4" +channels-redis = "4.0.0" +django-extra-fields = "0.9" +pillow = "10.3.0" +celery = "4.4.7" +gunicorn = "22.0.0" +urllib3 = ">=1.21.1,<1.25" +uvicorn = {version = "0.13.3", extras = ["standard"]} +pyyaml = "5.3.1" +watchdog = "2.1.1" +argh = "0.26.2" +python-dateutil = "2.7.3" +bpython = "^0.21.0" +websockets = "8.1" +aiofiles = "0.4.0" +oyaml = "0.7" +factory-boy = "2.11.1" +bleach = "3.1.4" +django-debug-toolbar = "3.2" +django-querycount = "0.7.0" +blessings = "1.7" +django-su = "0.9.0" +django-ajax-selects = "2.0.0" +dj-database-url = "0.4.2" +psycopg2-binary = "2.8.6" +django-redis = "4.12.1" +django-storages = {version = "1.7.2", extras = ["azure", "google"]} +azure-storage-blob = "2.1.0" +azure-storage-common = "2.1.0" +boto3 = "1.9.68" +whitenoise = "5.2.0" +coreapi = "2.3.3" +djangorestframework = "3.9.1" +djangorestframework-csv = "2.1.0" +drf-extensions = "0.4.0" +markdown = "2.6.11" +pygments = "2.2.0" +drf-writable-nested = "0.5.4" +django-filter = "2.4.0" +drf-yasg = {version = "1.11.0", extras = ["validation"]} +flex = "6.12.0" +pyrabbit2 = "1.0.7" +django-enforce-host = "1.0.1" +twisted = "24.7.0" +ipdb = "0.13" +flake8 = "3.8.4" +pytest = "6.2.1" +pytest-django = "4.1.0" +pytest-pythonpath = "0.7.3" +selenium = "3.141.0" +jinja2 = "3.1.4" +requests = "2.32.2" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file diff --git a/requirements.dev.txt b/requirements.dev.txt deleted file mode 100644 index ae1d22503..000000000 --- a/requirements.dev.txt +++ /dev/null @@ -1,8 +0,0 @@ --r requirements.txt - -ipdb==0.10.3 -flake8==3.8.4 -pytest==6.2.1 -pytest-django==4.1.0 -pytest-pythonpath==0.7.3 -selenium==3.141.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 291f4d483..000000000 --- a/requirements.txt +++ /dev/null @@ -1,74 +0,0 @@ -Django==2.2.17 -django-oauth-toolkit==1.0.0 -django-cors-middleware==1.5.0 -social-auth-core==2.0.0 -social-auth-app-django==3.1.0 -six==1.16.0 -django-extensions==2.2.6 -channels==2.4 -channels_redis==3.2.0 -django-extra-fields==0.9 -pillow==8.0.1 -celery==4.2.1 -gunicorn==20.0.4 -urllib3<1.25,>=1.21.1 -uvicorn[standard]==0.13.3 -#daphne==2.2.2 -pyyaml==5.3.1 -watchdog==0.8.3 -python-dateutil==2.7.3 -bpython==0.17.1 -websockets==8.1 -aiofiles==0.4.0 -oyaml==0.7 -factory_boy==2.11.1 -bleach==3.1.4 -# Heroku staging debug tools -django-debug-toolbar==3.2 -django-querycount==0.7.0 -blessings==1.7 - -# User impersonation -django-su==0.9.0 -django-ajax-selects==2.0.0 - -# Database -dj-database-url==0.4.2 -psycopg2-binary==2.8.6 -django-redis-cache==3.0.0 -django_redis==4.12.1 - -# Storage -#-e git+https://github.com/jschneier/django-storages.git#egg=django-storages[azure,google] -django-storages[azure,google]==1.7.2 -azure-storage-blob==2.1.0 -azure-storage-common==2.1.0 -boto3==1.9.68 -whitenoise==5.2.0 - -# Api -coreapi==2.3.3 -djangorestframework==3.9.1 -djangorestframework-csv==2.1.0 -drf-extensions==0.4.0 -markdown==2.6.11 -pygments==2.2.0 -drf-writable-nested==0.5.4 -django-filter==2.4.0 -drf-yasg[validation]==1.11.0 -flex==6.12.0 -#Click==7.0 - -# Greenlet/Gevent Workers -#gevent==1.4.0 -#gevent-eventemitter==2.0 -#greenlet==0.4.15 - -#urllib3<1.25,>=1.21.1 -#tornado>=4.2.0,<6.0.0 - -pyrabbit2==1.0.7 -django-enforce-host==1.0.1 - -# Fix on 6-3-21 for: TypeError: SelectorEventLoop required, instead got: -Twisted==20.3.0 diff --git a/src/apps/analytics/migrations/0001_initial.py b/src/apps/analytics/migrations/0001_initial.py new file mode 100644 index 000000000..b5aefc6c0 --- /dev/null +++ b/src/apps/analytics/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 2.2.17 on 2023-09-14 13:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('competitions', '0035_auto_20230914_1319'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AdminStorageDataPoint', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('backups_total', models.DecimalField(blank=True, decimal_places=2, max_digits=14, null=True)), + ('at_date', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='StorageUsageHistory', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('bucket_name', models.CharField(max_length=255)), + ('total_usage', models.DecimalField(blank=True, decimal_places=2, max_digits=14, null=True)), + ('competitions_usage', models.DecimalField(blank=True, decimal_places=2, max_digits=14, null=True)), + ('users_usage', models.DecimalField(blank=True, decimal_places=2, max_digits=14, null=True)), + ('admin_usage', models.DecimalField(blank=True, decimal_places=2, max_digits=14, null=True)), + ('orphaned_file_usage', models.DecimalField(blank=True, decimal_places=2, max_digits=14, null=True)), + ('at_date', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='UserStorageDataPoint', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('datasets_total', models.DecimalField(blank=True, decimal_places=2, max_digits=14, null=True)), + ('submissions_total', models.DecimalField(blank=True, decimal_places=2, max_digits=14, null=True)), + ('at_date', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='CompetitionStorageDataPoint', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('datasets_total', models.DecimalField(blank=True, decimal_places=2, max_digits=14, null=True)), + ('at_date', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('competition', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='competitions.Competition')), + ], + ), + ] diff --git a/src/apps/analytics/migrations/0002_auto_20250218_1143.py b/src/apps/analytics/migrations/0002_auto_20250218_1143.py new file mode 100644 index 000000000..32e8b0437 --- /dev/null +++ b/src/apps/analytics/migrations/0002_auto_20250218_1143.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-02-18 11:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('analytics', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='adminstoragedatapoint', + name='backups_total', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=20, null=True), + ), + ] diff --git a/src/apps/analytics/migrations/__init__.py b/src/apps/analytics/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/analytics/models.py b/src/apps/analytics/models.py new file mode 100644 index 000000000..976df8c1a --- /dev/null +++ b/src/apps/analytics/models.py @@ -0,0 +1,54 @@ +from django.db import models +from django.conf import settings + + +class StorageUsageHistory(models.Model): + bucket_name = models.CharField(max_length=255) + total_usage = models.DecimalField( + max_digits=14, decimal_places=2, null=True, blank=True + ) # in KiB up to ~ 930 TiB + competitions_usage = models.DecimalField( + max_digits=14, decimal_places=2, null=True, blank=True + ) + users_usage = models.DecimalField( + max_digits=14, decimal_places=2, null=True, blank=True + ) + admin_usage = models.DecimalField( + max_digits=14, decimal_places=2, null=True, blank=True + ) + orphaned_file_usage = models.DecimalField( + max_digits=14, decimal_places=2, null=True, blank=True + ) + at_date = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + + +class CompetitionStorageDataPoint(models.Model): + competition = models.ForeignKey( + "competitions.competition", null=True, on_delete=models.SET_NULL + ) + datasets_total = models.DecimalField( + max_digits=14, decimal_places=2, null=True, blank=True + ) + at_date = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + + +class UserStorageDataPoint(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL) + datasets_total = models.DecimalField( + max_digits=14, decimal_places=2, null=True, blank=True + ) + submissions_total = models.DecimalField( + max_digits=14, decimal_places=2, null=True, blank=True + ) + at_date = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) + + +class AdminStorageDataPoint(models.Model): + backups_total = models.DecimalField( + max_digits=20, decimal_places=2, null=True, blank=True + ) # stores bytes + at_date = models.DateTimeField() + created_at = models.DateTimeField(auto_now_add=True) diff --git a/src/apps/analytics/tasks.py b/src/apps/analytics/tasks.py new file mode 100644 index 000000000..96bf502cf --- /dev/null +++ b/src/apps/analytics/tasks.py @@ -0,0 +1,658 @@ +import os +import time +import logging +import json +from celery_config import app +from datetime import datetime, timezone, timedelta +from django.db.models import ( + Sum, + Q, + F, + Case, + Value, + When, + DecimalField, +) +from django.db.models.functions import TruncDay +from decimal import Decimal + +from competitions.models import Submission, SubmissionDetails +from datasets.models import Data +from utils.storage import BundleStorage +from analytics.models import ( + StorageUsageHistory, + CompetitionStorageDataPoint, + UserStorageDataPoint, + AdminStorageDataPoint, +) +from competitions.models import Competition +from profiles.models import User + +from utils.data import pretty_bytes + +logger = logging.getLogger() + + +@app.task(queue="site-worker", soft_time_limit=60 * 60 * 24) # 24 hours +def create_storage_analytics_snapshot(): + # Timer started ! + logger.info("Task create_storage_analytics_snapshot started") + starting_time = time.process_time() + + # Measure all files with unset size + for dataset in Data.objects.filter(Q(file_size__isnull=True) | Q(file_size__lt=0)): + try: + dataset.file_size = Decimal( + dataset.data_file.size + ) # file_size is in Bytes + except Exception: + dataset.file_size = Decimal(-1) + finally: + dataset.save() + + for submission in Submission.objects.filter( + Q(prediction_result_file_size__isnull=True) | + Q(prediction_result_file_size__lt=0) + ): + try: + submission.prediction_result_file_size = Decimal( + submission.prediction_result.size + ) # prediction_result_file_size is in Bytes + except Exception: + submission.prediction_result_file_size = Decimal(-1) + finally: + submission.save() + + for submission in Submission.objects.filter( + Q(scoring_result_file_size__isnull=True) | Q(scoring_result_file_size__lt=0) + ): + try: + submission.scoring_result_file_size = Decimal( + submission.scoring_result.size + ) # scoring_result_file_size is in Bytes + except Exception: + submission.scoring_result_file_size = Decimal(-1) + finally: + submission.save() + + for submission in Submission.objects.filter( + Q(detailed_result_file_size__isnull=True) | Q(detailed_result_file_size__lt=0) + ): + try: + submission.detailed_result_file_size = Decimal( + submission.detailed_result.size + ) # detailed_result_file_size is in Bytes + except Exception: + submission.detailed_result_file_size = Decimal(-1) + finally: + submission.save() + + for submissiondetails in SubmissionDetails.objects.filter( + Q(file_size__isnull=True) | Q(file_size__lt=0) + ): + try: + submissiondetails.file_size = Decimal( + submissiondetails.data_file.size + ) # file_size is in Bytes + except Exception: + submissiondetails.file_size = Decimal(-1) + finally: + submissiondetails.save() + + # Evaluate the storage usage per category (competition, user or admin) and per day + current_datetime = datetime.now(timezone.utc) + max_history_days = 365 # days + + # Competitions + competitions_datasets = ( + Data.objects.filter(competition_id__isnull=False) + .annotate(day=TruncDay("created_when")) + .values("day", "competition_id") + .annotate( + size=Sum( + Case( + When(file_size__gt=0, then=F("file_size")), + default=Value(0), + output_field=DecimalField(), + ) + ) + ) + ) + + last_competition_storage_datapoint = CompetitionStorageDataPoint.objects.order_by( + "-at_date" + ).first() + last_competition_storage_datapoint_date = ( + last_competition_storage_datapoint.at_date + if last_competition_storage_datapoint + else current_datetime - timedelta(days=max_history_days) + ).replace(hour=0, minute=0, second=0, microsecond=0) + competition_storage_days_count = int( + (current_datetime - last_competition_storage_datapoint_date).days + ) + competition_storage_day_range = [ + last_competition_storage_datapoint_date + timedelta(day) + for day in range(1, competition_storage_days_count + 1) + ] + + for date in competition_storage_day_range: + for competition in Competition.objects.order_by("id"): + datasets_usage = competitions_datasets.filter( + Q(competition_id=competition.id) & Q(day__lt=date) + ).aggregate(total=Sum("size"))["total"] + defaults = { + "datasets_total": datasets_usage or 0, + } + lookup_params = {"competition_id": competition.id, "at_date": date} + CompetitionStorageDataPoint.objects.update_or_create( + defaults=defaults, **lookup_params + ) + + # Users + users_datasets = ( + Data.objects.filter(created_by_id__isnull=False) + .annotate(day=TruncDay("created_when")) + .values("day", "created_by_id") + .annotate( + size=Sum( + Case( + When(file_size__gt=0, then=F("file_size")), + default=Value(0), + output_field=DecimalField(), + ) + ) + ) + ) + + users_submissions = ( + Submission.objects.filter(owner_id__isnull=False) + .annotate(day=TruncDay("created_when")) + .values("day", "owner_id") + .annotate( + size=Sum( + Case( + When( + prediction_result_file_size__gt=0, + then=F("prediction_result_file_size"), + ), + default=Value(0), + output_field=DecimalField(), + ) + Case( + When( + scoring_result_file_size__gt=0, + then=F("scoring_result_file_size"), + ), + default=Value(0), + output_field=DecimalField(), + ) + Case( + When( + detailed_result_file_size__gt=0, + then=F("detailed_result_file_size"), + ), + default=Value(0), + output_field=DecimalField(), + ) + ) + ) + ) + + users_submissions_details = ( + SubmissionDetails.objects.filter(submission__owner_id__isnull=False) + .annotate(day=TruncDay("submission__created_when")) + .values("day", "submission__owner_id") + .annotate( + size=Sum( + Case( + When(file_size__gt=0, then=F("file_size")), + default=Value(0), + output_field=DecimalField(), + ) + ) + ) + ) + + last_user_storage_datapoint = UserStorageDataPoint.objects.order_by( + "-at_date" + ).first() + last_user_storage_datapoint_date = ( + last_user_storage_datapoint.at_date + if last_user_storage_datapoint + else current_datetime - timedelta(days=max_history_days) + ).replace(hour=0, minute=0, second=0, microsecond=0) + user_storage_days_count = int( + (current_datetime - last_user_storage_datapoint_date).days + ) + user_storage_day_range = [ + last_user_storage_datapoint_date + timedelta(day) + for day in range(1, user_storage_days_count + 1) + ] + + for date in user_storage_day_range: + for user in User.objects.order_by("id"): + datasets_usage = users_datasets.filter( + Q(created_by_id=user.id) & Q(day__lt=date) + ).aggregate(total=Sum("size"))["total"] + submissions_usage = users_submissions.filter( + Q(owner_id=user.id) & Q(day__lt=date) + ).aggregate(total=Sum("size"))["total"] + submissiondetails_usage = users_submissions_details.filter( + Q(submission__owner_id=user.id) & Q(day__lt=date) + ).aggregate(total=Sum("size"))["total"] + defaults = { + "datasets_total": datasets_usage or 0, + "submissions_total": (submissions_usage or 0) + (submissiondetails_usage or 0), + } + lookup_params = {"user_id": user.id, "at_date": date} + UserStorageDataPoint.objects.update_or_create( + defaults=defaults, **lookup_params + ) + + # Admin + last_admin_storage_datapoint = AdminStorageDataPoint.objects.order_by( + "-at_date" + ).first() + last_admin_storage_datapoint_date = ( + last_admin_storage_datapoint.at_date + if last_admin_storage_datapoint + else current_datetime - timedelta(days=max_history_days) + ).replace(hour=0, minute=0, second=0, microsecond=0) + admin_storage_days_count = int( + (current_datetime - last_admin_storage_datapoint_date).days + ) + admin_storage_day_range = [ + last_admin_storage_datapoint_date + timedelta(day) + for day in range(1, admin_storage_days_count + 1) + ] + admin_storage_at_date = { + last_admin_storage_datapoint_date + timedelta(day): 0 + for day in range(1, admin_storage_days_count + 1) + } + + objects = BundleStorage.bucket.objects.filter(Prefix="backups") + for object in objects: + size = object.size + last_modified = object.last_modified + for date in admin_storage_day_range: + if last_modified < date: + admin_storage_at_date[date] += size + + for date in admin_storage_day_range: + defaults = {"backups_total": admin_storage_at_date[date]} + lookup_params = {"at_date": date} + AdminStorageDataPoint.objects.update_or_create( + defaults=defaults, **lookup_params + ) + + # Check for database <-> storage inconsistency + inconsistencies = {"database": [], "storage": []} + + # Prepare some data + last_storage_usage_history_point = ( + StorageUsageHistory.objects.filter(bucket_name=BundleStorage.bucket.name) + .order_by("-at_date") + .first() + ) + last_storage_usage_history_date = ( + last_storage_usage_history_point.at_date + if last_storage_usage_history_point + else current_datetime - timedelta(days=max_history_days) + ).replace(hour=0, minute=0, second=0, microsecond=0) + storage_usage_history_days_count = int( + (current_datetime - last_storage_usage_history_date).days + ) + storage_usage_history_days = range(1, storage_usage_history_days_count + 1) + storage_usage_history_day_range = [ + last_storage_usage_history_date + timedelta(day) + for day in range(1, storage_usage_history_days_count + 1) + ] + + # Database + nb_missing_files = 0 + + # Datasets + for dataset in Data.objects.all().order_by("id"): + if ( + not dataset.data_file or not dataset.data_file.name or not BundleStorage.exists(dataset.data_file.name) + ): + inconsistencies["database"].append( + {"model": "dataset", "field": "data_file", "id": dataset.id} + ) + nb_missing_files += 1 + + # Submissions + for submission in Submission.objects.all().order_by("id"): + if ( + not submission.prediction_result or not submission.prediction_result.name or not BundleStorage.exists(submission.prediction_result.name) + ): + inconsistencies["database"].append( + { + "model": "submission", + "field": "prediction_result", + "id": submission.id, + } + ) + nb_missing_files += 1 + if ( + not submission.scoring_result or not submission.scoring_result.name or not BundleStorage.exists(submission.scoring_result.name) + ): + inconsistencies["database"].append( + {"model": "submission", "field": "scoring_result", "id": submission.id} + ) + nb_missing_files += 1 + if ( + submission.detailed_result and submission.detailed_result.name and not BundleStorage.exists(submission.detailed_result.name) + ): + inconsistencies["database"].append( + {"model": "submission", "field": "detailed_result", "id": submission.id} + ) + nb_missing_files += 1 + + # Submission details + for submissiondetails in SubmissionDetails.objects.all().order_by("id"): + if ( + not submissiondetails.data_file or not submissiondetails.data_file.name or not BundleStorage.exists(submissiondetails.data_file.name) + ): + inconsistencies["database"].append( + { + "model": "submissiondetails", + "field": "data_file", + "id": submissiondetails.id, + } + ) + nb_missing_files += 1 + + # Storage + nb_orphaned_files = 0 + orphaned_files_total_size = 0 # In bytes + orphaned_files_size_per_date = { + last_storage_usage_history_date + timedelta(day): 0 + for day in range(1, storage_usage_history_days_count + 1) + } + + # Dataset + db_dataset_paths = Data.objects.values_list("data_file", flat=True).distinct() + storage_dataset_paths = [ + obj.key for obj in BundleStorage.bucket.objects.filter(Prefix="dataset") + ] + orphaned_dataset_files = [ + x for x in storage_dataset_paths if x not in set(db_dataset_paths) + ] + nb_orphaned_files += len(orphaned_dataset_files) + for file in orphaned_dataset_files: + size = BundleStorage.size(file) + last_modified = BundleStorage.get_modified_time(file) + inconsistencies["storage"].append({"path": file, "size": size}) + orphaned_files_total_size += size + for date in storage_usage_history_day_range: + if last_modified < date: + orphaned_files_size_per_date[date] += size + + # Detailed result + db_detailed_result_paths = Submission.objects.values_list( + "detailed_result", flat=True + ).distinct() + storage_detailed_result_paths = [ + obj.key for obj in BundleStorage.bucket.objects.filter(Prefix="detailed_result") + ] + orphaned_detailed_result_files = [ + x + for x in storage_detailed_result_paths + if x not in set(db_detailed_result_paths) + ] + nb_orphaned_files += len(orphaned_detailed_result_files) + for file in orphaned_detailed_result_files: + size = BundleStorage.size(file) + last_modified = BundleStorage.get_modified_time(file) + inconsistencies["storage"].append({"path": file, "size": size}) + orphaned_files_total_size += size + for date in storage_usage_history_day_range: + if last_modified < date: + orphaned_files_size_per_date[date] += size + + # Prediction result + db_prediction_result_paths = Submission.objects.values_list( + "prediction_result", flat=True + ).distinct() + storage_prediction_result_paths = [ + obj.key + for obj in BundleStorage.bucket.objects.filter(Prefix="prediction_result") + ] + orphaned_prediction_result_files = [ + x + for x in storage_prediction_result_paths + if x not in set(db_prediction_result_paths) + ] + nb_orphaned_files += len(orphaned_prediction_result_files) + for file in orphaned_prediction_result_files: + size = BundleStorage.size(file) + last_modified = BundleStorage.get_modified_time(file) + inconsistencies["storage"].append({"path": file, "size": size}) + orphaned_files_total_size += size + for date in storage_usage_history_day_range: + if last_modified < date: + orphaned_files_size_per_date[date] += size + + # Scoring result + db_scoring_result_paths = Submission.objects.values_list( + "scoring_result", flat=True + ).distinct() + storage_scoring_result_paths = [ + obj.key for obj in BundleStorage.bucket.objects.filter(Prefix="scoring_result") + ] + orphaned_scoring_result_files = [ + x for x in storage_scoring_result_paths if x not in set(db_scoring_result_paths) + ] + nb_orphaned_files += len(orphaned_scoring_result_files) + for file in orphaned_scoring_result_files: + size = BundleStorage.size(file) + last_modified = BundleStorage.get_modified_time(file) + inconsistencies["storage"].append({"path": file, "size": size}) + orphaned_files_total_size += size + for date in storage_usage_history_day_range: + if last_modified < date: + orphaned_files_size_per_date[date] += size + + # Submission details + db_submission_details_paths = SubmissionDetails.objects.values_list( + "data_file", flat=True + ).distinct() + storage_submission_details_paths = [ + obj.key + for obj in BundleStorage.bucket.objects.filter(Prefix="submission_details") + ] + orphaned_submission_details_files = [ + x + for x in storage_submission_details_paths + if x not in set(db_submission_details_paths) + ] + nb_orphaned_files += len(orphaned_submission_details_files) + for file in orphaned_submission_details_files: + size = BundleStorage.size(file) + last_modified = BundleStorage.get_modified_time(file) + inconsistencies["storage"].append({"path": file, "size": size}) + orphaned_files_total_size += size + for date in storage_usage_history_day_range: + if last_modified < date: + orphaned_files_size_per_date[date] += size + + # Log the results + log_file = ( + "/app/var/logs/" + + "db_storage_inconsistency_" + + current_datetime.strftime("%Y%m%d-%H%M%S") + + ".log" + ) + with open(log_file, "w") as file: + file.write("Database <---> Storage Inconsistency\n\n") + file.write(f"Bucket: {BundleStorage.bucket.name}\n") + file.write(f"Datetime: {current_datetime.isoformat()}\n\n") + file.write(f"Missing files: {nb_missing_files} files\n") + for missing_file in inconsistencies["database"]: + file.write( + f'{missing_file["model"]} of id={missing_file["id"]} is missing its {missing_file["field"]}\n' + ) + file.write( + f"\nOrphaned files: {nb_orphaned_files} files for a total of {pretty_bytes(orphaned_files_total_size)} ({orphaned_files_total_size}B)\n" + ) + for orphaned_file in inconsistencies["storage"]: + file.write( + f'{orphaned_file["path"]} {pretty_bytes(orphaned_file["size"])} ({orphaned_file["size"]}B)\n' + ) + + # Save the storage usage history points + for date in [ + last_storage_usage_history_date + timedelta(day) + for day in storage_usage_history_days + ]: + competitions_usage = ( + competitions_datasets.filter(day__lt=date).aggregate(total=Sum("size"))[ + "total" + ] or 0 + ) + users_usage = ( + ( + users_datasets.filter(day__lt=date).aggregate(total=Sum("size"))[ + "total" + ] or 0 + ) + + ( + users_submissions.filter(day__lt=date).aggregate(total=Sum("size"))[ + "total" + ] or 0 + ) + + ( + users_submissions_details.filter(day__lt=date).aggregate( + total=Sum("size") + )["total"] or 0 + ) + ) + admin_data_point = AdminStorageDataPoint.objects.filter(at_date=date).first() + admin_usage = (admin_data_point.backups_total or 0) if admin_data_point else 0 + orphaned_file_usage = Decimal(orphaned_files_size_per_date[date]) + total_usage = ( + users_usage + admin_usage + orphaned_file_usage + ) # competitions_usage is included inside users_usage + storage_usage_history_point = { + "bucket_name": BundleStorage.bucket.name, + "total_usage": total_usage, + "competitions_usage": competitions_usage, + "users_usage": users_usage, + "admin_usage": admin_usage, + "orphaned_file_usage": orphaned_file_usage, + "at_date": date, + } + StorageUsageHistory.objects.create(**storage_usage_history_point) + + # Stop the count! + elapsed_time = time.process_time() - starting_time + logger.info( + "Task create_storage_analytics_snapshot stoped. Duration = {:.3f} seconds".format( + elapsed_time + ) + ) + + +@app.task(queue="site-worker") +def update_home_page_counters(): + starting_time = time.process_time() + logger.info("Task update_home_page_counters Started") + + # Count public competitions + public_competitions = Competition.objects.filter(published=True).count() + + # Count active users + users = User.objects.filter(is_deleted=False).count() + + # Count all submissions + submissions = Submission.objects.all().count() + + # Create counters data + counters_data = { + "public_competitions": public_competitions, + "users": users, + "submissions": submissions, + "last_updated": datetime.now(timezone.utc).isoformat() + } + + # Save latest counters in the file + log_file = "/app/home_page_counters.json" + with open(log_file, "w") as f: + json.dump(counters_data, f, indent=4) + + elapsed_time = time.process_time() - starting_time + logger.info( + "Task update_home_page_counters Completed. Duration = {:.3f} seconds".format(elapsed_time) + ) + + +@app.task(queue="site-worker") +def delete_orphan_files(): + logger.info("Task delete_orphan_files started") + + # Find most recent file + most_recent_log_file = get_most_recent_storage_inconsistency_log_file(logger) + if not most_recent_log_file: + logger.warning("No storage inconsistency log file found. Nothing will be removed") + raise Exception("No storage inconsistency log file found") + + # Get the list of orphan files from the content of the most recent log file + log_folder = "/app/var/logs/" + orphan_files_path = get_files_path_from_orphan_log_file(os.path.join(log_folder, most_recent_log_file), logger) + + # Delete those files in batch (max 1000 element at once) + batch_size = 1000 + for i in range(0, len(orphan_files_path), batch_size): + batch = orphan_files_path[i:i + batch_size] + objects_formatted = [{'Key': path} for path in batch] + BundleStorage.bucket.delete_objects(Delete={'Objects': objects_formatted}) + + logger.info("Delete oprhan files finished") + + +def get_most_recent_storage_inconsistency_log_file(logger): + log_folder = "/app/var/logs/" + try: + log_files = [f for f in os.listdir(log_folder) if os.path.isfile(os.path.join(log_folder, f))] + except FileNotFoundError: + logger.info(f"Folder '{log_folder}' does not exist.") + return None + + most_recent_log_file = None + most_recent_datetime = None + datetime_format = "%Y%m%d-%H%M%S" + for file in log_files: + try: + basename = os.path.basename(file) + datetime_str = basename[len("db_storage_inconsistency_"):-len(".log")] + file_datetime = datetime.strptime(datetime_str, datetime_format) + if most_recent_datetime is None or file_datetime > most_recent_datetime: + most_recent_datetime = file_datetime + most_recent_log_file = file + except ValueError: + logger.warning(f"Filename '{file}' does not match the expected format and will be ignored.") + + return most_recent_log_file + + +def get_files_path_from_orphan_log_file(log_file_path, logger): + files_path = [] + + try: + with open(log_file_path) as log_file: + lines = log_file.readlines() + orphan_files_lines = [] + for i, line in enumerate(lines): + if "Orphaned files" in line: + orphan_files_lines = lines[i + 1:] + break + + for orphan_files_line in orphan_files_lines: + files_path.append(orphan_files_line.split(maxsplit=1)[0]) + except FileNotFoundError: + logger.error(f"File '{log_file_path}' does not exist.") + except PermissionError: + logger.error(f"Permission denied for reading the file '{log_file_path}'.") + except IOError as e: + logger.error(f"An I/O error occurred while accessing the file at {log_file_path}: {e}") + + return files_path diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index 2f580903b..c9488997e 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -9,7 +9,7 @@ from api.serializers.profiles import CollaboratorSerializer from api.serializers.submissions import SubmissionScoreSerializer from api.serializers.tasks import PhaseTaskInstanceSerializer -from competitions.models import Competition, Phase, Page, CompetitionCreationTaskStatus, CompetitionParticipant +from competitions.models import Competition, Phase, Page, CompetitionCreationTaskStatus, CompetitionParticipant, CompetitionWhiteListEmail from forums.models import Forum from leaderboards.models import Leaderboard from profiles.models import User @@ -24,6 +24,7 @@ class PhaseSerializer(WritableNestedModelSerializer): tasks = serializers.SlugRelatedField(queryset=Task.objects.all(), required=True, allow_null=False, slug_field='key', many=True) status = serializers.SerializerMethodField() + is_final_phase = serializers.SerializerMethodField() class Meta: model = Phase @@ -42,12 +43,22 @@ class Meta: 'max_submissions_per_person', 'auto_migrate_to_this_phase', 'hide_output', + 'hide_prediction_output', + 'hide_score_output', 'leaderboard', 'public_data', 'starting_kit', 'is_final_phase', ) + def get_is_final_phase(self, obj): + if len(obj.competition.phases.all()) > 1: + return obj.is_final_phase + elif len(obj.competition.phases.all()) == 1: + obj.is_final_phase = True + obj.save() + return obj.is_final_phase + def get_status(self, obj): now = datetime.now().replace(tzinfo=None) @@ -115,6 +126,8 @@ class Meta: 'max_submissions_per_person', 'auto_migrate_to_this_phase', 'hide_output', + 'hide_prediction_output', + 'hide_score_output', # no leaderboard 'public_data', 'starting_kit', @@ -170,7 +183,7 @@ def get_used_submissions_per_day(self, obj): # Get all submissions which are not failed and belongs to this user for this phase qs = obj.submissions.filter(owner=user, parent__isnull=True).exclude(status='Failed') # Count submissions made today - daily_submission_count = qs.filter(created_when__day=now().day).count() + daily_submission_count = qs.filter(created_when__date=now().date()).count() return daily_submission_count return 0 @@ -208,6 +221,12 @@ class Meta: ) +class CompetitionWhitelistSerializer(serializers.ModelSerializer): + class Meta: + model = CompetitionWhiteListEmail + fields = ['email'] + + class CompetitionSerializer(DefaultUserCreateMixin, WritableNestedModelSerializer): created_by = serializers.CharField(source='created_by.username', read_only=True) pages = PageSerializer(many=True) @@ -217,6 +236,7 @@ class CompetitionSerializer(DefaultUserCreateMixin, WritableNestedModelSerialize # We're using a Base64 image field here so we can send JSON for create/update of this object, if we wanted # include the logo as a _file_ then we would need to use FormData _not_ JSON. logo = NamedBase64ImageField(required=True, allow_null=True) + whitelist_emails = CompetitionWhitelistSerializer(many=True, required=False) class Meta: model = Competition @@ -238,6 +258,10 @@ class Meta: 'registration_auto_approve', 'queue', 'enable_detailed_results', + 'show_detailed_results_in_submission_panel', + 'show_detailed_results_in_leaderboard', + 'auto_run_submissions', + 'can_participants_make_submissions_public', 'make_programs_available', 'make_input_data_available', 'docker_image', @@ -247,6 +271,8 @@ class Meta: 'reward', 'contact_email', 'report', + 'whitelist_emails', + 'forum_enabled' ) def validate_phases(self, phases): @@ -287,6 +313,26 @@ def create(self, validated_data): return instance + def update(self, instance, validated_data): + + # Get the updated whitelist emails from the validated data + updated_whitelist_emails = validated_data.get('whitelist_emails', []) + + # Delete all existing emails + instance.whitelist_emails.all().delete() + + # Save the updated whitelist emails to the instance + for whitelist_email in updated_whitelist_emails: + CompetitionWhiteListEmail.objects.create(competition=instance, email=whitelist_email["email"]) + + # Remove the 'whitelist_emails' key from validated_data to prevent it from being processed again + validated_data.pop('whitelist_emails', None) + + # Continue with the regular update process + super(CompetitionSerializer, self).update(instance, validated_data) + + return instance + class CompetitionUpdateSerializer(CompetitionSerializer): phases = PhaseUpdateSerializer(many=True) @@ -299,14 +345,17 @@ class CompetitionCreateSerializer(CompetitionSerializer): class CompetitionDetailSerializer(serializers.ModelSerializer): created_by = serializers.CharField(source='created_by.username', read_only=True) + owner_display_name = serializers.SerializerMethodField() + logo_icon = NamedBase64ImageField(allow_null=True) pages = PageSerializer(many=True) phases = PhaseDetailSerializer(many=True) leaderboards = serializers.SerializerMethodField() collaborators = CollaboratorSerializer(many=True) participant_status = serializers.CharField(read_only=True) - participant_count = serializers.IntegerField(read_only=True) - submission_count = serializers.IntegerField(read_only=True) + participants_count = serializers.IntegerField(read_only=True) + submissions_count = serializers.IntegerField(read_only=True) queue = QueueSerializer(read_only=True) + whitelist_emails = serializers.SerializerMethodField() class Meta: model = Competition @@ -316,8 +365,10 @@ class Meta: 'published', 'secret_key', 'created_by', + 'owner_display_name', 'created_when', 'logo', + 'logo_icon', 'terms', 'pages', 'phases', @@ -326,10 +377,14 @@ class Meta: 'participant_status', 'registration_auto_approve', 'description', - 'participant_count', - 'submission_count', + 'participants_count', + 'submissions_count', 'queue', 'enable_detailed_results', + 'show_detailed_results_in_submission_panel', + 'show_detailed_results_in_leaderboard', + 'auto_run_submissions', + 'can_participants_make_submissions_public', 'make_programs_available', 'make_input_data_available', 'docker_image', @@ -340,6 +395,8 @@ class Meta: 'reward', 'contact_email', 'report', + 'whitelist_emails', + 'forum_enabled' ) def get_leaderboards(self, instance): @@ -352,10 +409,34 @@ def get_leaderboards(self, instance): raise Exception(f'KeyError on context. Context: {self.context}') return LeaderboardSerializer(qs, many=True).data + def get_whitelist_emails(self, instance): + whitelist_emails_query = instance.whitelist_emails.all() + whitelist_emails_list = [entry.email for entry in whitelist_emails_query] + return whitelist_emails_list + + def get_owner_display_name(self, obj): + # Get the user's display name if not None, otherwise return username + return obj.created_by.display_name if obj.created_by.display_name else obj.created_by.username + + def to_representation(self, instance): + """ + This is a built-in function where we can choose which fields to include in the serializer's output + """ + representation = super().to_representation(instance) + user = self.context['request'].user + + # If user is not admin/creator/collaborator then do not include secret_key and whitelist_emails + if not instance.user_has_admin_permission(user): + representation.pop('secret_key', None) + representation.pop('whitelist_emails', None) + + return representation + class CompetitionSerializerSimple(serializers.ModelSerializer): - created_by = serializers.CharField(source='created_by.username') - participant_count = serializers.IntegerField(read_only=True) + created_by = serializers.CharField(source='created_by.username', read_only=True) + owner_display_name = serializers.SerializerMethodField() + participants_count = serializers.IntegerField(read_only=True) class Meta: model = Competition @@ -363,17 +444,30 @@ class Meta: 'id', 'title', 'created_by', + 'owner_display_name', 'created_when', 'published', - 'participant_count', + 'participants_count', 'logo', + 'logo_icon', 'description', 'competition_type', 'reward', 'contact_email', 'report', + 'is_featured', + 'submissions_count', + 'participants_count' ) + def get_created_by(self, obj): + # Get the user's display name if not None, otherwise return username + return obj.created_by.display_name if obj.created_by.display_name else obj.created_by.username + + def get_owner_display_name(self, obj): + # Get the user's display name if not None, otherwise return username + return obj.created_by.display_name if obj.created_by.display_name else obj.created_by.username + PageSerializer.competition = CompetitionSerializer(many=True, source='competition') @@ -393,6 +487,7 @@ class CompetitionParticipantSerializer(serializers.ModelSerializer): username = serializers.CharField(source='user.username') is_bot = serializers.BooleanField(source='user.is_bot') email = serializers.CharField(source='user.email') + is_deleted = serializers.BooleanField(source='user.is_deleted') class Meta: model = CompetitionParticipant @@ -402,12 +497,13 @@ class Meta: 'is_bot', 'email', 'status', + 'is_deleted', ) class FrontPageCompetitionsSerializer(serializers.Serializer): popular_comps = CompetitionSerializerSimple(many=True) - featured_comps = CompetitionSerializerSimple(many=True) + recent_comps = CompetitionSerializerSimple(many=True) class PhaseResultsSubmissionSerializer(serializers.Serializer): diff --git a/src/apps/api/serializers/datasets.py b/src/apps/api/serializers/datasets.py index 25e069afc..110aba43f 100644 --- a/src/apps/api/serializers/datasets.py +++ b/src/apps/api/serializers/datasets.py @@ -3,6 +3,7 @@ from api.mixins import DefaultUserCreateMixin from datasets.models import Data, DataGroup +from competitions.models import CompetitionCreationTaskStatus, CompetitionDump class DataSerializer(DefaultUserCreateMixin, serializers.ModelSerializer): @@ -74,15 +75,24 @@ class Meta: class DataDetailSerializer(serializers.ModelSerializer): - created_by = serializers.CharField(source='created_by.username') + created_by = serializers.CharField(source='created_by.username', read_only=True) + owner_display_name = serializers.SerializerMethodField() competition = serializers.SerializerMethodField() + file_size = serializers.SerializerMethodField() value = serializers.CharField(source='key', required=False) + # These fields will be conditionally returned for type == SUBMISSION only + submission_file_size = serializers.SerializerMethodField() + prediction_result_file_size = serializers.SerializerMethodField() + scoring_result_file_size = serializers.SerializerMethodField() + detailed_result_file_size = serializers.SerializerMethodField() + class Meta: model = Data fields = ( 'id', 'created_by', + 'owner_display_name', 'created_when', 'name', 'type', @@ -93,21 +103,104 @@ class Meta: 'value', 'was_created_by_competition', 'in_use', - 'file_size', 'competition', 'file_name', + 'file_size', + 'submission_file_size', + 'prediction_result_file_size', + 'scoring_result_file_size', + 'detailed_result_file_size', ) - def get_competition(self, obj): + def to_representation(self, instance): + """ + Called automatically by DRF when serializing a model instance to JSON. + + This method customizes the serialized output of the DataDetailSerializer. + Specifically, it removes detailed file size fields when the data type is not 'SUBMISSION'. + + Example: For input_data or scoring_program types, submission-related fields + are not relevant and will be excluded from the output. + """ + # First, generate the default serialized representation using the parent method + rep = super().to_representation(instance) + + # If this data object is NOT of type 'submission', remove the following fields + if instance.type != Data.SUBMISSION: + # These fields are only meaningful for submission-type data + rep.pop('submission_file_size', None) + rep.pop('prediction_result_file_size', None) + rep.pop('scoring_result_file_size', None) + rep.pop('detailed_result_file_size', None) + + # Return the final customized representation + return rep + + def get_file_size(self, obj): + # Check if the data object is of type 'SUBMISSION' + if obj.type == Data.SUBMISSION: + # Start with the base file size of the data file itself (if present) + total_size = obj.file_size or 0 + + # Loop through all submissions that use this data + for submission in obj.submission.all(): + # Add the size of the prediction result file (if any) + total_size += submission.prediction_result_file_size or 0 + # Add the size of the scoring result file (if any) + total_size += submission.scoring_result_file_size or 0 + # Add the size of the detailed result file (if any) + total_size += submission.detailed_result_file_size or 0 - # return competition dict with id and title if available + # Return the combined size of data file and all associated result files + return total_size + + # For non-submission data types, just return the file size as-is + return obj.file_size + + def get_submission_file_size(self, obj): + return obj.file_size or 0 + + def get_prediction_result_file_size(self, obj): + return sum([s.prediction_result_file_size or 0 for s in obj.submission.all()]) + + def get_scoring_result_file_size(self, obj): + return sum([s.scoring_result_file_size or 0 for s in obj.submission.all()]) + + def get_detailed_result_file_size(self, obj): + return sum([s.detailed_result_file_size or 0 for s in obj.submission.all()]) + + def get_competition(self, obj): if obj.competition: + # Submission return { "id": obj.competition.id, "title": obj.competition.title, } + else: + competition = None + try: + # Check if it is a bundle + competition = CompetitionCreationTaskStatus.objects.get(dataset=obj).resulting_competition + except CompetitionCreationTaskStatus.DoesNotExist: + competition = None + if not competition: + # Check if it is a dump + try: + competition = CompetitionDump.objects.get(dataset=obj).competition + except CompetitionDump.DoesNotExist: + competition = None + + if competition: + return { + "id": competition.id, + "title": competition.title + } + return None + def get_owner_display_name(self, instance): + return instance.created_by.display_name if instance.created_by.display_name else instance.created_by.username + class DataGroupSerializer(serializers.ModelSerializer): class Meta: diff --git a/src/apps/api/serializers/leaderboards.py b/src/apps/api/serializers/leaderboards.py index 2ab509898..1e33863a1 100644 --- a/src/apps/api/serializers/leaderboards.py +++ b/src/apps/api/serializers/leaderboards.py @@ -2,7 +2,8 @@ from drf_writable_nested import WritableNestedModelSerializer from rest_framework import serializers -from api.serializers.submissions import SubmissionLeaderBoardSerializer +from api.serializers.submission_leaderboard import SubmissionLeaderBoardSerializer + from competitions.models import Submission, Phase from leaderboards.models import Leaderboard, Column @@ -155,6 +156,7 @@ def get_submissions(self, instance): ordering = [f'{"-" if primary_col.sorting == "desc" else ""}primary_col'] submissions = Submission.objects.filter( phase=instance, + is_soft_deleted=False, has_children=False, is_specific_task_re_run=False, leaderboard__isnull=False, ) \ diff --git a/src/apps/api/serializers/queues.py b/src/apps/api/serializers/queues.py index deea441d0..b3d84aee6 100644 --- a/src/apps/api/serializers/queues.py +++ b/src/apps/api/serializers/queues.py @@ -3,8 +3,8 @@ from api.mixins import DefaultUserCreateMixin from queues.models import Queue - from profiles.models import User +from django.db.models import Q class OrganizerSerializer(serializers.ModelSerializer): @@ -86,3 +86,30 @@ class Meta: 'created_when', 'is_owner', ) + + +class QueueListSerializer(QueueSerializer): + competitions = serializers.SerializerMethodField() + + class Meta(QueueSerializer.Meta): + fields = QueueSerializer.Meta.fields + ('competitions',) + + def get_competitions(self, obj): + # get user from the context request + user = self.context['request'].user + + # for super user return all competiitons using this queue + # for admin return competitions where this user is organizer using this queue + # for non-admin return public competitions using this queue + if user.is_superuser: + # Fetch all competitions + competitions = obj.competitions.all().values('id', 'title') + else: + # Fetch all competitions where user is organizer or competition is published + competitions = obj.competitions.filter( + Q(published=True) | + Q(created_by=user) | + Q(collaborators=user) + ).values('id', 'title') + + return competitions diff --git a/src/apps/api/serializers/submission_leaderboard.py b/src/apps/api/serializers/submission_leaderboard.py new file mode 100644 index 000000000..368f8fec4 --- /dev/null +++ b/src/apps/api/serializers/submission_leaderboard.py @@ -0,0 +1,31 @@ +# api/serializers/submission_leaderboard.py +from rest_framework import serializers +from competitions.models import Submission +from leaderboards.models import SubmissionScore +from api.serializers.profiles import SimpleOrganizationSerializer + + +class SubmissionScoreSerializer(serializers.ModelSerializer): + index = serializers.IntegerField(source='column.index', read_only=True) + column_key = serializers.CharField(source='column.key', read_only=True) + + class Meta: + model = SubmissionScore + fields = ('id', 'index', 'score', 'column_key') + + +class SubmissionLeaderBoardSerializer(serializers.ModelSerializer): + scores = SubmissionScoreSerializer(many=True) + owner = serializers.CharField(source='owner.username') + display_name = serializers.CharField(source='owner.display_name') + slug_url = serializers.CharField(source='owner.slug_url') + organization = SimpleOrganizationSerializer(allow_null=True) + created_when = serializers.DateTimeField() + + class Meta: + model = Submission + fields = ( + 'id', 'parent', 'owner', 'leaderboard_id', 'fact_sheet_answers', + 'task', 'scores', 'display_name', 'slug_url', 'organization', + 'detailed_result', 'created_when' + ) diff --git a/src/apps/api/serializers/submissions.py b/src/apps/api/serializers/submissions.py index ef3cdf39b..6def18976 100644 --- a/src/apps/api/serializers/submissions.py +++ b/src/apps/api/serializers/submissions.py @@ -8,31 +8,17 @@ from api.mixins import DefaultUserCreateMixin from api.serializers import leaderboards -from api.serializers.profiles import SimpleOrganizationSerializer +# from api.serializers.profiles import SimpleOrganizationSerializer from api.serializers.tasks import TaskSerializer +from api.serializers.submission_leaderboard import SubmissionScoreSerializer from competitions.models import Submission, SubmissionDetails, CompetitionParticipant, Phase from datasets.models import Data -from leaderboards.models import SubmissionScore from utils.data import make_url_sassy from tasks.models import Task from queues.models import Queue -class SubmissionScoreSerializer(serializers.ModelSerializer): - index = serializers.IntegerField(source='column.index', read_only=True) - column_key = serializers.CharField(source='column.key', read_only=True) - - class Meta: - model = SubmissionScore - fields = ( - 'id', - 'index', - 'score', - 'column_key', - ) - - class SubmissionSerializer(serializers.ModelSerializer): scores = SubmissionScoreSerializer(many=True) filename = serializers.SerializerMethodField(read_only=True) @@ -40,7 +26,9 @@ class SubmissionSerializer(serializers.ModelSerializer): phase_name = serializers.CharField(source='phase.name') on_leaderboard = serializers.BooleanField(read_only=True) task = TaskSerializer() - created_when = serializers.DateTimeField(format="%Y-%m-%d %H:%M") + created_when = serializers.DateTimeField() + auto_run = serializers.SerializerMethodField(read_only=True) + can_make_submissions_public = serializers.SerializerMethodField(read_only=True) class Meta: model = Submission @@ -66,6 +54,9 @@ class Meta: 'leaderboard', 'on_leaderboard', 'task', + 'auto_run', + 'can_make_submissions_public', + 'is_soft_deleted', ) read_only_fields = ( 'pk', @@ -77,35 +68,18 @@ class Meta: ) def get_filename(self, instance): - return basename(instance.data.data_file.name) - + if instance.data and instance.data.data_file: + return basename(instance.data.data_file.name) + # NOTE: if submission data is None, it means it is soft deleted + return "Deleted File" -class SubmissionLeaderBoardSerializer(serializers.ModelSerializer): - scores = SubmissionScoreSerializer(many=True) - owner = serializers.CharField(source='owner.username') - display_name = serializers.CharField(source='owner.display_name') - slug_url = serializers.CharField(source='owner.slug_url') - organization = SimpleOrganizationSerializer(allow_null=True) + def get_auto_run(self, instance): + # returns this submission's competition auto_run_submissions Flag + return instance.phase.competition.auto_run_submissions - class Meta: - model = Submission - fields = ( - 'id', - 'parent', - 'owner', - 'leaderboard_id', - 'fact_sheet_answers', - 'task', - 'scores', - 'display_name', - 'slug_url', - 'organization', - 'detailed_result' - ) - extra_kwargs = { - "scores": {"read_only": True}, - "owner": {"read_only": True}, - } + def get_can_make_submissions_public(self, instance): + # returns this submission's competition can_participants_make_submissions_public Flag + return instance.phase.competition.can_participants_make_submissions_public class SubmissionCreationSerializer(DefaultUserCreateMixin, serializers.ModelSerializer): @@ -149,9 +123,12 @@ def get_filename(self, instance): def create(self, validated_data): tasks = validated_data.pop('tasks', None) - sub = super().create(validated_data) - sub.start(tasks=tasks) + + # Check if auto_run_submissions is enabled then run the submission + # Otherwise organizer will run manually + if sub.phase.competition.auto_run_submissions: + sub.start(tasks=tasks) return sub @@ -284,7 +261,7 @@ def get_data_file(self, instance): def get_prediction_result(self, instance): if instance.prediction_result.name: - if instance.phase.hide_output and not instance.phase.competition.user_has_admin_permission(self.context['request'].user): + if (instance.phase.hide_output or instance.phase.hide_prediction_output) and not instance.phase.competition.user_has_admin_permission(self.context['request'].user): return None return make_url_sassy(instance.prediction_result.name) @@ -294,7 +271,7 @@ def get_detailed_result(self, instance): def get_scoring_result(self, instance): if instance.scoring_result.name: - if instance.phase.hide_output and not instance.phase.competition.user_has_admin_permission(self.context['request'].user): + if (instance.phase.hide_output or instance.phase.hide_score_output) and not instance.phase.competition.user_has_admin_permission(self.context['request'].user): return None return make_url_sassy(instance.scoring_result.name) diff --git a/src/apps/api/serializers/tasks.py b/src/apps/api/serializers/tasks.py index 1526a1980..7cf13d1cc 100644 --- a/src/apps/api/serializers/tasks.py +++ b/src/apps/api/serializers/tasks.py @@ -1,7 +1,5 @@ from drf_writable_nested import WritableNestedModelSerializer from rest_framework import serializers -from rest_framework.exceptions import ValidationError - from api.mixins import DefaultUserCreateMixin from api.serializers.datasets import DataDetailSerializer, DataSimpleSerializer from competitions.models import PhaseTaskInstance, Phase @@ -79,18 +77,13 @@ class Meta: 'created_by', ) - def validate_is_public(self, is_public): - validated = Task.objects.get(id=self.instance.id)._validated - if is_public and not validated: - raise ValidationError('Task must be validated before it can be published') - return is_public - def get_validated(self, instance): return hasattr(instance, 'validated') and instance.validated is not None class TaskDetailSerializer(WritableNestedModelSerializer): - created_by = serializers.CharField(source='created_by.username', read_only=True, required=False) + created_by = serializers.CharField(source='created_by.username', read_only=True) + owner_display_name = serializers.SerializerMethodField() input_data = DataSimpleSerializer(read_only=True) ingestion_program = DataSimpleSerializer(read_only=True) reference_data = DataSimpleSerializer(read_only=True) @@ -98,6 +91,7 @@ class TaskDetailSerializer(WritableNestedModelSerializer): solutions = SolutionSerializer(many=True, required=False, read_only=True) validated = serializers.SerializerMethodField(required=False) shared_with = serializers.SerializerMethodField() + competitions = serializers.SerializerMethodField() class Meta: model = Task @@ -105,11 +99,13 @@ class Meta: 'id', 'name', 'description', - 'key', 'created_by', + 'owner_display_name', 'created_when', 'is_public', 'validated', + 'shared_with', + 'competitions', # Data pieces 'input_data', @@ -117,21 +113,35 @@ class Meta: 'reference_data', 'scoring_program', 'solutions', - 'shared_with', ) - def get_validated(self, task): - return task.validated is not None + def get_validated(self, instance): + return hasattr(instance, 'validated') and instance.validated is not None + + def get_competitions(self, instance): + + # Fech competitions which hase phases with this task + # competitions = Phase.objects.filter(tasks__in=[instance.pk]).values('competition') + competitions = Competition.objects.filter(phases__tasks__in=[instance.pk]).values("id", "title").distinct() + + return competitions def get_shared_with(self, instance): - return self.context['shared_with'][instance.pk] + # Fetch the users with whom the task is shared + shared_users = instance.shared_with.all() + return [user.username for user in shared_users] + + def get_owner_display_name(self, instance): + # Get the user's display name if not None, otherwise return username + return instance.created_by.display_name if instance.created_by.display_name else instance.created_by.username class TaskListSerializer(serializers.ModelSerializer): solutions = SolutionListSerializer(many=True, required=False, read_only=True) value = serializers.CharField(source='key', required=False) - competitions = serializers.SerializerMethodField() - shared_with = serializers.SerializerMethodField() + created_by = serializers.CharField(source='created_by.username', read_only=True) + owner_display_name = serializers.SerializerMethodField() + is_used_in_competitions = serializers.SerializerMethodField() class Meta: model = Task @@ -139,27 +149,32 @@ class Meta: 'id', 'created_when', 'created_by', + 'owner_display_name', 'key', 'name', + 'description', 'solutions', 'ingestion_only_during_scoring', # Value is used for Semantic Multiselect dropdown api calls 'value', - 'competitions', - 'shared_with', + 'is_public', + 'is_used_in_competitions', ) - def get_competitions(self, instance): + def get_is_used_in_competitions(self, instance): - # Fech competitions which hase phases with this task - # competitions = Phase.objects.filter(tasks__in=[instance.pk]).values('competition') - competitions = Competition.objects.filter(phases__tasks__in=[instance.pk]).values("id", "title").distinct() + # Count competitions that are using this task + num_competitions = Competition.objects.filter(phases__tasks__in=[instance.pk]).distinct().count() - return competitions + return num_competitions > 0 def get_shared_with(self, instance): return self.context['shared_with'][instance.pk] + def get_owner_display_name(self, instance): + # Get the user's display name if not None, otherwise return username + return instance.created_by.display_name if instance.created_by.display_name else instance.created_by.username + class PhaseTaskInstanceSerializer(serializers.HyperlinkedModelSerializer): task = serializers.SlugRelatedField(queryset=Task.objects.all(), required=True, allow_null=False, slug_field='key', @@ -194,14 +209,28 @@ def get_solutions(self, instance): return SolutionSerializer(qs, many=True).data def get_public_datasets(self, instance): + input_data = instance.task.input_data reference_data = instance.task.reference_data ingestion_program = instance.task.ingestion_program scoring_program = instance.task.scoring_program + + # Some tasks may not have input data, reference data and ingestion program + # Checking all the datasets and programs and adding them to dataset_list_ids + dataset_list_ids = [] + if input_data: + dataset_list_ids.append(input_data.id) + if reference_data: + dataset_list_ids.append(reference_data.id) + if ingestion_program: + dataset_list_ids.append(ingestion_program.id) + if scoring_program: + dataset_list_ids.append(scoring_program.id) + + # Serializing the datasets try: - dataset_list_ids = [input_data.id, reference_data.id, ingestion_program.id, scoring_program.id] qs = Data.objects.filter(id__in=dataset_list_ids) return DataDetailSerializer(qs, many=True).data - except AttributeError: - print("This phase task has no datasets") - return None + except Exception: + # No datasets or programs to return + return [] diff --git a/src/apps/api/tests/test_competition_submissions_participants_counts.py b/src/apps/api/tests/test_competition_submissions_participants_counts.py new file mode 100644 index 000000000..e53fe06e9 --- /dev/null +++ b/src/apps/api/tests/test_competition_submissions_participants_counts.py @@ -0,0 +1,55 @@ +from django.test import TestCase +from competitions.models import Submission, CompetitionParticipant +from factories import UserFactory, CompetitionFactory, PhaseFactory, CompetitionParticipantFactory, SubmissionFactory + + +class CompetitionSubmissionsParticipantsCountsTests(TestCase): + def setUp(self): + + # User + self.creator = UserFactory(username='creator', password='creator') + # Competition + self.competition = CompetitionFactory(created_by=self.creator) + # Phase + self.phase = PhaseFactory(competition=self.competition) + + # Create a submission for the delete test + self.submission = SubmissionFactory(phase=self.phase, owner=self.creator, status=CompetitionParticipant.APPROVED) + self.competition.refresh_from_db() + + def test_adding_submission_updates_submission_count(self): + initial_count = self.competition.submissions_count + + self.assertEqual(initial_count, 1) # one submission created in the setup + + # Add a new submission + _ = SubmissionFactory(phase=self.phase, owner=self.creator, status=Submission.SUBMITTED) + self.competition.refresh_from_db() + + # Assert that the count increased by 1 + self.assertEqual(self.competition.submissions_count, initial_count + 1) + + def test_deleting_submission_updates_submission_count(self): + initial_count = self.competition.submissions_count + + self.assertEqual(initial_count, 1) # one submission created in the setup + + # Delete the existing submission + self.submission.delete() + self.competition.refresh_from_db() + + # Assert that the count decreased by 1 + self.assertEqual(self.competition.submissions_count, initial_count - 1) + + def test_adding_participant_updates_participants_count(self): + initial_count = self.competition.participants_count + + self.assertEqual(initial_count, 1) # default count is 1 + + # Add a new approved participant + new_participant = UserFactory(username='new_participant', password='test') + CompetitionParticipantFactory(user=new_participant, competition=self.competition, status=CompetitionParticipant.APPROVED) + self.competition.refresh_from_db() + + # Assert that the count increased by 1 + self.assertEqual(self.competition.participants_count, initial_count + 1) diff --git a/src/apps/api/tests/test_competitions.py b/src/apps/api/tests/test_competitions.py index 989f668f4..59ebc216c 100644 --- a/src/apps/api/tests/test_competitions.py +++ b/src/apps/api/tests/test_competitions.py @@ -111,7 +111,7 @@ def test_manual_migration_makes_submissions_from_one_phase_in_another(self): # make 5 submissions in phase 1 for _ in range(5): - SubmissionFactory(owner=self.creator, phase=self.phase_1, status=Submission.FINISHED) + SubmissionFactory(owner=self.creator, phase=self.phase_1, status=Submission.FINISHED, leaderboard=self.leaderboard) assert self.phase_1.submissions.count() == 5 assert self.phase_2.submissions.count() == 0 @@ -130,7 +130,7 @@ def test_manual_migration_makes_submissions_out_of_only_parents_not_children(sel self.client.login(username='creator', password='creator') # make 1 submission with 4 children - parent = SubmissionFactory(owner=self.creator, phase=self.phase_1, has_children=True, status=Submission.FINISHED) + parent = SubmissionFactory(owner=self.creator, phase=self.phase_1, has_children=True, status=Submission.FINISHED, leaderboard=self.leaderboard) for _ in range(4): # Make a submission _and_ new Task for phase 2 self.phase_2.tasks.add(TaskFactory()) diff --git a/src/apps/api/tests/test_datasets.py b/src/apps/api/tests/test_datasets.py index 664178116..fb50b5f76 100644 --- a/src/apps/api/tests/test_datasets.py +++ b/src/apps/api/tests/test_datasets.py @@ -3,6 +3,7 @@ from rest_framework.test import APITestCase from datasets.models import Data from factories import UserFactory, DataFactory +from utils.data import pretty_bytes, gb_to_bytes faker = Factory.create() @@ -11,7 +12,7 @@ class DatasetAPITests(APITestCase): def setUp(self): self.creator = UserFactory(username='creator', password='creator') - self.existing_dataset = DataFactory(created_by=self.creator, name="Test!") + self.existing_dataset = DataFactory(created_by=self.creator, name="Test!", file_size=1000) def test_dataset_api_checks_duplicate_names_for_same_user(self): self.client.login(username='creator', password='creator') @@ -22,6 +23,7 @@ def test_dataset_api_checks_duplicate_names_for_same_user(self): 'type': Data.COMPETITION_BUNDLE, 'request_sassy_file_name': faker.file_name(), 'file_name': faker.file_name(), + 'file_size': 1000, }) assert resp.status_code == 400 @@ -32,6 +34,7 @@ def test_dataset_api_checks_duplicate_names_for_same_user(self): 'name': 'Test!', 'type': Data.COMPETITION_BUNDLE, 'request_sassy_file_name': faker.file_name(), + 'file_size': 1000, }) assert resp.status_code == 200 @@ -43,3 +46,43 @@ def test_dataset_api_checks_for_authentication(self): 'request_sassy_file_name': faker.file_name(extension='.zip'), }) assert resp.status_code == 403 + + def test_dataset_api_check_quota(self): + self.client.login(username='creator', password='creator') + + # User quota is in GB + quota = float(self.creator.quota) + # Convert to bytes to compute available space + quota = gb_to_bytes(quota) + # Used storage is in bytes + storage_used = float(self.creator.get_used_storage_space()) + + available_space = quota - storage_used + + # 1 GB = 1,000,000,000 Bytes + # 1 TB = 1,000 GB = 1,000,000,000,000 Bytes + # Using a big file size of 1 TB to run the test + file_size = 1000 * 1000 * 1000 * 1000 + + # Fake upload a very big dataset + resp = self.client.post(reverse("data-list"), { + 'name': 'new-file-test', + 'type': Data.COMPETITION_BUNDLE, + 'request_sassy_file_name': faker.file_name(), + 'file_name': faker.file_name(), + 'file_size': file_size, + }) + + assert resp.status_code == 400 + assert resp.data["data_file"][0] == f'Insufficient space. Your available space is {pretty_bytes(available_space)}. The file size is {pretty_bytes(file_size)}. Please free up some space and try again. You can manage your files in the Resources page.' + + # Fake upload a small file + file_size = available_space - 1000 + resp = self.client.post(reverse("data-list"), { + 'name': 'new-file-test', + 'type': Data.COMPETITION_BUNDLE, + 'request_sassy_file_name': faker.file_name(), + 'file_name': faker.file_name(), + 'file_size': file_size, + }) + assert resp.status_code == 201 diff --git a/src/apps/api/tests/test_public_competitions.py b/src/apps/api/tests/test_public_competitions.py new file mode 100644 index 000000000..2366f4cf5 --- /dev/null +++ b/src/apps/api/tests/test_public_competitions.py @@ -0,0 +1,232 @@ +from rest_framework.test import APIClient +from django.test import TestCase +from factories import ( + UserFactory, + CompetitionFactory, + CompetitionParticipantFactory, +) + + +class PublicCompetitionsTests(TestCase): + def setUp(self): + self.client = APIClient() + + # Users + self.user = UserFactory(username="user1") + self.organizer = UserFactory(username="organizer") + self.other_user = UserFactory(username="other") + + # Login the test client + self.client.force_authenticate(user=self.user) + + # Competitions + self.competition1 = CompetitionFactory( + title="AI Challenge", + published=True, + created_by=self.organizer, + reward="First prize: $5000" + ) + self.competition2 = CompetitionFactory( + title="Vision Contest", + published=True + ) + self.competition3 = CompetitionFactory( + title="ML Challenge", + published=True, + created_by=self.other_user, + reward="Trophy + certificate" + ) + + # Add collaborators + self.competition1.collaborators.add(self.user) + + # Participating user + CompetitionParticipantFactory(user=self.user, competition=self.competition2, status="approved") + + # Add submission counts and participants counts manually + self.competition1.submissions_count = 10 + self.competition1.participants_count = 20 + self.competition1.save() + + self.competition2.submissions_count = 5 + self.competition2.participants_count = 15 + self.competition2.save() + + self.competition3.submissions_count = 0 + self.competition3.participants_count = 5 + self.competition3.save() + + def test_default_ordering(self): + # Send GET request to the public competitions API without any ordering parameter + # This should trigger the default ordering, which is by 'id' in descending order (most recent first) + response = self.client.get("/api/competitions/public/") + + # Ensure the response status is 200 (OK) + self.assertEqual(response.status_code, 200) + + # Extract competition IDs from the response data + ids = [comp["id"] for comp in response.data["results"]] + + # Assert that the competition IDs are sorted in descending order + # This confirms that the default ordering (-id) is correctly applied + self.assertTrue(ids == sorted(ids, reverse=True)) # ordered by -id + + def test_ordering_by_participants_count(self): + # Send GET request to the public competitions API with ordering set to 'popular' + # This should return competitions ordered by participants_count in descending order + response = self.client.get("/api/competitions/public/?ordering=popular") + + # Check that the response status is 200 (OK) + self.assertEqual(response.status_code, 200) + + # Extract the list of competition results from the response + results = response.data["results"] + + # Create a list of participants_count from the results + participants_counts = [c["participants_count"] for c in results] + + # Verify that the participants_count list is sorted in descending order + # This ensures the 'popular' ordering filter is applied correctly + self.assertEqual(participants_counts, sorted(participants_counts, reverse=True)) + + def test_ordering_by_submissions_count(self): + # Send GET request to the public competitions API with ordering by 'with_most_submissions' + # This should return competitions ordered by submissions_count in descending order + response = self.client.get("/api/competitions/public/?ordering=with_most_submissions") + + # Ensure the response was successful + self.assertEqual(response.status_code, 200) + + # Extract the results list from the response + results = response.data["results"] + + # Get the list of submissions_count values from the returned competitions + submissions_counts = [c["submissions_count"] for c in results] + + # Assert that the list is sorted in descending order + # This ensures that the ordering filter by submissions count is working correctly + self.assertEqual(submissions_counts, sorted(submissions_counts, reverse=True)) + + def test_filter_by_title_search(self): + # Send GET request to the public competitions API with the search query "vision" + # This should return only competitions with "vision" in their title (case-insensitive) + response = self.client.get("/api/competitions/public/?search=vision") + + # Ensure the response was successful + self.assertEqual(response.status_code, 200) + + # Extract and lowercase all returned competition titles + titles = [comp["title"].lower() for comp in response.data["results"]] + + # Assert that every returned title contains the word "vision" + # This verifies that the search filter is working correctly + self.assertTrue(all("vision" in title for title in titles)) + + def test_filter_by_participating_in(self): + # Send GET request to the public competitions API with the filter: participating_in=true + # This should return competitions where the user is an approved participant + response = self.client.get("/api/competitions/public/?participating_in=true") + + # Ensure the response was successful + self.assertEqual(response.status_code, 200) + + # Extract the list of competition IDs from the response + returned_ids = [comp["id"] for comp in response.data["results"]] + + # Check that the competition the user is participating in (self.competition2) is included + self.assertIn(self.competition2.id, returned_ids) + + # Check that the competition the user is NOT participating in (self.competition1) is excluded + self.assertNotIn(self.competition3.id, returned_ids) # Not participating in this + + def test_filter_by_organizing(self): + # Send GET request to the public competitions API with the filter: organizing=true + # This should return competitions where the request user is the creator or a collaborator + response = self.client.get("/api/competitions/public/?organizing=true") + + # Ensure the response is successful + self.assertEqual(response.status_code, 200) + + # Extract all returned competition IDs from the response + returned_ids = [comp["id"] for comp in response.data["results"]] + + # Verify that the competition created by the user (self.competition1) is present in the results + self.assertIn(self.competition1.id, returned_ids) + + # Verify that a competition the user is not organizing (self.competition2) is not included + self.assertNotIn(self.competition2.id, returned_ids) # Not organizing + + def test_combined_filters(self): + # Send GET request to the public competitions API + # with both filters: participating_in=true and search="vision" + response = self.client.get("/api/competitions/public/?participating_in=true&search=vision") + + # Ensure the response is successful + self.assertEqual(response.status_code, 200) + + # Extract the results from the paginated response + results = response.data["results"] + + # Expect only one competition to match both filters + self.assertEqual(len(results), 1) + + # Confirm that the returned competition is the one the user is participating in, + # and whose title includes the word "vision" + self.assertEqual(results[0]["id"], self.competition2.id) + + def test_auth_required_for_participating_in_filter(self): + # Log out the currently authenticated user to simulate an anonymous request + self.client.force_authenticate(user=None) + + # Send GET request to the public competitions API with participating_in=true + # Expect this to fail because the user is not authenticated + response = self.client.get("/api/competitions/public/?participating_in=true") + + # Ensure the response has status code 401 (Unauthorized) + self.assertEqual(response.status_code, 401) + + # Confirm that the error message is included in the response + self.assertIn("detail", response.data) + + # Confirm the error message explicitly states the reason + self.assertEqual( + response.data["detail"], + "Authentication required for filtering by participating in or organizing." + ) + + def test_auth_required_for_organizing_filter(self): + # Log out the currently authenticated user to simulate an anonymous request + self.client.force_authenticate(user=None) + + # Send GET request to the public competitions API with organizing=true + # Expect this to fail because the user is not authenticated + response = self.client.get("/api/competitions/public/?organizing=true") + + # Ensure the response has status code 401 (Unauthorized) + self.assertEqual(response.status_code, 401) + + # Confirm that the error message is included in the response + self.assertIn("detail", response.data) + + # Confirm the error message explicitly states the reason + self.assertEqual( + response.data["detail"], + "Authentication required for filtering by participating in or organizing." + ) + + def test_filter_by_has_reward(self): + # Send a GET request to filter competitions that have a reward + response = self.client.get("/api/competitions/public/?has_reward=true") + + # Ensure the response is successful with 200 OK status + self.assertEqual(response.status_code, 200) + + # Extract the competition IDs returned in the response + returned_ids = [comp["id"] for comp in response.data["results"]] + + # Check that competitions with rewards are returned + self.assertIn(self.competition1.id, returned_ids) + self.assertIn(self.competition3.id, returned_ids) + + # Check that competition without a reward is not included + self.assertNotIn(self.competition2.id, returned_ids) diff --git a/src/apps/api/tests/test_submissions.py b/src/apps/api/tests/test_submissions.py index e91a64ecb..a12e3ef13 100644 --- a/src/apps/api/tests/test_submissions.py +++ b/src/apps/api/tests/test_submissions.py @@ -7,7 +7,7 @@ from competitions.models import Submission, CompetitionParticipant from factories import UserFactory, CompetitionFactory, PhaseFactory, CompetitionParticipantFactory, SubmissionFactory, \ - TaskFactory, OrganizationFactory, DataFactory + TaskFactory, OrganizationFactory, DataFactory, LeaderboardFactory from datasets.models import Data from profiles.models import Membership @@ -26,9 +26,17 @@ def setUp(self): # Extra dummy user to test permissions, they shouldn't have access to many things self.other_user = UserFactory(username='other_user', password='other') - # Make a participant and submission into competition - self.participant = UserFactory(username='participant', password='other') - CompetitionParticipantFactory(user=self.participant, competition=self.comp) + # Make participants + self.participant = UserFactory(username='participant_approved', password='other') + self.pending_participant = UserFactory(username='participant_pending', password='other') + self.denied_participant = UserFactory(username='participant_denied', password='other') + + # Add user as participants in a competition with different statuses + CompetitionParticipantFactory(user=self.participant, competition=self.comp, status=CompetitionParticipant.APPROVED) + CompetitionParticipantFactory(user=self.pending_participant, competition=self.comp, status=CompetitionParticipant.PENDING) + CompetitionParticipantFactory(user=self.denied_participant, competition=self.comp, status=CompetitionParticipant.DENIED) + + # add submission with owner = approved participant self.existing_submission = SubmissionFactory( phase=self.phase, owner=self.participant, @@ -232,11 +240,21 @@ def test_who_can_see_detailed_result_when_visualization_is_true(self): resp = self.client.get(url) assert resp.status_code == 200 - # Actual user can see their submission detail result + # approved user can see submission detail result self.client.force_login(self.participant) resp = self.client.get(url) assert resp.status_code == 200 + # pending user cannot see submission detail result + self.client.force_login(self.pending_participant) + resp = self.client.get(url) + assert resp.status_code == 403 + + # denied user cannot see submission detail result + self.client.force_login(self.denied_participant) + resp = self.client.get(url) + assert resp.status_code == 403 + # Regular user cannot see submission detail result self.client.force_login(self.other_user) resp = self.client.get(url) @@ -526,3 +544,122 @@ def test_cannot_re_run_submissions_with_specific_task_without_bot_user(self): resp = self.client.post(url) assert resp.status_code == 403 assert resp.data["detail"] == "You do not have permission to re-run submissions" + + +class SubmissionSoftDeletionTest(APITestCase): + def setUp(self): + self.creator = UserFactory(username='creator', password='creator') + self.participant = UserFactory(username='participant', password='participant') + + self.leaderboard = LeaderboardFactory() + self.comp = CompetitionFactory(created_by=self.creator) + self.phase = PhaseFactory(competition=self.comp) + self.organization = OrganizationFactory() + + # Approved participant + CompetitionParticipantFactory(user=self.participant, competition=self.comp, status=CompetitionParticipant.APPROVED) + + # Submissions + self.submission = SubmissionFactory( + phase=self.phase, + owner=self.participant, + status=Submission.FINISHED, + is_soft_deleted=False, + leaderboard=None + ) + + self.leaderboard_submission = SubmissionFactory( + phase=self.phase, + owner=self.participant, + status=Submission.FINISHED, + is_soft_deleted=False, + leaderboard=self.leaderboard + ) + + self.running_submission = SubmissionFactory( + phase=self.phase, + owner=self.participant, + status=Submission.SUBMITTED, + is_soft_deleted=False, + leaderboard=None + ) + + self.soft_deleted_submission = SubmissionFactory( + phase=self.phase, + owner=self.participant, + status=Submission.FINISHED, + is_soft_deleted=True, + leaderboard=None + ) + + self.organization_submission = SubmissionFactory( + phase=self.phase, + owner=self.participant, + status=Submission.FINISHED, + is_soft_deleted=False, + leaderboard=None, + organization=self.organization + ) + + def test_cannot_delete_submission_if_not_owner(self): + """Ensure that a non-owner cannot soft delete a submission.""" + self.client.login(username="other_user", password="other") + url = reverse("submission-soft-delete", args=[self.submission.pk]) + resp = self.client.delete(url) + + assert resp.status_code == 403 + assert resp.data["error"] == "You are not allowed to delete this submission" + + def test_cannot_delete_leaderboard_submission(self): + """Ensure that a leaderboard submission cannot be deleted.""" + self.client.login(username="participant", password="participant") + url = reverse("submission-soft-delete", args=[self.leaderboard_submission.pk]) + resp = self.client.delete(url) + + assert resp.status_code == 403 + assert resp.data["error"] == "You are not allowed to delete a leaderboard submission" + + def test_cannot_delete_running_submission(self): + """Ensure that a running submission cannot be deleted.""" + self.client.login(username="participant", password="participant") + url = reverse("submission-soft-delete", args=[self.running_submission.pk]) + resp = self.client.delete(url) + + assert resp.status_code == 403 + assert resp.data["error"] == "You are not allowed to delete a running submission" + + def test_cannot_delete_already_soft_deleted_submission(self): + """Ensure that an already soft-deleted submission cannot be deleted again.""" + self.client.login(username="participant", password="participant") + url = reverse("submission-soft-delete", args=[self.soft_deleted_submission.pk]) + resp = self.client.delete(url) + + assert resp.status_code == 400 + assert resp.data["error"] == "Submission already deleted" + + def test_can_soft_delete_submission_successfully(self): + """Ensure a valid submission can be soft deleted successfully by its owner.""" + self.client.login(username="participant", password="participant") + url = reverse("submission-soft-delete", args=[self.submission.pk]) + resp = self.client.delete(url) + + assert resp.status_code == 200 + assert resp.data["message"] == "Submission deleted successfully" + + # Refresh from DB to verify + self.submission.refresh_from_db() + assert self.submission.is_soft_deleted is True + + def test_organization_is_removed_from_soft_deleted_submission(self): + """Ensure a organization reference is removed from soft-deleted submission""" + self.client.login(username="participant", password="participant") + url = reverse("submission-soft-delete", args=[self.organization_submission.pk]) + resp = self.client.delete(url) + + assert resp.status_code == 200 + assert resp.data["message"] == "Submission deleted successfully" + + # Refresh from DB to verify + self.organization_submission.refresh_from_db() + assert self.organization_submission.is_soft_deleted is True + assert self.organization_submission.organization is None diff --git a/src/apps/api/tests/test_tasks.py b/src/apps/api/tests/test_tasks.py index 521c531ba..257fa5aaa 100644 --- a/src/apps/api/tests/test_tasks.py +++ b/src/apps/api/tests/test_tasks.py @@ -1,8 +1,10 @@ +import os from django.urls import reverse from rest_framework.test import APITestCase +from rest_framework import status from competitions.models import Submission -from factories import UserFactory, CompetitionFactory, TaskFactory, SolutionFactory, PhaseFactory, SubmissionFactory +from factories import UserFactory, CompetitionFactory, TaskFactory, SolutionFactory, PhaseFactory, SubmissionFactory, DataFactory class TestTasks(APITestCase): @@ -47,3 +49,140 @@ def test_task_shown_as_validated_properly(self): resp = self.client.get(url) assert resp.status_code == 200 assert not resp.data["validated"] + + +class TestUploadTask(APITestCase): + def setUp(self): + self.user = UserFactory(username='user', password='password') + self.user_low_quota = UserFactory(username='user_low_quota', password='password_low_quota', quota=0) + self.user2 = UserFactory(username='user2', password='password2') + + uuid1 = "96187a93-94ea-40a1-b394-af2e7e3edb2e" + uuid2 = "a0f80316-8c46-4c04-a5d4-6184904bdb69" + uuid3 = "6c3e6dde-d0fa-4c22-af66-030187dbfd4f" + uuid4 = "c4179c3f-498c-486a-8ac5-1e194036a3ed" + uuid5 = "f861a11c-36cb-4907-9f82-4aa609b4e822" + + self.ingestion_program = DataFactory(created_by=self.user, type='ingestion_program', key=uuid1) + self.scoring_program = DataFactory(created_by=self.user, type='scoring_program', key=uuid2) + self.input_data = DataFactory(created_by=self.user, type='input_data', key=uuid3) + self.reference_data = DataFactory(created_by=self.user, type='reference_data', key=uuid4) + + self.ingestion_program_from_user2 = DataFactory(created_by=self.user2, type='ingestion_program', key=uuid5) + + def test_file_not_uploaded(self): + self.client.login(username=self.user.username, password='password') + + response = self.client.post(reverse('tasks:upload_task'), {}, format='multipart') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "No attached file found, please try again!" == response.data['error'] + + def test_quota_not_enough(self): + self.client.login(username=self.user_low_quota.username, password='password_low_quota') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'valid_task_with_files.zip') + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_507_INSUFFICIENT_STORAGE + assert "Insufficient space! Please free up some space and try again. You can manage your files in the Resources page." == response.data['error'] + + def test_yaml_not_found_in_zip(self): + self.client.login(username=self.user.username, password='password') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'no_yaml.zip') + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "task.yaml not found in the zip file" == response.data['error'] + + def test_yaml_cannot_be_parsed(self): + self.client.login(username=self.user.username, password='password') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'invalid_yaml.zip') + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Error parsing task.yaml:" in response.data['error'] + + def test_yaml_missing_name(self): + self.client.login(username=self.user.username, password='password') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'missing_name.zip') + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Missing: name, task must have a name" == response.data['error'] + + def test_yaml_missing_description(self): + self.client.login(username=self.user.username, password='password') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'missing_description.zip') + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Missing: description, task must have a description" == response.data['error'] + + def test_yaml_missing_scoring_program(self): + self.client.login(username=self.user.username, password='password') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'missing_scoring_program.zip') + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Missing: scoring_program, task must have a scoring_program" == response.data['error'] + + def test_dataset_not_belongs_to_user(self): + self.client.login(username=self.user.username, password='password') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'invalid_ingestion_key.zip') + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "ingestion_program with key 'f861a11c-36cb-4907-9f82-4aa609b4e822' not found." == response.data['error'] + + def test_missing_key_and_zip_for_scoring_program(self): + self.client.login(username=self.user.username, password='password') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'scoring_program_missing_key_and_zip.zip') + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "scoring_program must have either a key or zip" == response.data['error'] + + def test_dataset_file_missing_in_zip(self): + self.client.login(username=self.user.username, password='password') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'missing_ingestion_zip.zip') + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Dataset file 'iris-ingestion-program.zip' not found in the uploaded zip file." == response.data['error'] + + def test_dataset_file_not_zip(self): + self.client.login(username=self.user.username, password='password') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'invalid_ingestion_zip.zip') + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "Dataset file 'iris-ingestion-program.txt' should be a zip file." == response.data['error'] + + def test_task_created_successfully_with_keys(self): + self.client.login(username=self.user.username, password='password') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'valid_task_with_keys.zip') + + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_201_CREATED + assert "Task 'Iris Task' created successfully!" == response.data['message'] + + def test_task_created_successfully_with_zips(self): + self.client.login(username=self.user.username, password='password') + + file_path = os.path.join(os.path.dirname(__file__), 'upload_task_test_files', 'valid_task_with_files.zip') + + with open(file_path, 'rb') as zip_file: + response = self.client.post(reverse('tasks:upload_task'), {'file': zip_file}, format='multipart') + assert response.status_code == status.HTTP_201_CREATED + assert "Task 'Iris Task' created successfully!" == response.data['message'] diff --git a/src/apps/api/tests/upload_task_test_files/invalid_ingestion_key.zip b/src/apps/api/tests/upload_task_test_files/invalid_ingestion_key.zip new file mode 100644 index 000000000..b4ff47c8b Binary files /dev/null and b/src/apps/api/tests/upload_task_test_files/invalid_ingestion_key.zip differ diff --git a/src/apps/api/tests/upload_task_test_files/invalid_ingestion_zip.zip b/src/apps/api/tests/upload_task_test_files/invalid_ingestion_zip.zip new file mode 100644 index 000000000..59f22cec4 Binary files /dev/null and b/src/apps/api/tests/upload_task_test_files/invalid_ingestion_zip.zip differ diff --git a/src/apps/api/tests/upload_task_test_files/invalid_yaml.zip b/src/apps/api/tests/upload_task_test_files/invalid_yaml.zip new file mode 100644 index 000000000..293381a3b Binary files /dev/null and b/src/apps/api/tests/upload_task_test_files/invalid_yaml.zip differ diff --git a/src/apps/api/tests/upload_task_test_files/missing_description.zip b/src/apps/api/tests/upload_task_test_files/missing_description.zip new file mode 100644 index 000000000..9a3799079 Binary files /dev/null and b/src/apps/api/tests/upload_task_test_files/missing_description.zip differ diff --git a/src/apps/api/tests/upload_task_test_files/missing_ingestion_zip.zip b/src/apps/api/tests/upload_task_test_files/missing_ingestion_zip.zip new file mode 100644 index 000000000..ac5e66cbe Binary files /dev/null and b/src/apps/api/tests/upload_task_test_files/missing_ingestion_zip.zip differ diff --git a/src/apps/api/tests/upload_task_test_files/missing_name.zip b/src/apps/api/tests/upload_task_test_files/missing_name.zip new file mode 100644 index 000000000..8c9a78765 Binary files /dev/null and b/src/apps/api/tests/upload_task_test_files/missing_name.zip differ diff --git a/src/apps/api/tests/upload_task_test_files/missing_scoring_program.zip b/src/apps/api/tests/upload_task_test_files/missing_scoring_program.zip new file mode 100644 index 000000000..7110a9dd4 Binary files /dev/null and b/src/apps/api/tests/upload_task_test_files/missing_scoring_program.zip differ diff --git a/src/apps/api/tests/upload_task_test_files/no_yaml.zip b/src/apps/api/tests/upload_task_test_files/no_yaml.zip new file mode 100644 index 000000000..76941c5eb Binary files /dev/null and b/src/apps/api/tests/upload_task_test_files/no_yaml.zip differ diff --git a/src/apps/api/tests/upload_task_test_files/scoring_program_missing_key_and_zip.zip b/src/apps/api/tests/upload_task_test_files/scoring_program_missing_key_and_zip.zip new file mode 100644 index 000000000..af8b5c176 Binary files /dev/null and b/src/apps/api/tests/upload_task_test_files/scoring_program_missing_key_and_zip.zip differ diff --git a/src/apps/api/tests/upload_task_test_files/valid_task_with_files.zip b/src/apps/api/tests/upload_task_test_files/valid_task_with_files.zip new file mode 100644 index 000000000..43fbbf0a2 Binary files /dev/null and b/src/apps/api/tests/upload_task_test_files/valid_task_with_files.zip differ diff --git a/src/apps/api/tests/upload_task_test_files/valid_task_with_keys.zip b/src/apps/api/tests/upload_task_test_files/valid_task_with_keys.zip new file mode 100644 index 000000000..acb5a2831 Binary files /dev/null and b/src/apps/api/tests/upload_task_test_files/valid_task_with_keys.zip differ diff --git a/src/apps/api/urls.py b/src/apps/api/urls.py index 0bb521b3e..c116104ea 100644 --- a/src/apps/api/urls.py +++ b/src/apps/api/urls.py @@ -57,11 +57,23 @@ # User quota and cleanup path('user_quota_cleanup/', quota.user_quota_cleanup, name="user_quota_cleanup"), + path('user_quota/', quota.user_quota, name="user_quota"), path('delete_unused_tasks/', quota.delete_unused_tasks, name="delete_unused_tasks"), path('delete_unused_datasets/', quota.delete_unused_datasets, name="delete_unused_datasets"), path('delete_unused_submissions/', quota.delete_unused_submissions, name="delete_unused_submissions"), path('delete_failed_submissions/', quota.delete_failed_submissions, name="delete_failed_submissions"), + # User account + path('delete_account/', profiles.delete_account, name="delete_account"), + + # Analytics + path('analytics/storage_usage_history/', analytics.storage_usage_history, name='storage_usage_history'), + path('analytics/competitions_usage/', analytics.competitions_usage, name='competitions_usage'), + path('analytics/users_usage/', analytics.users_usage, name='users_usage'), + path('analytics/delete_orphan_files/', analytics.delete_orphan_files, name="delete_orphan_files"), + path('analytics/get_orphan_files/', analytics.get_orphan_files, name="get_orphan_files"), + path('analytics/check_orphans_deletion_status/', analytics.check_orphans_deletion_status, name="check_orphans_deletion_status"), + # API Docs re_path(r'docs(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), path('docs/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), diff --git a/src/apps/api/views/analytics.py b/src/apps/api/views/analytics.py index 084b042e7..c102135cb 100644 --- a/src/apps/api/views/analytics.py +++ b/src/apps/api/views/analytics.py @@ -1,20 +1,28 @@ from django.db.models import Count, F from django.contrib.auth import get_user_model from django.http import Http404 +from rest_framework import status +from rest_framework.exceptions import PermissionDenied from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.renderers import JSONRenderer from rest_framework.filters import BaseFilterBackend +from rest_framework.decorators import api_view from rest_framework_csv import renderers as r from competitions.models import Competition, Submission +from analytics.models import StorageUsageHistory, CompetitionStorageDataPoint, UserStorageDataPoint from api.serializers.analytics import AnalyticsSerializer +from apps.analytics.tasks import delete_orphan_files as delete_orphan_files_async_task +import os import datetime import coreapi import pytz +import logging User = get_user_model() +delete_orphan_files_task = None class SimpleFilterBackend(BaseFilterBackend): @@ -158,3 +166,243 @@ def get(self, request): 'end_date': end_date, 'time_unit': time_unit, }) + + +@api_view(["GET"]) +def storage_usage_history(request): + """ + Gets the storage usage timeline between the 2 provided dates at the given resolution + """ + if not request.user.is_superuser: + raise PermissionDenied(detail="Admin only") + + storage_usage_history = {} + last_storage_usage_history_snapshot = StorageUsageHistory.objects.order_by("-at_date").first() + if last_storage_usage_history_snapshot: + start_date = request.query_params.get("start_date", (datetime.datetime.today() - datetime.timedelta(weeks=4)).strftime("%Y-%m-%d")) + end_date = request.query_params.get("end_date", datetime.datetime.today().strftime("%Y-%m-%d")) + resolution = request.query_params.get("resolution", "day") + + query = StorageUsageHistory.objects.filter( + bucket_name=last_storage_usage_history_snapshot.bucket_name, + at_date__range=(start_date, end_date), + ).dates("at_date", resolution).values() + for su in query.order_by("-at_date"): + storage_usage_history[su['datefield'].isoformat()] = { + 'total_usage': su['total_usage'], + 'competitions_usage': su['competitions_usage'], + 'users_usage': su['users_usage'], + 'admin_usage': su['admin_usage'], + 'orphaned_file_usage': su['orphaned_file_usage'] + } + + response = { + "last_storage_calculation_date": last_storage_usage_history_snapshot.created_at.isoformat() if last_storage_usage_history_snapshot else None, + "storage_usage_history": storage_usage_history + } + + return Response(response, status=status.HTTP_200_OK) + + +@api_view(["GET"]) +def competitions_usage(request): + """ + Gets the competitions usage between the 2 provided dates at the given resolution + """ + if not request.user.is_superuser: + raise PermissionDenied(detail="Admin only") + + competitions_usage = {} + last_competition_storage_snapshot = CompetitionStorageDataPoint.objects.order_by("-at_date").first() + if last_competition_storage_snapshot: + start_date = request.query_params.get("start_date", (datetime.datetime.today() - datetime.timedelta(weeks=4)).strftime("%Y-%m-%d")) + end_date = request.query_params.get("end_date", datetime.datetime.today().strftime("%Y-%m-%d")) + resolution = request.query_params.get("resolution", "day") + + query = CompetitionStorageDataPoint.objects.filter( + at_date__range=(start_date, end_date), + competition__isnull=False + ).dates("at_date", resolution).values( + 'id', + 'competition__id', + 'competition__title', + 'competition__created_by__username', + 'competition__created_by__email', + 'competition__created_when', + 'datasets_total', + 'datefield' + ) + for su in query.order_by("-datefield", "competition__id"): + username = su['competition__created_by__username'] or ("user #" + su['competition__created_by__id']) or "unknown user" + email = su['competition__created_by__email'] or "no email" + competitions_usage.setdefault(su['datefield'].isoformat(), {})[su['competition__id']] = { + 'snapshot_id': su['id'], + 'title': su['competition__title'], + 'organizer': username + " (" + email + ")", + 'created_when': su['competition__created_when'], + 'datasets': su['datasets_total'], + } + + response = { + "last_storage_calculation_date": last_competition_storage_snapshot.created_at.isoformat() if last_competition_storage_snapshot else None, + "competitions_usage": competitions_usage + } + + return Response(response, status=status.HTTP_200_OK) + + +@api_view(["GET"]) +def users_usage(request): + """ + Gets the users usage between the 2 provided dates at the given resolution + """ + if not request.user.is_superuser: + raise PermissionDenied(detail="Admin only") + + users_usage = {} + last_user_storage_snapshot = UserStorageDataPoint.objects.order_by("-at_date").first() + if last_user_storage_snapshot: + start_date = request.query_params.get("start_date", (datetime.datetime.today() - datetime.timedelta(weeks=4)).strftime("%Y-%m-%d")) + end_date = request.query_params.get("end_date", datetime.datetime.today().strftime("%Y-%m-%d")) + resolution = request.query_params.get("resolution", "day") + + query = UserStorageDataPoint.objects.filter( + at_date__range=(start_date, end_date), + user__isnull=False + ).dates("at_date", resolution).values( + 'id', + 'user__id', + 'user__username', + 'user__email', + 'user__date_joined', + 'datasets_total', + 'submissions_total', + 'datefield' + ) + for su in query.order_by("-datefield", "user__id"): + username = su['user__username'] or ("user #" + su['user__id']) or "unknown user" + email = su['user__email'] or "no email" + users_usage.setdefault(su['datefield'].isoformat(), {})[su['user__id']] = { + 'snapshot_id': su['id'], + 'name': username + " (" + email + ")", + 'date_joined': su['user__date_joined'], + 'datasets': su['datasets_total'], + 'submissions': su['submissions_total'], + } + + response = { + "last_storage_calculation_date": last_user_storage_snapshot.created_at.isoformat() if last_user_storage_snapshot else None, + "users_usage": users_usage + } + + return Response(response, status=status.HTTP_200_OK) + + +@api_view(["GET"]) +def get_orphan_files(request): + """ + Get the orphan files based on the last storage analytics + """ + + if not request.user.is_superuser: + raise PermissionDenied(detail="Admin only") + + logger = logging.getLogger(__name__) + + # Find most recent file + most_recent_log_file = get_most_recent_storage_inconsistency_log_file() + if not most_recent_log_file: + logger.warning("No storage inconsistency log file found.") + return Response({"message": "No storage inconsistency log file found."}, status=status.HTTP_404_NOT_FOUND) + + # Get the list of orphan files from the content of the most recent log file + log_folder = "/app/logs/" + orphan_files_path = get_files_path_from_orphan_log_file(os.path.join(log_folder, most_recent_log_file)) + + return Response({"data": orphan_files_path}, status=status.HTTP_200_OK) + + +@api_view(["DELETE"]) +def delete_orphan_files(request): + """ + Start the deletion of orphan files task + """ + + if not request.user.is_superuser: + raise PermissionDenied(detail="Admin only") + + global delete_orphan_files_task + delete_orphan_files_task = delete_orphan_files_async_task.delay() + + return Response({"success": True, "message": "orphan files deletion started"}, status=status.HTTP_200_OK) + + +def get_most_recent_storage_inconsistency_log_file(): + logger = logging.getLogger(__name__) + + log_folder = "/app/logs/" + try: + log_files = [f for f in os.listdir(log_folder) if os.path.isfile(os.path.join(log_folder, f))] + except FileNotFoundError: + logger.info(f"Folder '{log_folder}' does not exist.") + return None + + most_recent_log_file = None + most_recent_datetime = None + datetime_format = "%Y%m%d-%H%M%S" + for file in log_files: + try: + basename = os.path.basename(file) + datetime_str = basename[len("db_storage_inconsistency_"):-len(".log")] + file_datetime = datetime.datetime.strptime(datetime_str, datetime_format) + if most_recent_datetime is None or file_datetime > most_recent_datetime: + most_recent_datetime = file_datetime + most_recent_log_file = file + except ValueError: + logger.warning(f"Filename '{file}' does not match the expected format and will be ignored.") + + return most_recent_log_file + + +def get_files_path_from_orphan_log_file(log_file_path): + logger = logging.getLogger(__name__) + + files_path = [] + + try: + with open(log_file_path) as log_file: + lines = log_file.readlines() + orphan_files_lines = [] + for i, line in enumerate(lines): + if "Orphaned files" in line: + orphan_files_lines = lines[i + 1:] + break + + for orphan_files_line in orphan_files_lines: + files_path.append(orphan_files_line.split(maxsplit=1)[0]) + except FileNotFoundError: + logger.error(f"File '{log_file_path}' does not exist.") + except PermissionError: + logger.error(f"Permission denied for reading the file '{log_file_path}'.") + except IOError as e: + logger.error(f"An I/O error occurred while accessing the file at {log_file_path}: {e}") + + return files_path + + +@api_view(["GET"]) +def check_orphans_deletion_status(request): + """ + Get the orphan files deletion task status. + Return one of ["PENDING", "STARTED", "SUCCESS", "FAILURE", "RETRY", "REVOKED"] + """ + + if not request.user.is_superuser: + raise PermissionDenied(detail="Admin only") + + global delete_orphan_files_task + state = None + if delete_orphan_files_task: + state = delete_orphan_files_task.state + + return Response({"status": state}, status=status.HTTP_200_OK) diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index fa34b4d55..926da1b04 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -6,7 +6,7 @@ from django.http import HttpResponse from tempfile import SpooledTemporaryFile from django.db import IntegrityError -from django.db.models import Subquery, OuterRef, Count, Q, F, Case, When +from django.db.models import Subquery, OuterRef, Q from django_filters.rest_framework import DjangoFilterBackend from drf_yasg.utils import swagger_auto_schema, no_body from rest_framework import status @@ -18,8 +18,6 @@ from rest_framework.response import Response from rest_framework.renderers import JSONRenderer from rest_framework_csv.renderers import CSVRenderer -from rest_framework_extensions.cache.decorators import cache_response -from rest_framework_extensions.key_constructor.constructors import DefaultListKeyConstructor from api.pagination import LargePagination from api.renderers import ZipRenderer from rest_framework.viewsets import ModelViewSet @@ -32,11 +30,10 @@ from competitions.models import Competition, Phase, CompetitionCreationTaskStatus, CompetitionParticipant, Submission from datasets.models import Data from competitions.tasks import batch_send_email, manual_migration, create_competition_dump -from competitions.utils import get_popular_competitions, get_featured_competitions +from competitions.utils import get_popular_competitions, get_recent_competitions from leaderboards.models import Leaderboard from utils.data import make_url_sassy from api.permissions import IsOrganizerOrCollaborator -from datetime import datetime from django.db import transaction from django.conf import settings @@ -112,32 +109,34 @@ def get_queryset(self): # not called from search bar # not called with a valid secret key if (not mine) and (not participating_in) and (not secret_key) and (not search_query): - - # Return the following --- - # All competitions which belongs to you (private or public) - # And competitions where you are admin - # And public competitions - # And competitions where you are approved participant - # this filters out all private compettions from other users - base_qs = qs.filter( - (Q(created_by=self.request.user)) | - (Q(collaborators__in=[self.request.user])) | - (Q(published=True) & ~Q(created_by=self.request.user)) | - (Q(participants__user=self.request.user) & Q(participants__status="approved")) - ) - - # Additional condition of action - # allow private competition when action is register and has valid secret key - if self.request.method == 'POST' and self.action == 'register': - # get secret_key from request data - register_secret_key = self.request.data.get('secret_key', None) - # use secret key if available - if register_secret_key: - qs = base_qs | qs.filter(Q(secret_key=register_secret_key)) + # If authenticated user is not super user + if not self.request.user.is_superuser: + # Return the following --- + # All competitions which belongs to you (private or public) + # And competitions where you are admin + # And public competitions + # And competitions where you are approved participant + # this filters out all private compettions from other users + base_qs = qs.filter( + (Q(created_by=self.request.user)) | + (Q(collaborators__in=[self.request.user])) | + (Q(published=True) & ~Q(created_by=self.request.user)) | + (Q(participants__user=self.request.user) & Q(participants__status="approved")) + ) + + # Additional condition of action + # allow private competition when action is register and has valid secret key + if self.request.method == 'POST' and self.action == 'register': + # get secret_key from request data + register_secret_key = self.request.data.get('secret_key', None) + # use secret key if available + if register_secret_key: + qs = base_qs | qs.filter(Q(secret_key=register_secret_key)) + else: + qs = base_qs else: qs = base_qs - else: - qs = base_qs + # select distinct competitions qs = qs.distinct() @@ -168,13 +167,13 @@ def get_queryset(self): 'phases__leaderboard__columns', 'collaborators', ) - qs = qs.annotate(participant_count=Count(F('participants'), distinct=True)) - qs = qs.annotate(submission_count=Count( - # Filtering out children submissions so we only count distinct submissions - Case( - When(phases__submissions__parent__isnull=True, then='phases__submissions__pk') - ), distinct=True) - ) + # qs = qs.annotate(participant_count=Count(F('participants'), distinct=True)) + # qs = qs.annotate(submission_count=Count( + # # Filtering out children submissions so we only count distinct submissions + # Case( + # When(phases__submissions__parent__isnull=True, then='phases__submissions__pk') + # ), distinct=True) + # ) # search_query is true when called from searchbar if search_query: @@ -195,6 +194,8 @@ def get_permissions(self): def get_serializer_class(self): if self.action == 'list': return CompetitionSerializerSimple + if self.action == 'public': + return CompetitionSerializerSimple elif self.action in ['get_phases', 'results', 'get_leaderboard_frontend_object']: return LeaderboardPhaseSerializer elif self.request.method == 'GET': @@ -218,7 +219,7 @@ def create(self, request, *args, **kwargs): data = request.data if 'leaderboards' in data: leaderboard_data = data['leaderboards'][0] - if(leaderboard_data['id']): + if leaderboard_data['id']: leaderboard_instance = Leaderboard.objects.get(id=leaderboard_data['id']) leaderboard = LeaderboardSerializer(leaderboard_instance, data=data['leaderboards'][0]) else: @@ -251,7 +252,7 @@ def update(self, request, *args, **kwargs): # save leaderboard individually, then pass pk to each phase if 'leaderboards' in data: leaderboard_data = data['leaderboards'][0] - if(leaderboard_data['id']): + if leaderboard_data['id']: leaderboard_instance = Leaderboard.objects.get(id=leaderboard_data['id']) leaderboard = LeaderboardSerializer(leaderboard_instance, data=data['leaderboards'][0]) else: @@ -269,11 +270,13 @@ def update(self, request, *args, **kwargs): new_phase_obj = Phase.objects.create( status=phase["status"], index=phase["index"], - start=datetime.strptime(phase['start'], "%B %d, %Y"), - end=datetime.strptime(phase['end'], "%B %d, %Y") if phase['end'] else None, + start=phase['start'], + end=phase['end'] if phase['end'] else None, name=phase["name"], description=phase["description"], hide_output=phase["hide_output"], + hide_prediction_output=phase["hide_prediction_output"], + hide_score_output=phase["hide_score_output"], competition=Competition.objects.get(id=data['id']) ) # Get phase id @@ -296,8 +299,17 @@ def update(self, request, *args, **kwargs): phase['starting_kit'] = Data.objects.filter(key=phase['starting_kit']['value'])[0].id except TypeError: phase['starting_kit'] = None + + # Get whitelist emails from data + whitelist_emails = data['whitelist_emails'] + # Delete white_list emails from data because it is not in a list of dict format, it is just list of emails + data.pop('whitelist_emails', None) + # Loop over whitelist emails and add them back to whitelist emails in dict format + for email in whitelist_emails: + # user lower case email because some emails in the whitelist may have upper case letters + data.setdefault('whitelist_emails', []).append({'email': email.lower()}) + serializer = self.get_serializer(instance, data=data, partial=partial) - type(serializer) serializer.is_valid(raise_exception=True) self.perform_update(serializer) @@ -340,8 +352,14 @@ def register(self, request, pk): participant.status = 'approved' send_participation_accepted_emails(participant) else: - participant.status = 'pending' - send_participation_requested_emails(participant) + # check if user is in whitelist emails then approve directly + # Using lower case because some users have used uppercased emails addresses + if user.email.lower() in list(competition.whitelist_emails.values_list('email', flat=True)): + participant.status = 'approved' + send_participation_accepted_emails(participant) + else: + participant.status = 'pending' + send_participation_requested_emails(participant) participant.save() return Response({'participant_status': participant.status}, status=status.HTTP_201_CREATED) @@ -418,7 +436,7 @@ def collect_leaderboard_data(self, competition, phase_pk=None): return leaderboard_data @action(detail=True, methods=['GET'], renderer_classes=[JSONRenderer, CSVRenderer, ZipRenderer]) - def results(self, request, pk, format=None): + def results(self, request, pk, format='json'): competition = self.get_object() if not competition.user_has_admin_permission(request.user): raise PermissionDenied("You are not a competition admin or superuser") @@ -493,12 +511,12 @@ def creation_status(self, request, pk): @action(detail=False, methods=('GET',), permission_classes=(AllowAny,)) def front_page(self, request): popular_comps = get_popular_competitions() - featured_comps = get_featured_competitions(excluded_competitions=popular_comps) + recent_comps = get_recent_competitions(exclude_comps=popular_comps) popular_comps_serializer = CompetitionSerializerSimple(popular_comps, many=True) - featured_comps_serializer = CompetitionSerializerSimple(featured_comps, many=True) + recent_comps_serializer = CompetitionSerializerSimple(recent_comps, many=True) return Response(data={ "popular_comps": popular_comps_serializer.data, - "featured_comps": featured_comps_serializer.data + "recent_comps": recent_comps_serializer.data }) @swagger_auto_schema(request_body=no_body, responses={201: CompetitionCreationTaskStatusSerializer()}) @@ -518,14 +536,83 @@ def create_dump(self, request, pk=None): serializer = CompetitionCreationTaskStatusSerializer({"status": "Success. Competition dump is being created."}) return Response(serializer.data, status=201) - @cache_response(key_func=DefaultListKeyConstructor()) @action(detail=False, methods=('GET',), pagination_class=LargePagination) def public(self, request): - qs = self.get_queryset() - qs = qs.filter(published=True) - qs = qs.order_by('-id') - queryset = self.filter_queryset(qs) + """ + Retrieve a public list of published competitions with optional filtering and ordering. + + This endpoint returns a paginated list of competitions that are publicly published. + It supports several optional query parameters for filtering and sorting the results. + Some filters require the user to be authenticated. + + Query Parameters: + ----------------- + - search (str, optional): A search term to filter competitions by their title. + - ordering (str, optional): Specifies the order of the results. Supported values: + * "recent" - Most recently created competitions. + * "popular" - Competitions with the most participants. + * "with_most_submissions" - Competitions with the highest number of submissions. + Defaults to "recent" if not provided or invalid. + - participating_in (bool, optional): If "true", filters competitions where the user + is an approved participant. Requires authentication. + - organizing (bool, optional): If "true", filters competitions organized by the user + (either created or as a collaborator). Requires authentication. + - has_reward (bool, optional): If "true", includes only competitions that have a + non-empty reward field. + + Returns: + -------- + - 200 OK: A paginated or full list of serialized competitions matching the filter criteria. The response is serialized using `CompetitionSerializerSimple`. + - 401 Unauthorized: If the user tries to use filters requiring authentication while not logged in. + """ + + # Receive filters from request query params + search = request.query_params.get("search") + ordering = request.query_params.get("ordering") + participating_in = request.query_params.get("participating_in", "false").lower() == "true" + organizing = request.query_params.get("organizing", "false").lower() == "true" + has_reward = request.query_params.get("has_reward", "false").lower() == "true" + + # If user is not authenticated but trying to use filters that require authentication + if not request.user.is_authenticated and (participating_in or organizing): + return Response( + {"detail": "Authentication required for filtering by participating in or organizing."}, + status=status.HTTP_401_UNAUTHORIZED + ) + + qs = Competition.objects.filter(published=True) + + # Filter by title (search) + if search: + qs = qs.filter(title__icontains=search) + + # Filter by participation + if participating_in: + participant_comp_ids = CompetitionParticipant.objects.filter( + user=request.user, + status="approved" + ).values_list("competition_id", flat=True) + qs = qs.filter(id__in=participant_comp_ids) + + # Filter by organizing (created_by or collaborator) + if organizing: + qs = qs.filter(Q(created_by=request.user) | Q(collaborators=request.user)) + + # Apply ordering + if ordering == "recent": + qs = qs.order_by("-id") # most recently created + elif ordering == "popular": + qs = qs.order_by("-participants_count") + elif ordering == "with_most_submissions": + qs = qs.order_by("-submissions_count") + else: + qs = qs.order_by("-id") # default fallback + + # Applying has reward + if has_reward: + qs = qs.exclude(reward__isnull=True).exclude(reward__exact='') + queryset = self.filter_queryset(qs) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True) @@ -619,8 +706,8 @@ def rerun_submissions(self, request, pk): phase = self.get_object() comp = phase.competition - # Get submissions - submissions = phase.submissions.all() + # Get submissions with no parent + submissions = phase.submissions.filter(parent__isnull=True) can_re_run_submissions = False error_message = "" @@ -688,23 +775,7 @@ def get_leaderboard(self, request, pk): submissions_keys = {} submission_detailed_results = {} for submission in query['submissions']: - # count number of entries/number of submissions for the owner of this submission for this phase - # count all submissions with no parent and count all parents without counting the children - num_entries = Submission.objects.filter( - Q(owner__username=submission['owner']) | Q(parent__owner__username=submission['owner']), - phase=phase, - ).exclude( - parent__isnull=False - ).count() - - # get date of last submission by the owner of this submission for this phase - last_entry_date = Submission.objects.filter(owner__username=submission['owner'], phase=phase)\ - .values('created_when')\ - .order_by('-created_when')[0]['created_when']\ - .strftime('%Y-%m-%d') - submission_key = f"{submission['owner']}{submission['parent'] or submission['id']}" - # gather detailed result from submissions for each task # detailed_results are gathered based on submission key # `id` is used to fetch the right detailed result in detailed results page @@ -725,8 +796,7 @@ def get_leaderboard(self, request, pk): 'fact_sheet_answers': submission['fact_sheet_answers'], 'slug_url': submission['slug_url'], 'organization': submission['organization'], - 'num_entries': num_entries, - 'last_entry_date': last_entry_date + 'created_when': submission['created_when'] }) for score in submission['scores']: @@ -783,7 +853,7 @@ class CompetitionParticipantViewSet(ModelViewSet): queryset = CompetitionParticipant.objects.all() serializer_class = CompetitionParticipantSerializer filter_backends = (DjangoFilterBackend, SearchFilter) - filter_fields = ('user__username', 'user__email', 'status', 'competition') + filter_fields = ('user__username', 'user__email', 'status', 'competition', 'user__is_deleted') search_fields = ('user__username', 'user__email',) def get_queryset(self): @@ -824,16 +894,23 @@ def get_queryset(self): return CompetitionParticipant.objects.none() def update(self, request, *args, **kwargs): - if request.method == 'PATCH': - if 'status' in request.data: - participation_status = request.data['status'] - participant = self.get_object() - emails = { - 'approved': send_participation_accepted_emails, - 'denied': send_participation_denied_emails, - } - if participation_status in emails: - emails[participation_status](participant) + if request.method == 'PATCH' and 'status' in request.data: + participation_status = request.data['status'] + participant = self.get_object() + + # Check if the new status is the same as the current status + if participation_status == participant.status: + return Response( + {"error": f"Status is already set to `{participation_status}`"}, + status=status.HTTP_400_BAD_REQUEST + ) + + emails = { + 'approved': send_participation_accepted_emails, + 'denied': send_participation_denied_emails, + } + if participation_status in emails: + emails[participation_status](participant) return super().update(request, *args, **kwargs) diff --git a/src/apps/api/views/datasets.py b/src/apps/api/views/datasets.py index fd2ae17cb..8618af38e 100644 --- a/src/apps/api/views/datasets.py +++ b/src/apps/api/views/datasets.py @@ -14,7 +14,7 @@ from api.serializers import datasets as serializers from datasets.models import Data, DataGroup from competitions.models import CompetitionCreationTaskStatus -from utils.data import make_url_sassy +from utils.data import make_url_sassy, pretty_bytes, gb_to_bytes class DataViewSet(ModelViewSet): @@ -40,6 +40,9 @@ def get_queryset(self): # _type = dataset if called from datasets and programs tab to filter datasets and programs is_dataset = self.request.query_params.get('_type', '') == 'dataset' + # _type = dataset if called from datasets and programs tab to filter datasets and programs + is_bundle = self.request.query_params.get('_type', '') == 'bundle' + # get queryset qs = self.queryset @@ -49,7 +52,19 @@ def get_queryset(self): # filter datasets and programs if is_dataset: - qs = qs.filter(~Q(type=Data.SUBMISSION)) + qs = qs.filter(type__in=[ + Data.INPUT_DATA, + Data.PUBLIC_DATA, + Data.REFERENCE_DATA, + Data.INGESTION_PROGRAM, + Data.SCORING_PROGRAM, + Data.STARTING_KIT, + Data.SOLUTION + ]) + + # filter bundles + if is_bundle: + qs = qs.filter(Q(type=Data.COMPETITION_BUNDLE)) # public filter check if is_public: @@ -58,7 +73,7 @@ def get_queryset(self): qs = qs.filter(Q(created_by=self.request.user)) # if GET is called but provided no filters, fall back to default behaviour - if (not is_submission) and (not is_dataset) and (not is_public): + if (not is_submission) and (not is_dataset) and (not is_bundle) and (not is_public): qs = self.queryset qs = qs.filter(Q(is_public=True) | Q(created_by=self.request.user)) @@ -66,7 +81,7 @@ def get_queryset(self): qs = self.queryset qs = qs.filter(Q(is_public=True) | Q(created_by=self.request.user)) - qs = qs.exclude(Q(type=Data.COMPETITION_BUNDLE) | Q(name__isnull=True)) + qs = qs.exclude(Q(name__isnull=True)) qs = qs.select_related('created_by').order_by('-created_when') @@ -79,6 +94,18 @@ def get_serializer_class(self): return serializers.DataSerializer def create(self, request, *args, **kwargs): + # Check User quota + storage_used = float(request.user.get_used_storage_space()) + quota = float(request.user.quota) + quota = gb_to_bytes(quota) + file_size = float(request.data['file_size']) + if storage_used + file_size > quota: + available_space = pretty_bytes(quota - storage_used) + file_size = pretty_bytes(file_size) + message = f'Insufficient space. Your available space is {available_space}. The file size is {file_size}. Please free up some space and try again. You can manage your files in the Resources page.' + return Response({'data_file': [message]}, status=status.HTTP_400_BAD_REQUEST) + + # All good, let's proceed serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) new_dataset = serializer.save() # request_sassy_file_name is temporarily set via this serializer @@ -139,7 +166,7 @@ def check_delete_permissions(self, request, dataset): if dataset.submission.first(): sub = dataset.submission.first() if sub.phase: - return 'Cannot delete submission: submission belongs to an existing competition' + return 'Cannot delete submission: submission belongs to an existing competition. Please visit the competition and delete your submission from there.' class DataGroupViewSet(ModelViewSet): diff --git a/src/apps/api/views/leaderboards.py b/src/apps/api/views/leaderboards.py index 2b7ed2524..87753a3b4 100644 --- a/src/apps/api/views/leaderboards.py +++ b/src/apps/api/views/leaderboards.py @@ -1,7 +1,7 @@ -from rest_framework.permissions import IsAuthenticated from rest_framework.viewsets import ModelViewSet from rest_framework.response import Response -from api.permissions import LeaderboardNotHidden, LeaderboardIsOrganizerOrCollaborator +from rest_framework.status import HTTP_405_METHOD_NOT_ALLOWED +from api.permissions import LeaderboardNotHidden from api.serializers.leaderboards import LeaderboardEntriesSerializer from api.serializers.submissions import SubmissionScoreSerializer from leaderboards.models import Leaderboard, SubmissionScore @@ -10,24 +10,32 @@ class LeaderboardViewSet(ModelViewSet): queryset = Leaderboard.objects.all() serializer_class = LeaderboardEntriesSerializer + http_method_names = ['get'] # Only allow GET requests - # TODO: The retrieve and list actions are the only ones used, apparently. Delete other permission checks soon! - def get_permissions(self): - if self.action in ['update', 'partial_update', 'destroy']: - raise Exception('Unexpected code branch execution.') - self.permission_classes = [LeaderboardIsOrganizerOrCollaborator] - elif self.action in ['create']: - raise Exception('Unexpected code branch execution.') - self.permission_classes = [IsAuthenticated] - elif self.action in ['retrieve', 'list']: - self.permission_classes = [LeaderboardNotHidden] + def create(self, request, *args, **kwargs): + return Response({'detail': 'Method not allowed.'}, status=HTTP_405_METHOD_NOT_ALLOWED) - return [permission() for permission in self.permission_classes] + def update(self, request, *args, **kwargs): + return Response({'detail': 'Method not allowed.'}, status=HTTP_405_METHOD_NOT_ALLOWED) + + def partial_update(self, request, *args, **kwargs): + return Response({'detail': 'Method not allowed.'}, status=HTTP_405_METHOD_NOT_ALLOWED) + + def destroy(self, request, *args, **kwargs): + return Response({'detail': 'Method not allowed.'}, status=HTTP_405_METHOD_NOT_ALLOWED) def list(self, request, *args, **kwargs): # Return an empty list for the leaderboard-list endpoint return Response([]) + def get_permissions(self): + if self.action in ['create', 'update', 'partial_update', 'destroy']: + return [] # No permissions, effectively disables the action + elif self.action in ['retrieve', 'list']: + self.permission_classes = [LeaderboardNotHidden] + + return [permission() for permission in self.permission_classes] + class SubmissionScoreViewSet(ModelViewSet): queryset = SubmissionScore.objects.all() diff --git a/src/apps/api/views/profiles.py b/src/apps/api/views/profiles.py index d2068b4c0..7c7f6dcd8 100644 --- a/src/apps/api/views/profiles.py +++ b/src/apps/api/views/profiles.py @@ -4,7 +4,7 @@ from django.contrib.auth.decorators import login_required from django.db.models import Q from django.http import HttpResponse -from rest_framework.decorators import action +from rest_framework.decorators import action, api_view from rest_framework.exceptions import ValidationError, PermissionDenied from rest_framework.generics import GenericAPIView, RetrieveAPIView from rest_framework import permissions, mixins @@ -13,12 +13,14 @@ from rest_framework.response import Response from rest_framework import status from django.urls import reverse +from django.db import IntegrityError from api.permissions import IsUserAdminOrIsSelf, IsOrganizationEditor from api.serializers.profiles import MyProfileSerializer, UserSerializer, \ OrganizationSerializer, MembershipSerializer, SimpleOrganizationSerializer, DeleteMembershipSerializer from profiles.helpers import send_mail from profiles.models import Organization, Membership +from profiles.views import send_delete_account_confirmation_mail User = get_user_model() @@ -84,6 +86,27 @@ def _get_data(user): ) +@api_view(['DELETE']) +def delete_account(request): + # Check data + user = request.user + is_username_valid = user.username == request.data["username"] + is_password_valid = user.check_password(request.data["password"]) + + if is_username_valid and is_password_valid: + send_delete_account_confirmation_mail(request, user) + + return Response({ + "success": True, + "message": "A confirmation link has been sent to your email. Follow the instruction to finish the process" + }) + else: + return Response({ + "success": False, + "error": "Wrong username or password" + }) + + class OrganizationViewSet(mixins.CreateModelMixin, mixins.UpdateModelMixin, GenericViewSet): @@ -235,17 +258,24 @@ def delete_organization(self, request, pk=None): try: org = Organization.objects.get(id=pk) member = org.membership_set.get(user=request.user) - if member.group == Membership.OWNER: - org.delete() - return Response({ - "success": True, - "message": "Organization deleted!" - }) - else: + if member.group != Membership.OWNER: return Response({ "success": False, "message": "You do not have delete rights!" }) + + org.delete() + return Response({ + "success": True, + "message": "Organization deleted!" + }) + + except IntegrityError: + return Response({ + "success": False, + "message": "This organization cannot be deleted because it is associated with existing submissions. Please remove those submissions first." + }) + except Exception as e: return Response({ "success": False, diff --git a/src/apps/api/views/queues.py b/src/apps/api/views/queues.py index db96188c6..23e486e85 100644 --- a/src/apps/api/views/queues.py +++ b/src/apps/api/views/queues.py @@ -13,7 +13,7 @@ class QueueViewSet(ModelViewSet): queryset = Queue.objects.all() - serializer_class = serializers.QueueSerializer + serializer_class = serializers.QueueListSerializer filter_fields = ('owner', 'is_public', 'name') filter_backends = (DjangoFilterBackend, SearchFilter) search_fields = ('name',) @@ -29,7 +29,7 @@ def get_queryset(self): def get_serializer_class(self): if self.request.method == 'GET': - return serializers.QueueSerializer + return serializers.QueueListSerializer else: return serializers.QueueCreationSerializer diff --git a/src/apps/api/views/quota.py b/src/apps/api/views/quota.py index 869861576..4d9368e82 100644 --- a/src/apps/api/views/quota.py +++ b/src/apps/api/views/quota.py @@ -50,6 +50,13 @@ def user_quota_cleanup(request): }) +@api_view(["GET"]) +def user_quota(request): + quota = request.user.quota + storage_used = request.user.get_used_storage_space() + return Response({"quota": quota, "storage_used": storage_used}) + + @api_view(['DELETE']) def delete_unused_tasks(request): try: diff --git a/src/apps/api/views/submissions.py b/src/apps/api/views/submissions.py index 6ce9da84b..afe14fb36 100644 --- a/src/apps/api/views/submissions.py +++ b/src/apps/api/views/submissions.py @@ -6,7 +6,6 @@ from django_filters.rest_framework import DjangoFilterBackend from rest_framework import status from rest_framework.decorators import api_view, permission_classes, action -from django.http import Http404 from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.filters import SearchFilter from rest_framework.generics import get_object_or_404 @@ -16,6 +15,7 @@ from rest_framework.viewsets import ModelViewSet from rest_framework_csv import renderers from django.core.files.base import ContentFile +from django.http import StreamingHttpResponse from profiles.models import Organization, Membership from tasks.models import Task @@ -24,6 +24,7 @@ from leaderboards.strategies import put_on_leaderboard_by_submission_rule from leaderboards.models import SubmissionScore, Column, Leaderboard + logger = logging.getLogger() @@ -31,7 +32,7 @@ class SubmissionViewSet(ModelViewSet): queryset = Submission.objects.all().order_by('-pk') permission_classes = [] filter_backends = (DjangoFilterBackend, SearchFilter) - filter_fields = ('phase__competition', 'phase', 'status') + filter_fields = ('phase__competition', 'phase', 'status', 'is_soft_deleted') search_fields = ('data__data_file', 'description', 'name', 'owner__username') renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [renderers.CSVRenderer] @@ -68,9 +69,8 @@ def check_object_permissions(self, request, obj): # Set file name to ingestion std error as default error_file_name = "prediction_ingestion_stderr" - # Change error file name when error comes from execution time limit - # and error occured during scoring - if request.data["type"] == "Execution_Time_Limit_Exceeded" and request.data['is_scoring'] == "True": + # Change error file name to scoring_stderr when error occurs during scoring + if request.data.get("is_scoring", "False") == "True": error_file_name = "scoring_stderr" try: @@ -94,7 +94,7 @@ def check_object_permissions(self, request, obj): not_bot_user = self.request.user.is_authenticated and not self.request.user.is_bot - if self.action in ['update_fact_sheet', 're_run_submission']: + if self.action in ['update_fact_sheet', 'run_submission', 're_run_submission']: # get_queryset will stop us from re-running something we're not supposed to pass elif not self.request.user.is_authenticated or not_bot_user: @@ -119,6 +119,9 @@ def get_queryset(self): if not self.request.user.is_authenticated: return Submission.objects.none() + # Check if admin is requesting to see soft-deleted submissions + show_is_soft_deleted = self.request.query_params.get('show_is_soft_deleted', 'false').lower() == 'true' + if not self.request.user.is_superuser and not self.request.user.is_staff and not self.request.user.is_bot: # if you're the creator of the submission or a collaborator on the competition qs = qs.filter( @@ -126,6 +129,11 @@ def get_queryset(self): Q(phase__competition__created_by=self.request.user) | Q(phase__competition__collaborators__in=[self.request.user.pk]) ).distinct() + + # By default, exclude soft-deleted submissions unless explicitly requested by an admin + if not show_is_soft_deleted: + qs = qs.filter(is_soft_deleted=False) + qs = qs.select_related( 'phase', 'phase__competition', @@ -179,12 +187,41 @@ def destroy(self, request, *args, **kwargs): self.perform_destroy(submission) return Response(status=status.HTTP_204_NO_CONTENT) + @action(detail=True, methods=('DELETE',)) + def soft_delete(self, request, pk): + submission = self.get_object() + + # Check if submission exists + if not submission: + return Response({'error': 'Submission not found'}, status=status.HTTP_404_NOT_FOUND) + + # Check if owner is requesting soft delete + if submission.owner != request.user: + return Response({'error': 'You are not allowed to delete this submission'}, status=status.HTTP_403_FORBIDDEN) + + # Check if submission is finished and on the leaderboard + if submission.status == Submission.FINISHED and submission.on_leaderboard: + return Response({'error': 'You are not allowed to delete a leaderboard submission'}, status=status.HTTP_403_FORBIDDEN) + + # Check if submission is in running state + if submission.status not in [Submission.FAILED, Submission.FINISHED, Submission.CANCELLED]: + return Response({'error': 'You are not allowed to delete a running submission'}, status=status.HTTP_403_FORBIDDEN) + + # Check if submission is not already soft deleted + if submission.is_soft_deleted: + return Response({'error': 'Submission already deleted'}, status=status.HTTP_400_BAD_REQUEST) + + # soft delete submission and return success response + submission.soft_delete() + return Response({'message': 'Submission deleted successfully'}, status=status.HTTP_200_OK) + @action(detail=False, methods=('DELETE',)) def delete_many(self, request, *args, **kwargs): qs = self.get_queryset() if not qs: return Response({'Submission search returned empty'}, status=status.HTTP_404_NOT_FOUND) - qs.delete() + for submission in qs: + submission.delete() # This will trigger the model's `delete` method return Response({}) def get_renderer_context(self): @@ -207,14 +244,24 @@ def has_admin_permission(self, user, submission): @action(detail=True, methods=('POST', 'DELETE')) def submission_leaderboard_connection(self, request, pk): + + # get submission submission = self.get_object() + + # get submission phase phase = submission.phase - if not (request.user.is_superuser or request.user == submission.owner): - if not phase.competition.collaborators.filter(pk=request.user.pk).exists(): - raise Http404 + # only super user, owner of submission and competition organizer can proceed + if not ( + request.user.is_superuser or + request.user == submission.owner or + request.user in phase.competition.all_organizers + ): + raise PermissionDenied("You cannot perform this action, contact the competition organizer!") + + # only super user and with these leaderboard rules (FORCE_LAST, FORCE_BEST, FORCE_LATEST_MULTIPLE) can proceed if submission.phase.leaderboard.submission_rule in Leaderboard.AUTO_SUBMISSION_RULES and not request.user.is_superuser: - raise ValidationError("Users are not allowed to edit the leaderboard on this Competition") + raise PermissionDenied("Users are not allowed to edit the leaderboard on this Competition") if request.method == 'POST': # Removing any existing submissions on leaderboard unless multiples are allowed @@ -229,7 +276,7 @@ def submission_leaderboard_connection(self, request, pk): if request.method == 'DELETE': if submission.phase.leaderboard.submission_rule not in [Leaderboard.ADD_DELETE, Leaderboard.ADD_DELETE_MULTIPLE]: - raise ValidationError("You are not allowed to remove a submission on this phase") + raise PermissionDenied("You are not allowed to remove a submission on this phase") submission.leaderboard = None submission.save() Submission.objects.filter(parent=submission).update(leaderboard=None) @@ -247,6 +294,21 @@ def cancel_submission(self, request, pk): canceled = submission.cancel() return Response({'canceled': canceled}) + @action(detail=True, methods=('POST',)) + def run_submission(self, request, pk): + submission = self.get_object() + + # Only organizer of the competition can run the submission + if not self.has_admin_permission(request.user, submission): + raise PermissionDenied('You do not have permission to run this submission') + + # Allow only to run a submission with status `Submitting` + if submission.status != Submission.SUBMITTING: + raise PermissionDenied('Cannot run a submission which is not in submitting status') + + new_sub = submission.run() + return Response({'id': new_sub.id}) + @action(detail=True, methods=('POST',)) def re_run_submission(self, request, pk): submission = self.get_object() @@ -271,7 +333,14 @@ def re_run_submission(self, request, pk): rerun_kwargs = {} new_sub = submission.re_run(**rerun_kwargs) - return Response({'id': new_sub.id}) + if new_sub is None: + # return error + return Response({ + "error_msg": "You cannot rerun this submission because one or more tasks this submission was running are deleted, resubmit the submission or contact the competition organizer!"}, + status=status.HTTP_404_NOT_FOUND + ) + else: + return Response({'id': new_sub.id}) @action(detail=False, methods=('POST',)) def re_run_many_submissions(self, request): @@ -280,6 +349,53 @@ def re_run_many_submissions(self, request): submission.re_run() return Response({}) + @action(detail=False, methods=['get']) + def download_many(self, request): + """ + Download a ZIP containing several submissions. + """ + pks = request.query_params.get('pks') + if pks: + pks = json.loads(pks) # Convert JSON string to list + else: + return Response({"error": "`pks` query parameter is required"}, status=400) + + # Get submissions + submissions = Submission.objects.filter(pk__in=pks).select_related( + "owner", + "phase__competition", + "phase__competition__created_by", + ).prefetch_related("phase__competition__collaborators") + if submissions.count() != len(pks): + return Response({"error": "One or more submission IDs are invalid"}, status=404) + + # Check permissions + if not request.user.is_authenticated: + raise PermissionDenied("You must be logged in to download submissions") + # Allow admins + if request.user.is_superuser or request.user.is_staff: + allowed = True + else: + # Build one Q object for "owner OR organizer" + organiser_q = ( + Q(phase__competition__created_by=request.user) | + Q(phase__competition__collaborators=request.user) + ) + # Submissions that violate the rule + disallowed = submissions.exclude(Q(owner=request.user) | organiser_q) + allowed = not disallowed.exists() + if not allowed: + raise PermissionDenied( + "You do not have permission to download one or more of the requested submissions" + ) + + # Download + from competitions.tasks import stream_batch_download + in_memory_zip = stream_batch_download(pks) + response = StreamingHttpResponse(in_memory_zip, content_type='application/zip') + response['Content-Disposition'] = 'attachment; filename="bulk_submissions.zip"' + return response + @action(detail=True, methods=('GET',)) def get_details(self, request, pk): submission = super().get_object() @@ -295,14 +411,14 @@ def get_detail_result(self, request, pk): submission = Submission.objects.get(pk=pk) # Check if competition show visualization is true if submission.phase.competition.enable_detailed_results: - # get submission's competition participants - participants = submission.phase.competition.participants.all() - participant_usernames = [participant.user.username for participant in participants] + # get submission's competition approved participants + approved_participants = submission.phase.competition.participants.filter(status=CompetitionParticipant.APPROVED) + participant_usernames = [participant.user.username for participant in approved_participants] # check if in this competition # user is collaborator # or - # user is participant + # user is approved participant # or # user is creator # or @@ -322,15 +438,15 @@ def get_detail_result(self, request, pk): ) else: return Response({ - "error_msg": "Visualizations are disabled"}, + "error_msg": "Detailed results are disable for this competition!"}, status=status.HTTP_404_NOT_FOUND ) @action(detail=True, methods=('GET',)) def toggle_public(self, request, pk): submission = super().get_object() - if not self.has_admin_permission(request.user, submission): - raise PermissionDenied(f'You do not have permission to publish this submissions') + if not submission.phase.competition.can_participants_make_submissions_public: + raise PermissionDenied("You do not have permission to make this submissions public/private") is_public = not submission.is_public submission.data.is_public = is_public submission.data.save(send=False) diff --git a/src/apps/api/views/tasks.py b/src/apps/api/views/tasks.py index 2c1642448..31a3b027c 100644 --- a/src/apps/api/views/tasks.py +++ b/src/apps/api/views/tasks.py @@ -1,6 +1,10 @@ +import io +import yaml +import zipfile +from django.core.files.uploadedfile import InMemoryUploadedFile from collections import defaultdict - from django.db.models import Q, OuterRef, Subquery +from django.db import transaction from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -14,6 +18,8 @@ from competitions.models import Submission, Phase from profiles.models import User from tasks.models import Task +from datasets.models import Data +from utils.data import pretty_bytes, gb_to_bytes # TODO:// TaskViewSimple uses simple serializer from tasks, which exists purely for the use of Select2 on phase modal @@ -42,7 +48,11 @@ def get_queryset(self): ) task_filter = Q(created_by=self.request.user) | Q(shared_with=self.request.user) - if self.request.query_params.get('public'): + # when there is `public` in the query params, it means user has checked on the front-end + # the Show public tasks checkbox. + # When a user clicks that public task that may not belong to the user, we want to show + # the public task to the user and hence we check the `retrieve` action + if self.request.query_params.get('public') or self.action == 'retrieve': task_filter |= Q(is_public=True) qs = qs.filter(task_filter) @@ -84,10 +94,42 @@ def get_serializer_context(self): return context def update(self, request, *args, **kwargs): + + # Get task task = self.get_object() + + # Raise error if user is not the creator of the task or not a super user if request.user != task.created_by and not request.user.is_superuser: raise PermissionDenied("Cannot update a task that is not yours") - return super().update(request, *args, **kwargs) + + # Check if 'is_public' is sent in the data + # This means that from the front end the update is_public api is calle + # with `is_public` in the data + if 'is_public' in request.data: + # Perform the update using the parent class's update method + super().update(request, *args, **kwargs) + else: + # If the key is not in the request data, set the corresponding field to None + # No condition for scoring program because a task must have a scoring program + if "ingestion_program" not in request.data: + task.ingestion_program = None + if "input_data" not in request.data: + task.input_data = None + if "reference_data" not in request.data: + task.reference_data = None + + # Save the task to apply the changes + task.save() + super().update(request, *args, **kwargs) + + # Fetch the updated task from the database to ensure it reflects all changes + task.refresh_from_db() + + # Serialize the updated task using TaskDetailSerializer + task_detail_serializer = serializers.TaskSerializer(task) + + # Return the serialized data as a response + return Response(task_detail_serializer.data) def destroy(self, request, *args, **kwargs): instance = self.get_object() @@ -120,6 +162,194 @@ def delete_many(self, request): status=status.HTTP_400_BAD_REQUEST if errors else status.HTTP_200_OK ) + @action(detail=False, methods=('POST',)) + def upload_task(self, request): + + """ + This function is used to upload a task. To upload a task, a zip file is created from the components of the task: + - task.yaml (required) + - ingestion_program.zip (optional) + - scoring_program.zip (optional) + - input_data.zip (optional) + - reference_data.zip (optional) + + task.yaml has the following structure: + name: Task Name + description: Task Description + is_public: true/false + input_data: + key: Your dataset key + reference_data: + key: Your dataset key + scoring_program: + zip: scoring_program.zip + ingestion_program: + zip: ingestion_program.zip + + Note: + - You can upload a task.yaml file without any other files if you want to create a task from existing datasets/programs using keys + - You can use a mix of key and zip to upload a task e.g. to use already uploaded input data and reference data but upload new ingestion and scoring programs + - You can choose to upload all the datasets and programs without using the key + + """ + + # Access uploaded file + uploaded_file = request.FILES.get('file') + + # ----------- + # Check File + # ----------- + + # Check if a file is provided + if not uploaded_file: + return Response({"error": "No attached file found, please try again!"}, status=status.HTTP_400_BAD_REQUEST) + + # ------------ + # Check Quota + # ------------ + + # Check if user has enough quota to proceed + storage_used = float(request.user.get_used_storage_space()) + # User quota is in GB + quota = float(request.user.quota) + # Convert user quota to bytes + quota = gb_to_bytes(quota) + file_size = uploaded_file.size + if storage_used + file_size > quota: + file_size = pretty_bytes(file_size) + return Response({'error': "Insufficient space! Please free up some space and try again. You can manage your files in the Resources page."}, status=status.HTTP_507_INSUFFICIENT_STORAGE) + + # ---------------------- + # Process Task zip file + # ---------------------- + try: + # Process the zip file + with zipfile.ZipFile(uploaded_file, 'r') as zip_file: + + # ------------------ + # Process yaml file + # ------------------ + + # Check if 'task.yaml' exists + if 'task.yaml' not in zip_file.namelist(): + return Response({"error": "task.yaml not found in the zip file"}, status=status.HTTP_400_BAD_REQUEST) + + # Read the task.yaml file + with zip_file.open('task.yaml') as task_file: + try: + task_data = yaml.safe_load(task_file) + except yaml.YAMLError as e: + return Response({"error": f"Error parsing task.yaml: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) + + # ------------------ + # Yaml file checks + # ------------------ + + # Check if task has a name + if "name" not in task_data: + return Response({"error": f"Missing: name, task must have a name"}, status=status.HTTP_400_BAD_REQUEST) + + # Check if task has a description + if "description" not in task_data: + return Response({"error": f"Missing: description, task must have a description"}, status=status.HTTP_400_BAD_REQUEST) + + # Check if task has a scoring program + if Data.SCORING_PROGRAM not in task_data: + return Response({"error": f"Missing: scoring_program, task must have a scoring_program"}, status=status.HTTP_400_BAD_REQUEST) + + # ------------------------------ + # Process datasets and programs + # ------------------------------ + + # Begin atomic transaction to ensure rollback if any error occurs + with transaction.atomic(): + # Initialize task fields + task_kwargs = { + 'name': task_data.get('name'), + 'description': task_data.get('description'), + 'created_by': request.user, + 'is_public': task_data.get('is_public', False), + 'ingestion_only_during_scoring': task_data.get('ingestion_only_during_scoring', False), + } + + # Function to create or get dataset from either zip or key + # If both key and zip are present, key is used and zip is ignored + def create_or_get_data(data_type, data_info): + # Process dataset/program if data_info is not empty i.e. provided in the yaml file + if data_info: + key = data_info.get('key', None) + zip_name = data_info.get('zip', None) + + if key: + # Retrieve dataset by key if provided + try: + return Data.objects.get(key=key, created_by=request.user, type=data_type) + except Data.DoesNotExist: + raise ValueError(f"{data_type} with key '{key}' not found.") + elif zip_name: + # Check that the zip file exists in the main zip and create dataset + if zip_name not in zip_file.namelist(): + raise ValueError(f"Dataset file '{zip_name}' not found in the uploaded zip file.") + if not zip_name.endswith(".zip"): + raise ValueError(f"Dataset file '{zip_name}' should be a zip file.") + try: + # Createa a new dataset using the zip file for dataset/program + with zip_file.open(zip_name) as data_zip_file: + # Read file content + file_content = data_zip_file.read() + + # Get the file size in bytes + file_size = len(file_content) + + # Create a BytesIO object for the dataset file + data_file = InMemoryUploadedFile( + file=io.BytesIO(file_content), + field_name='data_file', + name=zip_name, + content_type='application/zip', + size=file_size, + charset=None + ) + # Create dataset + dataset = Data.objects.create( + name=zip_name, + created_by=request.user, + data_file=data_file, + type=data_type + ) + return dataset + except zipfile.BadZipFile: + raise ValueError(f"{zip_name} is not a valid ZIP file.") + except Exception as e: + raise ValueError(f"Error processing {zip_name}: {str(e)}") + + # For scoring program key or zip is required because task must have a scoring program + if data_type == Data.SCORING_PROGRAM: + raise ValueError(f"{data_type} must have either a key or zip") + else: + return None + + # Create datasets based on task.yaml contents + # Loop over all possible datasets and programs and create or get that dataset. + # If a dataset is not provided in the yaml, use None value for it + datasets_and_programs = [Data.INGESTION_PROGRAM, Data.SCORING_PROGRAM, Data.INPUT_DATA, Data.REFERENCE_DATA] + for dataset in datasets_and_programs: + task_kwargs[dataset] = create_or_get_data(data_type=dataset, data_info=task_data.get(dataset, {})) + + # Create the Task using the task kwrgs created from yaml and datasets/programs + task = Task.objects.create(**task_kwargs) + + # Return a success message + return Response({"message": f"Task '{task.name}' created successfully!"}, status=status.HTTP_201_CREATED) + + except ValueError as e: + # catch all value errors here + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + # catch all other unexpected errors here + return Response({"error": f"An error occurred while creating the task.\n {e}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + # This function allows for multiple errors when deleting multiple objects def check_delete_permissions(self, request, task): if request.user != task.created_by: diff --git a/src/apps/chahub/tests/test_chahub_mixin.py b/src/apps/chahub/tests/test_chahub_mixin.py index 75565c196..7b408e75b 100644 --- a/src/apps/chahub/tests/test_chahub_mixin.py +++ b/src/apps/chahub/tests/test_chahub_mixin.py @@ -21,6 +21,7 @@ def setUp(self): participant=self.participant, status='Finished', is_public=True, + leaderboard=None ) def test_submission_save_sends_to_chahub(self): @@ -41,22 +42,22 @@ def test_submission_save_sends_updated_data(self): resp2 = self.mock_chahub_save(self.submission) assert resp2.called - def test_invalid_submission_not_sent(self): - self.submission.status = "Running" - self.submission.is_public = False - resp1 = self.mock_chahub_save(self.submission) - assert not resp1.called - self.submission = Submission.objects.get(id=self.submission.id) - self.submission.status = "Finished" - resp2 = self.mock_chahub_save(self.submission) - assert resp2.called + # def test_invalid_submission_not_sent(self): + # self.submission.status = "Running" + # self.submission.is_public = False + # resp1 = self.mock_chahub_save(self.submission) + # assert not resp1.called + # self.submission = Submission.objects.get(id=self.submission.id) + # self.submission.status = "Finished" + # resp2 = self.mock_chahub_save(self.submission) + # assert resp2.called - def test_retrying_invalid_submission_wont_retry_again(self): - self.submission.status = "Running" - self.submission.chahub_needs_retry = True - resp = self.mock_chahub_save(self.submission) - assert not resp.called - assert not Submission.objects.get(id=self.submission.id).chahub_needs_retry + # def test_retrying_invalid_submission_wont_retry_again(self): + # self.submission.status = "Running" + # self.submission.chahub_needs_retry = True + # resp = self.mock_chahub_save(self.submission) + # assert not resp.called + # assert not Submission.objects.get(id=self.submission.id).chahub_needs_retry def test_valid_submission_marked_for_retry_sent_and_needs_retry_unset(self): # Mark submission for retry diff --git a/src/apps/competitions/admin.py b/src/apps/competitions/admin.py index edc2686d3..b991555da 100644 --- a/src/apps/competitions/admin.py +++ b/src/apps/competitions/admin.py @@ -3,7 +3,13 @@ from . import models -admin.site.register(models.Competition) +class CompetitionAdmin(admin.ModelAdmin): + search_fields = ['title', 'docker_image', 'created_by__username'] + list_display = ['id', 'title', 'created_by', 'is_featured'] + list_filter = ['is_featured'] + + +admin.site.register(models.Competition, CompetitionAdmin) admin.site.register(models.CompetitionCreationTaskStatus) admin.site.register(models.CompetitionParticipant) admin.site.register(models.Page) diff --git a/src/apps/competitions/emails.py b/src/apps/competitions/emails.py index 7c12a1fa3..016c2c1c6 100644 --- a/src/apps/competitions/emails.py +++ b/src/apps/competitions/emails.py @@ -2,10 +2,13 @@ def get_organizer_emails(competition): - return [user.email for user in competition.all_organizers] + return [user.email for user in competition.all_organizers if not user.is_deleted] def send_participation_requested_emails(participant): + if participant.user.is_deleted: + return + context = { 'participant': participant } @@ -29,6 +32,9 @@ def send_participation_requested_emails(participant): def send_participation_accepted_emails(participant): + if participant.user.is_deleted: + return + context = { 'participant': participant } @@ -50,6 +56,9 @@ def send_participation_accepted_emails(participant): def send_participation_denied_emails(participant): + if participant.user.is_deleted: + return + context = { 'participant': participant } @@ -72,6 +81,9 @@ def send_participation_denied_emails(participant): def send_direct_participant_email(participant, content): + if participant.user.is_deleted: + return + codalab_send_markdown_email( subject=f'A message from the admins of {participant.competition.title}', markdown_content=content, diff --git a/src/apps/competitions/migrations/0035_auto_20230914_1319.py b/src/apps/competitions/migrations/0035_auto_20230914_1319.py new file mode 100644 index 000000000..60e6cd96a --- /dev/null +++ b/src/apps/competitions/migrations/0035_auto_20230914_1319.py @@ -0,0 +1,33 @@ +# Generated by Django 2.2.17 on 2023-09-14 13:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0034_auto_20230727_1147'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='detailed_result_file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True), + ), + migrations.AddField( + model_name='submission', + name='prediction_result_file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True), + ), + migrations.AddField( + model_name='submission', + name='scoring_result_file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True), + ), + migrations.AddField( + model_name='submissiondetails', + name='file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True), + ), + ] diff --git a/src/apps/competitions/migrations/0040_auto_20231113_1103.py b/src/apps/competitions/migrations/0040_auto_20231113_1103.py new file mode 100644 index 000000000..2dc06eb14 --- /dev/null +++ b/src/apps/competitions/migrations/0040_auto_20231113_1103.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.17 on 2023-11-13 11:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0039_merge_20230906_1305'), + ] + + operations = [ + migrations.AlterField( + model_name='phase', + name='has_max_submissions', + field=models.BooleanField(default=True), + ), + ] diff --git a/src/apps/competitions/migrations/0040_competitionwhitelistemail.py b/src/apps/competitions/migrations/0040_competitionwhitelistemail.py new file mode 100644 index 000000000..5be18d55a --- /dev/null +++ b/src/apps/competitions/migrations/0040_competitionwhitelistemail.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.17 on 2023-11-12 14:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0039_merge_20230906_1305'), + ] + + operations = [ + migrations.CreateModel( + name='CompetitionWhiteListEmail', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=254)), + ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competitions.Competition')), + ], + ), + ] diff --git a/src/apps/competitions/migrations/0040_merge_20231123_1456.py b/src/apps/competitions/migrations/0040_merge_20231123_1456.py new file mode 100644 index 000000000..3172cbb95 --- /dev/null +++ b/src/apps/competitions/migrations/0040_merge_20231123_1456.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.17 on 2023-11-23 14:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0039_merge_20230906_1305'), + ('competitions', '0035_auto_20230914_1319'), + ] + + operations = [ + ] diff --git a/src/apps/competitions/migrations/0041_auto_20231112_1446.py b/src/apps/competitions/migrations/0041_auto_20231112_1446.py new file mode 100644 index 000000000..d65214d5f --- /dev/null +++ b/src/apps/competitions/migrations/0041_auto_20231112_1446.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.17 on 2023-11-12 14:46 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0040_competitionwhitelistemail'), + ] + + operations = [ + migrations.AlterField( + model_name='competitionwhitelistemail', + name='competition', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='whitelist_emails', to='competitions.Competition'), + ), + ] diff --git a/src/apps/competitions/migrations/0042_merge_20231120_1551.py b/src/apps/competitions/migrations/0042_merge_20231120_1551.py new file mode 100644 index 000000000..de243fa02 --- /dev/null +++ b/src/apps/competitions/migrations/0042_merge_20231120_1551.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.17 on 2023-11-20 15:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0041_auto_20231112_1446'), + ('competitions', '0040_auto_20231113_1103'), + ] + + operations = [ + ] diff --git a/src/apps/competitions/migrations/0043_merge_20231213_0948.py b/src/apps/competitions/migrations/0043_merge_20231213_0948.py new file mode 100644 index 000000000..c74629a95 --- /dev/null +++ b/src/apps/competitions/migrations/0043_merge_20231213_0948.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.17 on 2023-12-13 09:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0042_merge_20231120_1551'), + ('competitions', '0035_auto_20230914_1319'), + ] + + operations = [ + ] diff --git a/src/apps/competitions/migrations/0044_merge_20231221_1416.py b/src/apps/competitions/migrations/0044_merge_20231221_1416.py new file mode 100644 index 000000000..547c72b86 --- /dev/null +++ b/src/apps/competitions/migrations/0044_merge_20231221_1416.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.17 on 2023-12-21 14:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0040_merge_20231123_1456'), + ('competitions', '0043_merge_20231213_0948'), + ] + + operations = [ + ] diff --git a/src/apps/competitions/migrations/0045_auto_20240129_2314.py b/src/apps/competitions/migrations/0045_auto_20240129_2314.py new file mode 100644 index 000000000..2cb752724 --- /dev/null +++ b/src/apps/competitions/migrations/0045_auto_20240129_2314.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.17 on 2024-01-29 23:14 + +from django.db import migrations, models +import utils.data + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0044_merge_20231221_1416'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='logo_icon', + field=models.ImageField(blank=True, null=True, upload_to=utils.data.PathWrapper('logos', manual_override=True)), + ), + ] diff --git a/src/apps/competitions/migrations/0045_competition_auto_run_submissions.py b/src/apps/competitions/migrations/0045_competition_auto_run_submissions.py new file mode 100644 index 000000000..86161e98c --- /dev/null +++ b/src/apps/competitions/migrations/0045_competition_auto_run_submissions.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.17 on 2024-01-22 10:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0044_merge_20231221_1416'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='auto_run_submissions', + field=models.BooleanField(default=True), + ), + ] diff --git a/src/apps/competitions/migrations/0046_merge_20240222_1916.py b/src/apps/competitions/migrations/0046_merge_20240222_1916.py new file mode 100644 index 000000000..347e64bc9 --- /dev/null +++ b/src/apps/competitions/migrations/0046_merge_20240222_1916.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.17 on 2024-02-22 19:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0045_competition_auto_run_submissions'), + ('competitions', '0045_auto_20240129_2314'), + ] + + operations = [ + ] diff --git a/src/apps/competitions/migrations/0047_competition_can_participants_make_submissions_public.py b/src/apps/competitions/migrations/0047_competition_can_participants_make_submissions_public.py new file mode 100644 index 000000000..2b750fa02 --- /dev/null +++ b/src/apps/competitions/migrations/0047_competition_can_participants_make_submissions_public.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.17 on 2024-03-28 13:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0046_merge_20240222_1916'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='can_participants_make_submissions_public', + field=models.BooleanField(default=True), + ), + ] diff --git a/src/apps/competitions/migrations/0048_auto_20240401_1646.py b/src/apps/competitions/migrations/0048_auto_20240401_1646.py new file mode 100644 index 000000000..3ed2ad446 --- /dev/null +++ b/src/apps/competitions/migrations/0048_auto_20240401_1646.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.17 on 2024-04-01 16:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0047_competition_can_participants_make_submissions_public'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='show_detailed_results_in_leaderboard', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='competition', + name='show_detailed_results_in_submission_panel', + field=models.BooleanField(default=True), + ), + ] diff --git a/src/apps/competitions/migrations/0049_auto_20241118_1106.py b/src/apps/competitions/migrations/0049_auto_20241118_1106.py new file mode 100644 index 000000000..e7a9c0765 --- /dev/null +++ b/src/apps/competitions/migrations/0049_auto_20241118_1106.py @@ -0,0 +1,45 @@ +# Generated by Django 2.2.17 on 2024-11-18 11:06 + +from django.db import migrations, models +import storages.backends.s3boto3 +import utils.data + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0048_auto_20240401_1646'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='participants_count', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='competition', + name='submissions_count', + field=models.PositiveIntegerField(default=0), + ), + migrations.AlterField( + model_name='submission', + name='detailed_result', + field=models.FileField(blank=True, null=True, storage=storages.backends.s3boto3.S3Boto3Storage(), upload_to=utils.data.PathWrapper('detailed_result')), + ), + migrations.AlterField( + model_name='submission', + name='prediction_result', + field=models.FileField(blank=True, null=True, storage=storages.backends.s3boto3.S3Boto3Storage(), upload_to=utils.data.PathWrapper('prediction_result')), + ), + migrations.AlterField( + model_name='submission', + name='scoring_result', + field=models.FileField(blank=True, null=True, storage=storages.backends.s3boto3.S3Boto3Storage(), upload_to=utils.data.PathWrapper('scoring_result')), + ), + migrations.AlterField( + model_name='submissiondetails', + name='data_file', + field=models.FileField(storage=storages.backends.s3boto3.S3Boto3Storage(), upload_to=utils.data.PathWrapper('submission_details')), + ), + ] diff --git a/src/apps/competitions/migrations/0049_auto_20241121_0922.py b/src/apps/competitions/migrations/0049_auto_20241121_0922.py new file mode 100644 index 000000000..83a3c99a9 --- /dev/null +++ b/src/apps/competitions/migrations/0049_auto_20241121_0922.py @@ -0,0 +1,40 @@ +# Generated by Django 2.2.17 on 2024-11-21 09:22 + +from django.db import migrations, models +import storages.backends.s3boto3 +import utils.data + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0048_auto_20240401_1646'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='is_featured', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='submission', + name='detailed_result', + field=models.FileField(blank=True, null=True, storage=storages.backends.s3boto3.S3Boto3Storage(), upload_to=utils.data.PathWrapper('detailed_result')), + ), + migrations.AlterField( + model_name='submission', + name='prediction_result', + field=models.FileField(blank=True, null=True, storage=storages.backends.s3boto3.S3Boto3Storage(), upload_to=utils.data.PathWrapper('prediction_result')), + ), + migrations.AlterField( + model_name='submission', + name='scoring_result', + field=models.FileField(blank=True, null=True, storage=storages.backends.s3boto3.S3Boto3Storage(), upload_to=utils.data.PathWrapper('scoring_result')), + ), + migrations.AlterField( + model_name='submissiondetails', + name='data_file', + field=models.FileField(storage=storages.backends.s3boto3.S3Boto3Storage(), upload_to=utils.data.PathWrapper('submission_details')), + ), + ] diff --git a/src/apps/competitions/migrations/0050_auto_20241128_0814.py b/src/apps/competitions/migrations/0050_auto_20241128_0814.py new file mode 100644 index 000000000..f35847188 --- /dev/null +++ b/src/apps/competitions/migrations/0050_auto_20241128_0814.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2024-11-28 08:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0049_auto_20241118_1106'), + ] + + operations = [ + migrations.AlterField( + model_name='competition', + name='participants_count', + field=models.PositiveIntegerField(default=1), + ), + ] diff --git a/src/apps/competitions/migrations/0051_merge_20241203_1313.py b/src/apps/competitions/migrations/0051_merge_20241203_1313.py new file mode 100644 index 000000000..52b57e54c --- /dev/null +++ b/src/apps/competitions/migrations/0051_merge_20241203_1313.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.28 on 2024-12-03 13:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0050_auto_20241128_0814'), + ('competitions', '0049_auto_20241121_0922'), + ] + + operations = [ + ] diff --git a/src/apps/competitions/migrations/0052_auto_20250129_1058.py b/src/apps/competitions/migrations/0052_auto_20250129_1058.py new file mode 100644 index 000000000..dcdd25c8a --- /dev/null +++ b/src/apps/competitions/migrations/0052_auto_20250129_1058.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.28 on 2025-01-29 10:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0051_merge_20241203_1313'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='is_soft_deleted', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='submission', + name='soft_deleted_when', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AlterField( + model_name='submission', + name='data', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submission', to='datasets.Data'), + ), + ] diff --git a/src/apps/competitions/migrations/0053_auto_20250218_1151.py b/src/apps/competitions/migrations/0053_auto_20250218_1151.py new file mode 100644 index 000000000..c5562b388 --- /dev/null +++ b/src/apps/competitions/migrations/0053_auto_20250218_1151.py @@ -0,0 +1,28 @@ +# Generated by Django 2.2.28 on 2025-02-18 11:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0052_auto_20250129_1058'), + ] + + operations = [ + migrations.AlterField( + model_name='submission', + name='detailed_result_file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), + ), + migrations.AlterField( + model_name='submission', + name='prediction_result_file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), + ), + migrations.AlterField( + model_name='submission', + name='scoring_result_file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), + ), + ] diff --git a/src/apps/competitions/migrations/0053_competition_forum_enabled.py b/src/apps/competitions/migrations/0053_competition_forum_enabled.py new file mode 100644 index 000000000..6caa8279e --- /dev/null +++ b/src/apps/competitions/migrations/0053_competition_forum_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-03-10 14:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0052_auto_20250129_1058'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='forum_enabled', + field=models.BooleanField(default=True), + ), + ] diff --git a/src/apps/competitions/migrations/0054_auto_20250321_1341.py b/src/apps/competitions/migrations/0054_auto_20250321_1341.py new file mode 100644 index 000000000..e5b7a6451 --- /dev/null +++ b/src/apps/competitions/migrations/0054_auto_20250321_1341.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-03-21 13:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0053_auto_20250218_1151'), + ] + + operations = [ + migrations.AlterField( + model_name='submissiondetails', + name='file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), + ), + ] diff --git a/src/apps/competitions/migrations/0054_auto_20250324_0622.py b/src/apps/competitions/migrations/0054_auto_20250324_0622.py new file mode 100644 index 000000000..2389f396a --- /dev/null +++ b/src/apps/competitions/migrations/0054_auto_20250324_0622.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-03-24 06:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0053_auto_20250218_1151'), + ] + + operations = [ + migrations.AlterField( + model_name='submissiondetails', + name='file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), + ), + ] diff --git a/src/apps/competitions/migrations/0055_merge_20250324_0650.py b/src/apps/competitions/migrations/0055_merge_20250324_0650.py new file mode 100644 index 000000000..d65c1e302 --- /dev/null +++ b/src/apps/competitions/migrations/0055_merge_20250324_0650.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.28 on 2025-03-24 06:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0054_auto_20250324_0622'), + ('competitions', '0053_competition_forum_enabled'), + ] + + operations = [ + ] diff --git a/src/apps/competitions/migrations/0056_merge_20250324_2128.py b/src/apps/competitions/migrations/0056_merge_20250324_2128.py new file mode 100644 index 000000000..16554ffa0 --- /dev/null +++ b/src/apps/competitions/migrations/0056_merge_20250324_2128.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.28 on 2025-03-24 21:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0054_auto_20250321_1341'), + ('competitions', '0055_merge_20250324_0650'), + ] + + operations = [ + ] diff --git a/src/apps/competitions/migrations/0057_phase_hide_score_output.py b/src/apps/competitions/migrations/0057_phase_hide_score_output.py new file mode 100644 index 000000000..1f805e0f4 --- /dev/null +++ b/src/apps/competitions/migrations/0057_phase_hide_score_output.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-04-25 09:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0056_merge_20250324_2128'), + ] + + operations = [ + migrations.AddField( + model_name='phase', + name='hide_score_output', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/apps/competitions/migrations/0058_phase_hide_prediction_output.py b/src/apps/competitions/migrations/0058_phase_hide_prediction_output.py new file mode 100644 index 000000000..0c241ca2b --- /dev/null +++ b/src/apps/competitions/migrations/0058_phase_hide_prediction_output.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-05-14 19:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competitions', '0057_phase_hide_score_output'), + ] + + operations = [ + migrations.AddField( + model_name='phase', + name='hide_prediction_output', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index f139ce29b..97b29ef8b 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -1,20 +1,26 @@ import logging import uuid +import os +import io +import botocore.exceptions from django.conf import settings from django.contrib.sites.models import Site from django.contrib.postgres.fields import JSONField +from django.core.files.base import ContentFile from django.db import models from django.db.models import Q from django.urls import reverse from django.utils.timezone import now +from decimal import Decimal -from celery_config import app +from celery_config import app, app_for_vhost from chahub.models import ChaHubSaveMixin from leaderboards.models import SubmissionScore from profiles.models import User, Organization from utils.data import PathWrapper from utils.storage import BundleStorage +from PIL import Image from tasks.models import Task @@ -32,6 +38,7 @@ class Competition(ChaHubSaveMixin, models.Model): title = models.CharField(max_length=256) logo = models.ImageField(upload_to=PathWrapper('logos'), null=True, blank=True) + logo_icon = models.ImageField(upload_to=PathWrapper('logos', manual_override=True), null=True, blank=True) created_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="competitions") created_when = models.DateTimeField(default=now) @@ -44,6 +51,10 @@ class Competition(ChaHubSaveMixin, models.Model): description = models.TextField(null=True, blank=True) docker_image = models.CharField(max_length=128, default="codalab/codalab-legacy:py37") enable_detailed_results = models.BooleanField(default=False) + # If true, show detailed results in submission panel + show_detailed_results_in_submission_panel = models.BooleanField(default=True) + # If true, show detailed results in leaderboard + show_detailed_results_in_leaderboard = models.BooleanField(default=True) make_programs_available = models.BooleanField(default=False) make_input_data_available = models.BooleanField(default=False) @@ -60,6 +71,25 @@ class Competition(ChaHubSaveMixin, models.Model): reward = models.CharField(max_length=256, null=True, blank=True) report = models.CharField(max_length=256, null=True, blank=True) + # if true, submissions are auto-run when submitted + # if false, submissions run will be intiiated by organizer + auto_run_submissions = models.BooleanField(default=True) + + # If true, participants see the make their submissions public + can_participants_make_submissions_public = models.BooleanField(default=True) + + # If true, competition is featured and may show up on the home page + is_featured = models.BooleanField(default=False) + + # Count of submissions for this competition + submissions_count = models.PositiveIntegerField(default=0) + + # Count of participants in this competition (default = 1 because competition creator is also a participant) + participants_count = models.PositiveIntegerField(default=1) + + # If true, forum is enabled (default=True) + forum_enabled = models.BooleanField(default=True) + def __str__(self): return f"competition-{self.title}-{self.pk}-{self.competition_type}" @@ -112,10 +142,12 @@ def apply_phase_migration(self, current_phase, next_phase, force_migration=False self.is_migrating = True self.save() + # Get submissions of current phase with finished status and which are on leaderboard submissions = Submission.objects.filter( phase=current_phase, is_migrated=False, parent__isnull=True, + leaderboard__isnull=False, status=Submission.FINISHED ) @@ -214,8 +246,39 @@ def get_chahub_data(self): return self.clean_private_data(data) + def make_logo_icon(self): + if self.logo: + # Read the content of the logo file + self.logo.name + self.logo_icon + icon_dirname_only = os.path.dirname(self.logo.name) # Get just the path + icon_basename_only = os.path.basename(self.logo.name) # Get just the filename + file_name = os.path.splitext(icon_basename_only)[0] + ext = os.path.splitext(icon_basename_only)[1] + new_path = os.path.join(icon_dirname_only, f"{file_name}_icon{ext}") + logo_content = self.logo.read() + original_logo = Image.open(io.BytesIO(logo_content)) + # Resize the image to a smaller size for logo_icon + width, height = original_logo.size + new_width = 100 # Specify the desired width for the logo_icon + new_height = int((new_width / width) * height) + resized_logo = original_logo.resize((new_width, new_height)) + # Create a BytesIO object to save the resized image + icon_content = io.BytesIO() + resized_logo.save(icon_content, format='PNG') + # Save the resized logo as logo_icon + self.logo_icon.save(new_path, ContentFile(icon_content.getvalue()), save=False) + def save(self, *args, **kwargs): super().save(*args, **kwargs) + if not self.logo: + pass + elif not self.logo_icon: + self.make_logo_icon() + self.save() + elif os.path.dirname(self.logo.name) != os.path.dirname(self.logo_icon.name): + self.make_logo_icon() + self.save() to_create = User.objects.filter( Q(id=self.created_by_id) | Q(id__in=self.collaborators.all().values_list('id', flat=True)) ).exclude(id__in=self.participants.values_list('user_id', flat=True)).distinct() @@ -279,8 +342,10 @@ class Phase(ChaHubSaveMixin, models.Model): auto_migrate_to_this_phase = models.BooleanField(default=False) has_been_migrated = models.BooleanField(default=False) hide_output = models.BooleanField(default=False) + hide_prediction_output = models.BooleanField(default=False) + hide_score_output = models.BooleanField(default=False) - has_max_submissions = models.BooleanField(default=False) + has_max_submissions = models.BooleanField(default=True) max_submissions_per_day = models.PositiveIntegerField(default=5, null=True, blank=True) max_submissions_per_person = models.PositiveIntegerField(default=100, null=True, blank=True) @@ -313,7 +378,7 @@ def can_user_make_submissions(self, user): qs = self.submissions.filter(owner=user, parent__isnull=True).exclude(status='Failed') total_submission_count = qs.count() - daily_submission_count = qs.filter(created_when__day=now().day).count() + daily_submission_count = qs.filter(created_when__date=now().date()).count() if self.max_submissions_per_day: if daily_submission_count >= self.max_submissions_per_day: @@ -403,9 +468,25 @@ class SubmissionDetails(models.Model): ] name = models.CharField(max_length=50) data_file = models.FileField(upload_to=PathWrapper('submission_details'), storage=BundleStorage) + file_size = models.DecimalField(max_digits=15, decimal_places=2, null=True, blank=True) # in Bytes submission = models.ForeignKey('Submission', on_delete=models.CASCADE, related_name='details') is_scoring = models.BooleanField(default=False) + def save(self, *args, **kwargs): + if self.data_file and (not self.file_size or self.file_size == -1): + try: + # self.data_file.size returns bytes + self.file_size = self.data_file.size + except TypeError: + # -1 indicates an error + self.file_size = -1 + except botocore.exceptions.ClientError: + # file might not exist in the storage + logger.warning(f"The data_file of SubmissionDetails id={self.id} does not exist in the storage. data_file and file_size has been cleared") + self.file_size = Decimal(0) + self.data_file = None + return super().save(*args, **kwargs) + class Submission(ChaHubSaveMixin, models.Model): NONE = "None" @@ -437,7 +518,7 @@ class Submission(ChaHubSaveMixin, models.Model): status_details = models.TextField(null=True, blank=True) phase = models.ForeignKey(Phase, related_name='submissions', on_delete=models.CASCADE) appear_on_leaderboards = models.BooleanField(default=False) - data = models.ForeignKey("datasets.Data", on_delete=models.CASCADE, related_name='submission') + data = models.ForeignKey("datasets.Data", on_delete=models.SET_NULL, related_name='submission', null=True, blank=True) md5 = models.CharField(max_length=32, null=True, blank=True) prediction_result = models.FileField(upload_to=PathWrapper('prediction_result'), null=True, blank=True, @@ -447,6 +528,10 @@ class Submission(ChaHubSaveMixin, models.Model): detailed_result = models.FileField(upload_to=PathWrapper('detailed_result'), null=True, blank=True, storage=BundleStorage) + prediction_result_file_size = models.DecimalField(max_digits=15, decimal_places=2, null=True, blank=True) # in Bytes + scoring_result_file_size = models.DecimalField(max_digits=15, decimal_places=2, null=True, blank=True) # in Bytes + detailed_result_file_size = models.DecimalField(max_digits=15, decimal_places=2, null=True, blank=True) # in Bytes + secret = models.UUIDField(default=uuid.uuid4) celery_task_id = models.UUIDField(null=True, blank=True) task = models.ForeignKey(Task, on_delete=models.SET_NULL, null=True, blank=True, related_name="submissions") @@ -479,19 +564,75 @@ class Submission(ChaHubSaveMixin, models.Model): fact_sheet_answers = JSONField(null=True, blank=True, max_length=4096) + # True when submission owner deletes a submission + is_soft_deleted = models.BooleanField(default=False) + # DataTime of when a submission is soft_deleted + soft_deleted_when = models.DateTimeField(null=True, blank=True) + def __str__(self): return f"{self.phase.competition.title} submission PK={self.pk} by {self.owner.username}" + def soft_delete(self): + """ + Soft delete the submission: remove files but keep record in DB. + Also deletes associated SubmissionDetails and cleans up storage. + Also removes organization reference from the submission + """ + + # Remove related files from storage + # 'save=False' prevents a database save, which is handled later after marking the submission as soft-deleted. + self.prediction_result.delete(save=False) + self.prediction_result_file_size = 0 + self.scoring_result.delete(save=False) + self.scoring_result_file_size = 0 + self.detailed_result.delete(save=False) + self.detailed_result_file_size = 0 + + # Delete related SubmissionDetails files and records + for detail in self.details.all(): + detail.data_file.delete(save=False) # Delete file from storage + detail.delete() # Remove record from DB + + # Clear the data field if no other submissions are using it + other_submissions_using_data = Submission.objects.filter(data=self.data).exclude(pk=self.pk).exists() + if not other_submissions_using_data: + self.data.delete() + + # Clear the data field for this submission + self.data = None + + # Clear the organization field for this submission + self.organization = None + + # Mark submission as deleted + self.is_soft_deleted = True + self.soft_deleted_when = now() + self.save() + def delete(self, **kwargs): + + # Check if any other submissions are using the same data + other_submissions_using_data = Submission.objects.filter(data=self.data).exclude(pk=self.pk).exists() + + if not other_submissions_using_data: + # If no other submissions are using the same data, delete it + self.data.delete() + # Also clean up details on delete self.details.all().delete() - # Call this here so that the data_file for the submission also gets deleted from storage - self.data.delete() + + # Decrement the submissions_count for the competition on submission deletion + # Fetching competition from the phase of this submission + competition = self.phase.competition super().delete(**kwargs) + # Ensure submissions_count stays non-negative + if competition.submissions_count > 0: + competition.submissions_count -= 1 + competition.save() def save(self, ignore_submission_limit=False, **kwargs): - created = not self.pk - if created and not ignore_submission_limit: + is_new = self.pk is None + if is_new and not ignore_submission_limit: can_make_submission, reason_why_not = self.phase.can_user_make_submissions(self.owner) if not can_make_submission: raise PermissionError(reason_why_not) @@ -499,31 +640,91 @@ def save(self, ignore_submission_limit=False, **kwargs): if self.status == Submission.RUNNING and not self.started_when: self.started_when = now() + files_and_sizes_dict = { + 'prediction_result': 'prediction_result_file_size', + 'scoring_result': 'scoring_result_file_size', + 'detailed_result': 'detailed_result_file_size', + } + for file_path_attr, file_size_attr in files_and_sizes_dict.items(): + if getattr(self, file_path_attr) and (not getattr(self, file_size_attr) or getattr(self, file_size_attr) == -1): + try: + # self.data_file.size returns bytes + setattr(self, file_size_attr, getattr(self, file_path_attr).size) + except TypeError: + # -1 indicates an error + setattr(self, file_size_attr, Decimal(-1)) + except botocore.exceptions.ClientError: + # file might not exist in the storage + logger.warning(f"The {file_path_attr} of Submission id={self.id} does not exist in the storage. {file_path_attr} and {file_size_attr} has been cleared") + setattr(self, file_size_attr, Decimal(0)) + setattr(self, file_path_attr, None) + super().save(**kwargs) + # Only increment when a submission is parent (do not count child submissions) + if is_new and self.parent is None: + # Increment the submissions_count for the competition + self.phase.competition.submissions_count += 1 + self.phase.competition.save() + def start(self, tasks=None): from .tasks import run_submission run_submission(self.pk, tasks=tasks) + def run(self): + # get tasks from the phase + tasks = self.phase.tasks.all() + # start submission providing the tasks + self.start(tasks=tasks) + return self + def re_run(self, task=None): + + # task to use in the new submission + new_submission_task = task or self.task + + # set is_specific_task_re_run + is_specific_task_re_run = bool(task) + + flag_rerun_specific_task_or_has_no_children = False + # Check if this submission needs to rerun on specific children or has no children + if not self.has_children or is_specific_task_re_run: + flag_rerun_specific_task_or_has_no_children = True + + # Check if task exists in case of specific task rerun or no children + if flag_rerun_specific_task_or_has_no_children and new_submission_task is None: + logger.error(f"Cannot rerun `{self}` because the task is None (deleted)") + return None + else: + children_tasks = self.children.values_list('task', flat=True) + if None in children_tasks: + logger.error(f"Cannot rerun `{self}` because one or more children submission tasks are None (deleted)") + return None + + # Create a new submission submission_arg_dict = { 'owner': self.owner, - 'task': task or self.task, + 'task': new_submission_task, 'phase': self.phase, 'data': self.data, 'has_children': self.has_children, - 'is_specific_task_re_run': bool(task), + 'is_specific_task_re_run': is_specific_task_re_run, 'fact_sheet_answers': self.fact_sheet_answers, + 'queue': self.phase.competition.queue } sub = Submission(**submission_arg_dict) sub.save(ignore_submission_limit=True) - # No need to rerun on children if this is running on a specific task - if not self.has_children or sub.is_specific_task_re_run: - self.refresh_from_db() + # set tasks for rerunning + if flag_rerun_specific_task_or_has_no_children: + # in case of a submission with no children or specific task rerun + # submission with no children is same as submission with one task tasks = [sub.task] else: + # in case submission has multiple children or multiple task rerun + # tasks are gathered from the children submissions tasks = Task.objects.filter(pk__in=self.children.values_list('task', flat=True)) + sub.start(tasks=tasks) return sub @@ -532,7 +733,12 @@ def cancel(self, status=CANCELLED): if self.has_children: for sub in self.children.all(): sub.cancel(status=status) - app.control.revoke(self.celery_task_id, terminate=True) + celery_app = app + # If a custom queue is set, we need to fetch the appropriate celery app + if self.phase.competition.queue: + celery_app = app_for_vhost(str(self.phase.competition.queue.vhost)) + + celery_app.control.revoke(self.celery_task_id, terminate=True) self.status = status self.save() return True @@ -646,6 +852,23 @@ def get_chahub_data(self): } return self.clean_private_data(data) + def save(self, *args, **kwargs): + # Determine if this is a new participant (no existing record in DB) + is_new = self.pk is None + super().save(*args, **kwargs) + + if is_new: + # Increment the participants_count for the competition + self.competition.participants_count += 1 + self.competition.save() + + def delete(self, *args, **kwargs): + # Decrement the participants_count for the competition + competition = self.competition + super().delete(*args, **kwargs) + competition.participants_count -= 1 + competition.save() + class Page(models.Model): competition = models.ForeignKey(Competition, related_name='pages', on_delete=models.CASCADE) @@ -678,3 +901,15 @@ class CompetitionDump(models.Model): def __str__(self): return f"Comp dump created by {self.dataset.created_by} - {self.status}" + + +# Competition White List Email Model class +# related to Competition Model +# Each Competition can have multiple white list emails +# These are used to auto approve if competition white list has this email +class CompetitionWhiteListEmail(models.Model): + competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='whitelist_emails') + email = models.EmailField() + + def __str__(self): + return f"{self.email} - Competition: {self.competition.title}" diff --git a/src/apps/competitions/statistics.py b/src/apps/competitions/statistics.py new file mode 100644 index 000000000..4a06e0022 --- /dev/null +++ b/src/apps/competitions/statistics.py @@ -0,0 +1,251 @@ +""" +This script is created to compute two types of statistics: + 1. Overall platform statistics for a specified year + 2. Overall published competitions statistics + +Usage: + Bash into django console + ``` + docker compose exec django ./manage.py shell_plus + ``` + + For overall platform statistics + ``` + from competitions.statistics import create_codabench_statistics + create_codabench_statistics(year=2024) + + # if year is not specified, current year is used by default + # a csv file named codabench_statistics_2024.csv is generated in statistics folder (for year=2024) + ``` + + For overall published competitions statistics + ``` + from competitions.statistics import create_codabench_statistics_published_comps + create_codabench_statistics_published_comps() + + # a csv file named codabench_statistics_published_comps.csv is generated in statistics folder + ``` +""" + +# -------------------------------------------------- +# Imports +# -------------------------------------------------- +import os +from datetime import datetime +from competitions.models import Competition, Submission, CompetitionParticipant +from profiles.models import User + +# -------------------------------------------------- +# Setting constants +# -------------------------------------------------- +BASE_URL = "https://www.codabench.org/competitions/" +STATISTICS_DIR = "/app/statistics/" + + +def create_codabench_statistics(year=None): + """ + This function prepares a CSV file with different statistics per month + """ + + # Set year to current year if None + if year is None: + year = datetime.now().year + + # Create statistics directory if not already createad + if not os.path.exists(STATISTICS_DIR): + os.makedirs(STATISTICS_DIR) + + rows_dict = {} + + # Initialize sets for tracking total of users, participants and submissions + total_users = set() + total_participants = set() + total_submissions = set() + + # Loop over months + for month in range(1, 13): + + # count total competitions + tota_competitions_count = Competition.objects.filter(created_when__year=year, created_when__month=month).count() + rows_dict.setdefault("total_competitions", []).append(tota_competitions_count) + + # count public competitions + public_competitions_count = Competition.objects.filter(created_when__year=year, created_when__month=month, published=True).count() + rows_dict.setdefault("public_competitions", []).append(public_competitions_count) + + # count private competitions + private_competitions_count = Competition.objects.filter(created_when__year=year, created_when__month=month, published=False).count() + rows_dict.setdefault("private_competitions", []).append(private_competitions_count) + + # Count new users + new_users_count = User.objects.filter(date_joined__year=year, date_joined__month=month).count() + rows_dict.setdefault("new_users", []).append(new_users_count) + + # Count total users (including the current month) + new_user_ids = set(User.objects.filter(date_joined__year=year, date_joined__month=month).values_list('id', flat=True)) + total_users.update(new_user_ids) + rows_dict.setdefault("total_users", []).append(len(total_users)) + + # Count new participants + new_participants_count = CompetitionParticipant.objects.filter(competition__created_when__year=year, competition__created_when__month=month).count() + rows_dict.setdefault("new_participants", []).append(new_participants_count) + + # Count total participants (including the current month) + new_participants_ids = set(CompetitionParticipant.objects.filter(competition__created_when__year=year, competition__created_when__month=month).values_list('id', flat=True)) + total_participants.update(new_participants_ids) + rows_dict.setdefault("total_participants", []).append(len(total_participants)) + + # Count new submissions + new_submissions_count = Submission.objects.filter(created_when__year=year, created_when__month=month).count() + rows_dict.setdefault("new_submissions", []).append(new_submissions_count) + + # Submissions per day = total submissions/30 + submissions_per_day = 0 + if new_submissions_count > 0: + submissions_per_day = int(new_submissions_count / 30) + rows_dict.setdefault("submissions_per_day", []).append(submissions_per_day) + + # Count successful submissions (i.e., those that are finished) + successful_submissions = Submission.objects.filter(created_when__year=year, created_when__month=month, status=Submission.FINISHED).count() + rows_dict.setdefault("finished_submissions", []).append(successful_submissions) + + # Count failed submissions (i.e., those that are failed) + failed_submissions = Submission.objects.filter(created_when__year=year, created_when__month=month, status=Submission.FAILED).count() + rows_dict.setdefault("failed_submissions", []).append(failed_submissions) + + # Count total submissions (including the current month) + new_submissions_ids = set(Submission.objects.filter(created_when__year=year, created_when__month=month).values_list('id', flat=True)) + total_submissions.update(new_submissions_ids) + rows_dict.setdefault("total_submissions", []).append(len(total_submissions)) + + # Set CSV file and path + CSV_FILE_NAME = f"codabench_statistics_{year}.csv" + CSV_PATH = STATISTICS_DIR + CSV_FILE_NAME + + # Define month abbreviations + month_abbr = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + + # Open the CSV file in write mode + with open(CSV_PATH, 'w') as output_file: + # Write the header row only once if the file is empty + if output_file.tell() == 0: + header = f"{year}; " + "; ".join(month_abbr) + "; Total \n" + output_file.write(header) + + # Loop over each metric in the rows_dict and write the corresponding row + for metric, values in rows_dict.items(): + + # for total_users, total_participants, and total_submissions + # total is the last value + # for others total is the sum + if metric in ["total_users", "total_participants", "total_submissions"]: + total = values[-1] + else: + # Calculate the total for the metric (sum of all monthly counts) + total = sum(values) + + # Create a row with the metric name followed by the values for each month + row = f"{metric}; " + "; ".join(map(str, values)) + f"; {total} \n" + output_file.write(row) + + +def create_codabench_statistics_published_comps(): + """ + This function prepares a CSV file with all published competitions + """ + + # Set CSV file and path + CSV_FILE_NAME = "codabench_statistics_published_comps.csv" + CSV_PATH = STATISTICS_DIR + CSV_FILE_NAME + + # Create statistics directory if not already createad + if not os.path.exists(STATISTICS_DIR): + os.makedirs(STATISTICS_DIR) + + # Write header of the CSV file + with open(CSV_PATH, 'w', newline='') as output_file: + # Header for the csv + header = 'title; description; participants; submissions; year; phases; reward; duration (days); url;\n' + output_file.write(header) + + # loop over published competitions + for comp in Competition.objects.filter(published=True): + + # get title + title = comp.title + title = clean_string(title) + + # get description + desc = comp.description + desc = clean_string(desc) + + # get participants + num_participants = comp.participants.count() + + # get phases + phases = comp.phases.all() + num_phases = len(phases) + + # get submissions + num_submissions = 0 + for phase in phases: + num_submissions += phase.submissions.count() + + # get competition first phase year + year = phases[0].start.year + + # get competition start and end date + start_date = phases[0].start + end_date = phases[num_phases - 1].end + # if last phase has no end date, set end date to last phase start date + if end_date is None: + end_date = phases[num_phases - 1].start + + # compute duration of the competition + duration = (end_date - start_date).days + + # get reward + reward = comp.reward + # set reward to empty string if none + if reward is None: + reward = "" + else: + reward = clean_string(reward) + + # prepare competition url + url = f"{BASE_URL}{comp.id}" + + # prepare a row with all the computed information for one competition + row = '{}; {}; {}; {}; {}; {}; {}; {}; {}; \n'.format( + title, + desc, + num_participants, + num_submissions, + year, + num_phases, + reward, + duration, + url + ) + + # write row in the CSV file + with open(CSV_PATH, 'a') as output_file: + output_file.write(row) + + +def clean_string(text): + """ + This function cleans an input text + """ + if ";" in text: + text = text.replace(";", ",") + + if '\n' in text: + text = text.replace(r'\n', ' ') + + if '\r' in text: + text = text.replace(r'\r', ' ') + + text = ''.join(text.splitlines()) + + return text diff --git a/src/apps/competitions/submission_participant_counts.py b/src/apps/competitions/submission_participant_counts.py new file mode 100644 index 000000000..46c4895cc --- /dev/null +++ b/src/apps/competitions/submission_participant_counts.py @@ -0,0 +1,49 @@ +""" +This script is created to fill newly added fields in the competition modal with the correct data +The new fields are: + - submissions_count + - participants_count + +This script should be used only after the new changes are deployed on the server. + +Usage: + Bash into django console + ``` + docker compose exec django ./manage.py shell_plus + ``` + + Import and call the function + ``` + from competitions.submission_participant_counts import compute_submissions_participants_counts + compute_submissions_participants_counts() + ``` +""" +from competitions.models import Competition, CompetitionParticipant, Phase, Submission + + +def compute_submissions_participants_counts(): + """ + This function counts submissions and participants of competitions and updates all competitions + """ + competitions = Competition.objects.all() + + for competition in competitions: + # Count participants for the competition + participants_count = CompetitionParticipant.objects.filter(competition=competition).count() + + # Get all phases related to the competition + phases = Phase.objects.filter(competition=competition) + + # Count submissions across all phases of the competition + submissions_count = Submission.objects.filter(phase__in=phases, parent__isnull=True).count() + + # Update the competition fields + competition.participants_count = participants_count + competition.submissions_count = submissions_count + try: + competition.save() + except Exception as e: + print(f"Fail for competition {competition.pk}") + print(e) + + print(f"{len(competitions)} Competitions updated successfully!") diff --git a/src/apps/competitions/tasks.py b/src/apps/competitions/tasks.py index 87ad0d316..9b685d1d7 100644 --- a/src/apps/competitions/tasks.py +++ b/src/apps/competitions/tasks.py @@ -20,6 +20,10 @@ from django.utils.timezone import now from rest_framework.exceptions import ValidationError +from urllib.request import urlopen +from contextlib import closing +from urllib.error import ContentTooShortError + from celery_config import app from competitions.models import Submission, CompetitionCreationTaskStatus, SubmissionDetails, Competition, \ CompetitionDump, Phase @@ -36,11 +40,22 @@ COMPETITION_FIELDS = [ "title", + "description", "docker_image", "queue", - "description", "registration_auto_approve", - "enable_detailed_results" + "enable_detailed_results", + "show_detailed_results_in_submission_panel", + "show_detailed_results_in_leaderboard", + "auto_run_submissions", + "can_participants_make_submissions_public", + "make_programs_available", + "make_input_data_available", + "competition_type", + "reward", + "contact_email", + "fact_sheet", + "forum_enabled" ] TASK_FIELDS = [ @@ -67,6 +82,8 @@ 'execution_time_limit', 'auto_migrate_to_this_phase', 'hide_output', + 'hide_prediction_output', + 'hide_score_output', ] PHASE_FILES = [ "input_data", @@ -83,6 +100,7 @@ 'title', 'key', 'hidden', + 'submission_rule', # For later # 'force_submission_to_leaderboard', @@ -98,7 +116,9 @@ 'computation', 'computation_indexes', 'hidden', + 'precision', ] +MAX_EXECUTION_TIME_LIMIT = int(os.environ.get('MAX_EXECUTION_TIME_LIMIT', 600)) # time limit of the default queue def _send_to_compute_worker(submission, is_scoring): @@ -107,7 +127,7 @@ def _send_to_compute_worker(submission, is_scoring): "submissions_api_url": settings.SUBMISSIONS_API_URL, "secret": submission.secret, "docker_image": submission.phase.competition.docker_image, - "execution_time_limit": submission.phase.execution_time_limit, + "execution_time_limit": min(MAX_EXECUTION_TIME_LIMIT, submission.phase.execution_time_limit), "id": submission.pk, "is_scoring": is_scoring, } @@ -185,8 +205,9 @@ def _send_to_compute_worker(submission, is_scoring): time_padding = 60 * 20 # 20 minutes time_limit = submission.phase.execution_time_limit + time_padding - if submission.phase.competition.queue: + if submission.phase.competition.queue: # if the competition is running on a custom queue, not the default queue submission.queue_name = submission.phase.competition.queue.name or '' + run_args['execution_time_limit'] = submission.phase.execution_time_limit # use the competition time limit submission.save() # Send to special queue? Using `celery_app` var name here since we'd be overriding the imported `app` @@ -259,6 +280,49 @@ def send_child_id(submission, child_id): }) +def retrieve_data(url, data=None): + with closing(urlopen(url, data)) as fp: + headers = fp.info() + + bs = 1024 * 8 + size = -1 + read = 0 + if "content-length" in headers: + size = int(headers["Content-Length"]) + + while True: + block = fp.read(bs) + if not block: + break + read += len(block) + yield(block) + + if size >= 0 and read < size: + raise ContentTooShortError( + "retrieval incomplete: got only %i out of %i bytes" + % (read, size)) + + +def zip_generator(submission_pks): + in_memory_zip = BytesIO() + with zipfile.ZipFile(in_memory_zip, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for submission_id in submission_pks: + submission = Submission.objects.get(id=submission_id) + short_name = "ID_" + str(submission_id) + '_' + submission.data.data_file.name.split('/')[-1] + url = make_url_sassy(path=submission.data.data_file.name) + for block in retrieve_data(url): + zip_file.writestr(short_name, block) + + in_memory_zip.seek(0) + + return in_memory_zip + + +@app.task(queue='site-worker', soft_time_limit=60 * 60) +def stream_batch_download(submission_pks): + return zip_generator(submission_pks) + + @app.task(queue='site-worker', soft_time_limit=60) def _run_submission(submission_pk, task_pks=None, is_scoring=False): """This function is wrapped so that when we run tests we can run this function not @@ -406,6 +470,8 @@ def _get_error_string(error_dict): # call again, to make sure phases get sent to chahub competition.save() logger.info("Competition saved!") + status.dataset.name += f" - {competition.title}" + status.dataset.save() except CompetitionUnpackingException as e: # We want to catch well handled exceptions and display them to the user diff --git a/src/apps/competitions/tests/test_phase_migration.py b/src/apps/competitions/tests/test_phase_migration.py index 404c0fee5..f1b6e77a8 100644 --- a/src/apps/competitions/tests/test_phase_migration.py +++ b/src/apps/competitions/tests/test_phase_migration.py @@ -7,7 +7,7 @@ from competitions.models import Submission, Competition, Phase from competitions.tasks import do_phase_migrations from factories import UserFactory, CompetitionFactory, PhaseFactory, SubmissionFactory, CompetitionParticipantFactory, \ - TaskFactory + TaskFactory, LeaderboardFactory twenty_minutes_ago = now() - timedelta(hours=0, minutes=20) twenty_five_minutes_ago = now() - timedelta(hours=0, minutes=25) @@ -22,6 +22,7 @@ def setUp(self): self.competition = CompetitionFactory(created_by=self.owner, title="Competition One") self.competition_participant = CompetitionParticipantFactory(user=self.normal_user, competition=self.competition) + self.leaderboard = LeaderboardFactory() self.phase1 = PhaseFactory( competition=self.competition, auto_migrate_to_this_phase=False, @@ -59,6 +60,7 @@ def make_submission(self, **kwargs): kwargs.setdefault('participant', self.competition_participant) kwargs.setdefault('phase', self.phase1) kwargs.setdefault('status', Submission.FINISHED) + kwargs.setdefault('leaderboard', self.leaderboard) sub = SubmissionFactory(**kwargs) return sub diff --git a/src/apps/competitions/tests/test_submissions.py b/src/apps/competitions/tests/test_submissions.py index 4e58ebd93..a7ae024f2 100644 --- a/src/apps/competitions/tests/test_submissions.py +++ b/src/apps/competitions/tests/test_submissions.py @@ -155,7 +155,21 @@ def test_only_owner_can_add_submission_to_leaderboard(self): self.client.force_login(different_user) url = reverse('submission-submission-leaderboard-connection', kwargs={'pk': parent_sub.pk}) resp = self.client.post(url) - assert resp.status_code == 404 + assert resp.status_code == 403 + assert resp.data["detail"] == "You cannot perform this action, contact the competition organizer!" + + def test_only_owner_can_remove_submission_from_leaderboard(self): + parent_sub = SubmissionFactory(has_children=True) + leaderboard = LeaderboardFactory() + parent_sub.phase.leaderboard = leaderboard + parent_sub.phase.save() + + different_user = UserFactory() + self.client.force_login(different_user) + url = reverse('submission-submission-leaderboard-connection', kwargs={'pk': parent_sub.pk}) + resp = self.client.delete(url) + assert resp.status_code == 403 + assert resp.data["detail"] == "You cannot perform this action, contact the competition organizer!" def test_adding_submission_removes_other_submissions_from_owner(self): leaderboard = LeaderboardFactory() diff --git a/src/apps/competitions/tests/unpacker_test_data.py b/src/apps/competitions/tests/unpacker_test_data.py index db69ec6dd..b2ee7e075 100644 --- a/src/apps/competitions/tests/unpacker_test_data.py +++ b/src/apps/competitions/tests/unpacker_test_data.py @@ -213,6 +213,9 @@ 'starting_kit': None, 'tasks': [0], 'status': 'Previous', + 'hide_output': False, + 'hide_prediction_output': False, + 'hide_score_output': False, }, { 'index': 1, @@ -230,14 +233,12 @@ 'tasks': [1], 'status': 'Current', 'is_final_phase': True, + 'hide_output': False, + 'hide_prediction_output': False, + 'hide_score_output': False, } ] -V2_SPECIFIC_PHASE_DATA = [ - # Tuples of (key, value) of data specific to v2 unpacker. - ('hide_output', False) -] - def get_phases(version): if version == 1: @@ -246,9 +247,6 @@ def get_phases(version): # Make a copy of the list so we aren't mutating the original phases object. May not be strictly necessary, # but if we ever write a test comparing v1 to v2 or something, this would avoid bugs. v2 = [{k: v for k, v in phase.items()} for phase in PHASES] - for phase in v2: - for key, value in V2_SPECIFIC_PHASE_DATA: - phase[key] = value return v2 diff --git a/src/apps/competitions/unpackers/v1.py b/src/apps/competitions/unpackers/v1.py index 6802002ac..535e9e2d5 100644 --- a/src/apps/competitions/unpackers/v1.py +++ b/src/apps/competitions/unpackers/v1.py @@ -23,6 +23,9 @@ def __init__(self, *args, **kwargs): "description": self.competition_yaml.get("description", ""), "docker_image": docker_image, "enable_detailed_results": self.competition_yaml.get('enable_detailed_results', False), + "show_detailed_results_in_submission_panel": self.competition_yaml.get('show_detailed_results_in_submission_panel', True), + "show_detailed_results_in_leaderboard": self.competition_yaml.get('show_detailed_results_in_leaderboard', True), + "auto_run_submissions": self.competition_yaml.get('auto_run_submissions', True), "make_programs_available": self.competition_yaml.get('make_programs_available', False), "make_input_data_available": self.competition_yaml.get('make_input_data_available', False), "end_date": self.competition_yaml.get('end_date', None), @@ -85,6 +88,9 @@ def _unpack_phases(self): 'max_submissions_per_day': phase.get('max_submissions_per_day', 5), 'max_submissions_per_person': phase.get('max_submissions', 100), 'auto_migrate_to_this_phase': phase.get('auto_migration', False), + 'hide_output': phase.get('hide_output', False), + 'hide_prediction_output': phase.get('hide_prediction_output', False), + 'hide_score_output': phase.get('hide_score_output', False), } execution_time_limit = phase.get('execution_time_limit') if execution_time_limit: diff --git a/src/apps/competitions/unpackers/v2.py b/src/apps/competitions/unpackers/v2.py index b3c87b2f4..508479eba 100644 --- a/src/apps/competitions/unpackers/v2.py +++ b/src/apps/competitions/unpackers/v2.py @@ -14,6 +14,10 @@ def __init__(self, *args, **kwargs): "registration_auto_approve": self.competition_yaml.get('registration_auto_approve', False), "docker_image": self.competition_yaml.get('docker_image', 'codalab/codalab-legacy:py37'), "enable_detailed_results": self.competition_yaml.get('enable_detailed_results', False), + "show_detailed_results_in_submission_panel": self.competition_yaml.get('show_detailed_results_in_submission_panel', True), + "show_detailed_results_in_leaderboard": self.competition_yaml.get('show_detailed_results_in_leaderboard', True), + "auto_run_submissions": self.competition_yaml.get('auto_run_submissions', True), + "can_participants_make_submissions_public": self.competition_yaml.get('can_participants_make_submissions_public', True), "make_programs_available": self.competition_yaml.get('make_programs_available', False), "make_input_data_available": self.competition_yaml.get('make_input_data_available', False), "description": self.competition_yaml.get("description", ""), @@ -21,6 +25,7 @@ def __init__(self, *args, **kwargs): "fact_sheet": self.competition_yaml.get("fact_sheet", None), "reward": self.competition_yaml.get("reward", None), "contact_email": self.competition_yaml.get("contact_email", None), + "forum_enabled": self.competition_yaml.get("forum_enabled", True), "pages": [], "phases": [], "leaderboards": [], @@ -193,6 +198,8 @@ def _unpack_phases(self): 'max_submissions_per_person': phase_data.get('max_submissions', 100), 'auto_migrate_to_this_phase': phase_data.get('auto_migrate_to_this_phase', False), 'hide_output': phase_data.get('hide_output', False), + 'hide_prediction_output': phase_data.get('hide_prediction_output', False), + 'hide_score_output': phase_data.get('hide_score_output', False), } try: new_phase['tasks'] = phase_data['tasks'] diff --git a/src/apps/competitions/utils.py b/src/apps/competitions/utils.py index 54998b608..a10174fca 100644 --- a/src/apps/competitions/utils.py +++ b/src/apps/competitions/utils.py @@ -3,22 +3,20 @@ ''' import random -from django.db.models import Count - from competitions.models import Competition def get_popular_competitions(limit=4): - ''' + """ Function to return most popular competitions based on the amount of participants. :param limit: Amount of competitions to return. Default is 3. :rtype: list :return: Most popular competitions. - ''' + """ + competitions = Competition.objects.filter(published=True) \ - .annotate(participant_count=Count('participants')) \ - .order_by('-participant_count') + .order_by('-is_featured', '-participants_count') if len(competitions) <= limit: return competitions @@ -26,23 +24,22 @@ def get_popular_competitions(limit=4): return competitions[:limit] -def get_featured_competitions(limit=4, excluded_competitions=None): - ''' - Function to return featured competitions if they are still open. +def get_recent_competitions(exclude_comps=None, limit=4, random_limit=8): + """ + Function to return recent competitions, excluding given and featured competitions. - :param limit: Amount of competitions to return. Default is 3 - :param excluded_competitions: list of popular competitions to prevent displaying duplicates + :param limit: Amount of competitions to return. Default is 4. + :param random_limit: Limit of recent competitions to take for randomization. Must be greater than `limit`. + :param exclude_comps: A queryset or list of competitions to exclude. :rtype: list - :return: list of featured competitions - ''' - - competitions = Competition.objects.filter(published=True) \ - .annotate(participant_count=Count('participants')) - - if excluded_competitions: - competitions = competitions.exclude(pk__in=[c.pk for c in excluded_competitions]) + :return: List of featured competitions. + """ + exclude_ids = [comp.id for comp in exclude_comps] if exclude_comps else [] + competitions = Competition.objects.filter(published=True, is_featured=False) \ + .exclude(id__in=exclude_ids) \ + .order_by('-created_when') if len(competitions) <= limit: return competitions else: - return random.sample(list(competitions), limit) + return random.sample(list(competitions)[:random_limit], limit) diff --git a/src/apps/datasets/migrations/0008_auto_20241118_1106.py b/src/apps/datasets/migrations/0008_auto_20241118_1106.py new file mode 100644 index 000000000..f708ada86 --- /dev/null +++ b/src/apps/datasets/migrations/0008_auto_20241118_1106.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.17 on 2024-11-18 11:06 + +from django.db import migrations, models +import storages.backends.s3boto3 +import utils.data + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasets', '0007_auto_20230609_1738'), + ] + + operations = [ + migrations.AlterField( + model_name='data', + name='data_file', + field=models.FileField(blank=True, null=True, storage=storages.backends.s3boto3.S3Boto3Storage(), upload_to=utils.data.PathWrapper('dataset')), + ), + ] diff --git a/src/apps/datasets/migrations/0008_auto_20241121_0922.py b/src/apps/datasets/migrations/0008_auto_20241121_0922.py new file mode 100644 index 000000000..e79d81c57 --- /dev/null +++ b/src/apps/datasets/migrations/0008_auto_20241121_0922.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.17 on 2024-11-21 09:22 + +from django.db import migrations, models +import storages.backends.s3boto3 +import utils.data + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasets', '0007_auto_20230609_1738'), + ] + + operations = [ + migrations.AlterField( + model_name='data', + name='data_file', + field=models.FileField(blank=True, null=True, storage=storages.backends.s3boto3.S3Boto3Storage(), upload_to=utils.data.PathWrapper('dataset')), + ), + ] diff --git a/src/apps/datasets/migrations/0009_merge_20241203_1313.py b/src/apps/datasets/migrations/0009_merge_20241203_1313.py new file mode 100644 index 000000000..8094aaba1 --- /dev/null +++ b/src/apps/datasets/migrations/0009_merge_20241203_1313.py @@ -0,0 +1,14 @@ +# Generated by Django 2.2.28 on 2024-12-03 13:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasets', '0008_auto_20241118_1106'), + ('datasets', '0008_auto_20241121_0922'), + ] + + operations = [ + ] diff --git a/src/apps/datasets/migrations/0010_auto_20250218_1100.py b/src/apps/datasets/migrations/0010_auto_20250218_1100.py new file mode 100644 index 000000000..3f2b30289 --- /dev/null +++ b/src/apps/datasets/migrations/0010_auto_20250218_1100.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-02-18 11:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('datasets', '0009_merge_20241203_1313'), + ] + + operations = [ + migrations.AlterField( + model_name='data', + name='file_size', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=15, null=True), + ), + ] diff --git a/src/apps/datasets/models.py b/src/apps/datasets/models.py index 48d50f3f3..67edb7343 100644 --- a/src/apps/datasets/models.py +++ b/src/apps/datasets/models.py @@ -1,11 +1,15 @@ import uuid +import botocore +import logging +import botocore.exceptions from django.conf import settings from django.contrib.sites.models import Site from django.db import models from django.db.models import Q from django.urls import reverse from django.utils.timezone import now +from decimal import Decimal from chahub.models import ChaHubSaveMixin from utils.data import PathWrapper @@ -13,6 +17,9 @@ from competitions.models import Competition +logger = logging.getLogger() + + class Data(ChaHubSaveMixin, models.Model): """Data models are unqiue based on name + created_by. If no name is given, then there is no uniqueness to enforce""" @@ -52,7 +59,7 @@ class Data(ChaHubSaveMixin, models.Model): key = models.UUIDField(default=uuid.uuid4, blank=True, unique=True) is_public = models.BooleanField(default=False) upload_completed_successfully = models.BooleanField(default=False) - file_size = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) + file_size = models.DecimalField(max_digits=15, decimal_places=2, null=True, blank=True) # in Bytes # This is true if the Data model was created as part of unpacking a competition. Competition bundles themselves # are NOT marked True, since they are not created by unpacking! @@ -65,13 +72,20 @@ def get_download_url(self): return reverse('datasets:download', kwargs={'key': self.key}) def save(self, *args, **kwargs): - if not self.file_size and self.data_file: + if self.data_file and (not self.file_size or self.file_size == -1): try: - # save file size as kbs - self.file_size = self.data_file.size / 1024 + # save file size in bytes + # self.data_file.size returns bytes + self.file_size = self.data_file.size except TypeError: - # file returns a None size, can't divide None / 1024 - self.file_size = 0 + # -1 indicates an error + self.file_size = Decimal(-1) + except botocore.exceptions.ClientError: + # file might not exist in the storage + logger.warning(f"The data_file of Data id={self.id} does not exist in the storage. data_file and file_size has been cleared") + self.file_size = Decimal(0) + self.data_file = None + if not self.name: self.name = f"{self.created_by.username} - {self.type}" return super().save(*args, **kwargs) @@ -87,7 +101,9 @@ def in_use(self): Q(phases__tasks__ingestion_program=self) | Q(phases__tasks__input_data=self) | Q(phases__tasks__reference_data=self) | - Q(phases__tasks__scoring_program=self) + Q(phases__tasks__scoring_program=self) | + Q(phases__starting_kit=self) | + Q(phases__public_data=self) ).values('pk', 'title').distinct() return competitions_in_use diff --git a/src/apps/forums/views.py b/src/apps/forums/views.py index 6525255f3..5896ed8d5 100644 --- a/src/apps/forums/views.py +++ b/src/apps/forums/views.py @@ -1,16 +1,18 @@ import datetime +from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect, Http404 -from django.shortcuts import get_object_or_404 +from django.shortcuts import get_object_or_404, redirect from django.utils.timezone import now from django.views.generic import DetailView, CreateView, DeleteView from .forms import PostForm, ThreadForm from .models import Forum, Thread, Post +from competitions.models import CompetitionParticipant User = get_user_model() @@ -24,18 +26,38 @@ class ForumBaseMixin(object): def dispatch(self, *args, **kwargs): # Get object early so we can access it in multiple places self.forum = get_object_or_404(Forum, pk=self.kwargs['forum_pk']) + + if not self.forum.competition.forum_enabled: + messages.error(self.request, "The forum for this competition is disabled.") + return redirect("competitions:detail", pk=self.forum.competition.pk) + if 'thread_pk' in self.kwargs: self.thread = get_object_or_404(Thread, pk=self.kwargs['thread_pk']) + + # Determine if the user is a participant and store it as an instance variable + self.is_participant = self.is_user_participant(self.request.user, self.forum) + return super().dispatch(*args, **kwargs) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['forum'] = self.forum context['thread'] = self.thread if hasattr(self, 'thread') else None + context['is_participant'] = self.is_participant return context + def is_user_participant(self, user, forum): + is_participant = False + if user.is_authenticated: + is_participant = CompetitionParticipant.objects.filter( + competition=forum.competition, + user=user, + status=CompetitionParticipant.APPROVED + ).exists() + return is_participant -class ForumDetailView(DetailView): + +class ForumDetailView(ForumBaseMixin, DetailView): """ Shows the details of a particular Forum. """ @@ -45,9 +67,15 @@ class ForumDetailView(DetailView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context['thread_list_sorted'] = self.object.threads.order_by('pinned_date', '-date_created')\ - .select_related('forum', 'forum__competition', 'forum__competition__created_by', 'started_by')\ - .prefetch_related('forum__competition__collaborators', 'posts') + + context['thread_list_sorted'] = self.object.threads.order_by( + 'pinned_date', '-date_created' + ).select_related( + 'forum', 'forum__competition', 'forum__competition__created_by', 'started_by' + ).prefetch_related( + 'forum__competition__collaborators', 'posts' + ) + return context @@ -66,6 +94,12 @@ class CreatePostView(ForumBaseMixin, RedirectToThreadMixin, LoginRequiredMixin, form_class = PostForm def form_valid(self, form): + + if not self.is_participant: + messages.error(self.request, "You must be a participant of this competition to create a post.") + return redirect("forums:forum_thread_detail", forum_pk=self.forum.pk, thread_pk=self.thread.pk) + + # Create the post since the user is a participant self.post = form.save(commit=False) self.post.thread = self.thread self.post.posted_by = self.request.user @@ -84,9 +118,9 @@ class DeletePostView(ForumBaseMixin, LoginRequiredMixin, DeleteView): def delete(self, request, *args, **kwargs): self.object = self.get_object() - if self.object.thread.forum.competition.created_by == request.user or \ - self.object.posted_by == request.user: - + if self.object.posted_by == request.user or \ + request.user in self.object.thread.forum.competition.collaborators.all() or \ + self.object.thread.forum.competition.created_by == request.user: # If there are more posts in the thread, leave it around, otherwise delete it if self.object.thread.posts.count() == 1: success_url = self.object.thread.forum.get_absolute_url() @@ -106,6 +140,13 @@ class CreateThreadView(ForumBaseMixin, RedirectToThreadMixin, LoginRequiredMixin form_class = ThreadForm def form_valid(self, form): + + if not self.is_participant: + messages.error(self.request, "You must be a participant of this competition to create a thread.") + return redirect("forums:forum_detail", forum_pk=self.forum.pk) + + # Create the thread since the user is a participant + self.thread = form.save(commit=False) self.thread = form.save(commit=False) self.thread.forum = self.forum self.thread.started_by = self.request.user @@ -146,9 +187,19 @@ class ThreadDetailView(ForumBaseMixin, DetailView): def get_context_data(self, **kwargs): thread = self.object context = super().get_context_data(**kwargs) - context['ordered_posts'] = thread.posts.all().order_by('date_created')\ + ordered_posts = thread.posts.all().order_by('date_created')\ .select_related('thread__forum__competition__created_by', 'posted_by')\ .prefetch_related('thread__forum__competition__collaborators') + + # Check if request.user has admin permissions + for post in ordered_posts: + post.user_is_admin = ( + self.request.user == post.posted_by or + self.request.user == post.thread.forum.competition.created_by or + self.request.user in post.thread.forum.competition.collaborators.all() + ) + + context['ordered_posts'] = ordered_posts return context diff --git a/src/apps/oidc_configurations/__init__.py b/src/apps/oidc_configurations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/oidc_configurations/admin.py b/src/apps/oidc_configurations/admin.py new file mode 100644 index 000000000..5ea6e683f --- /dev/null +++ b/src/apps/oidc_configurations/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from .models import Auth_Organization + +admin.site.register(Auth_Organization) + +# Register your models here. diff --git a/src/apps/oidc_configurations/apps.py b/src/apps/oidc_configurations/apps.py new file mode 100644 index 000000000..3d757062b --- /dev/null +++ b/src/apps/oidc_configurations/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OidcConfigurationsConfig(AppConfig): + name = 'oidc_configurations' diff --git a/src/apps/oidc_configurations/migrations/0001_initial.py b/src/apps/oidc_configurations/migrations/0001_initial.py new file mode 100644 index 000000000..085e64983 --- /dev/null +++ b/src/apps/oidc_configurations/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.17 on 2024-03-04 06:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Auth_Organization', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('client_id', models.CharField(max_length=255)), + ('client_secret', models.CharField(max_length=255)), + ('authorization_url', models.CharField(max_length=255)), + ('token_url', models.CharField(max_length=255)), + ('user_info_url', models.CharField(max_length=255)), + ('redirect_url', models.CharField(max_length=255)), + ('button_bg_color', models.CharField(default='#2C3E4C', max_length=20)), + ('button_text_color', models.CharField(default='#FFFFFF', max_length=20)), + ], + ), + ] diff --git a/src/apps/oidc_configurations/migrations/__init__.py b/src/apps/oidc_configurations/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/apps/oidc_configurations/models.py b/src/apps/oidc_configurations/models.py new file mode 100644 index 000000000..9e2b0c66c --- /dev/null +++ b/src/apps/oidc_configurations/models.py @@ -0,0 +1,14 @@ +# oidc_configurations/models.py +from django.db import models + + +class Auth_Organization(models.Model): + name = models.CharField(max_length=255) + client_id = models.CharField(max_length=255) + client_secret = models.CharField(max_length=255) + authorization_url = models.CharField(max_length=255) + token_url = models.CharField(max_length=255) + user_info_url = models.CharField(max_length=255) + redirect_url = models.CharField(max_length=255) + button_bg_color = models.CharField(max_length=20, default='#2C3E4C') + button_text_color = models.CharField(max_length=20, default='#FFFFFF') diff --git a/src/apps/oidc_configurations/urls.py b/src/apps/oidc_configurations/urls.py new file mode 100644 index 000000000..7bfae4f99 --- /dev/null +++ b/src/apps/oidc_configurations/urls.py @@ -0,0 +1,10 @@ +# oidc_configurations/urls.py +from django.urls import path +from .views import organization_oidc_login, oidc_complete + +app_name = 'oidc_configurations' + +urlpatterns = [ + path('organization_oidc_login/', organization_oidc_login, name='organization_oidc_login'), + path('complete//', oidc_complete, name='oidc_complete'), +] diff --git a/src/apps/oidc_configurations/views.py b/src/apps/oidc_configurations/views.py new file mode 100644 index 000000000..6b04be8ff --- /dev/null +++ b/src/apps/oidc_configurations/views.py @@ -0,0 +1,203 @@ +# oidc_configurations/views.py +import base64 +import requests +from django.shortcuts import render, redirect, get_object_or_404 +from .models import Auth_Organization +from django.contrib.auth import get_user_model, login +import re + +User = get_user_model() + +BACKEND = 'django.contrib.auth.backends.ModelBackend' + + +def organization_oidc_login(request): + # Check if this is a post request and it contains organization_oauth2_login + if request.method == 'POST' and 'organization_oidc_login' in request.POST: + # Get auth organization id from the request + auth_organization_id = request.POST.get('organization_oidc_login') + + # Get auth organization using its id + organization = get_object_or_404(Auth_Organization, pk=auth_organization_id) + + if organization: + # Create a redirect url consisiting of + # - authorization_url + # - client_id + # - response_type + # - redirect_uri + oidc_auth_url = ( + f"{organization.authorization_url}?" + f"client_id={organization.client_id}&" + "response_type=code&" + "scope=openid profile email&" + f"redirect_uri={organization.redirect_url}" + ) + + # Redirect the user to the OIDC provider's authorization URL + return redirect(oidc_auth_url) + + # Handle other cases or render a different template if needed + return render(request, 'registration/login.html') + + +def oidc_complete(request, auth_organization_id): + + # create empty context + context = {} + + # Get error or authorization code from the query string + error = request.GET.get('error', None) + error_description = request.GET.get('error_description', None) + authorization_code = request.GET.get('code', None) + + if error: + context["error"] = error + + if error_description: + context["error_description"] = error_description + + # Token exhange process + if authorization_code: + + try: + # STEP 1: Get auth organization using its id + organization = get_object_or_404(Auth_Organization, pk=auth_organization_id) + + if organization: + + # STEP 2: Get access token + access_token, token_error = get_access_token(organization, authorization_code) + + if token_error: + context["error"] = token_error + else: + # STEP 3: Get user info + user_info, user_info_error = get_user_info(organization, access_token) + if user_info_error: + context["error"] = user_info_error + else: + + # get email and nickname (username) of the user + user_email = user_info.get("email", None) + user_nickname = user_info.get("nickname", None) + if user_email: + # get user with this email + user = get_user_by_email(user_email) + # STEP 4: Check if user exists and user is created using oidc and oidc orgnaization matches this one + if user: + login(request, user, backend=BACKEND) + # Redirect the user home page + return redirect('pages:home') + else: + return register_and_authenticate_user(request, user_email, user_nickname, organization) + + else: + context["error"] = "Unable to extract email from user info! Please contact platform" + else: + context["error"] = "Invalid Organization ID!" + except Exception as e: + context["error"] = f"{e}" + + return render(request, 'oidc/oidc_complete.html', context) + + +def get_access_token(organization, authorization_code): + + token_url = organization.token_url + client_id = organization.client_id + client_secret = organization.client_secret + redirect_url = organization.redirect_url + + auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode("utf-8") + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": f"Basic {auth_header}", + } + data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": redirect_url, + } + + try: + response = requests.request("POST", token_url, data=data, headers=headers) + response.raise_for_status() # Raise an HTTPError for bad responses (4xx or 5xx) + token_data = response.json() + access_token = token_data.get('access_token') + return access_token, None + except requests.exceptions.RequestException as e: + print(f"Error during token request: {e}") + return None, e + except Exception as e: + print(f"Error parsing token response: {e}") + return None, e + + +def get_user_info(organization, access_token): + + user_info_url = organization.user_info_url + + headers = { + 'Authorization': f'Bearer {access_token}', + } + + response = requests.get(user_info_url, headers=headers) + + try: + user_info = response.json() + return user_info, None + except Exception as e: + return None, e + + +def register_and_authenticate_user(request, user_email, user_nickname, organization): + + if not user_nickname: + username = re.sub(r'[^a-zA-Z0-9]', '', user_email.split('@')[0]) + else: + username = user_nickname + + # Ensure the username is unique + username = create_unique_username(username) + + # Create a new user + user = User.objects.create( + username=username, + email=user_email, + is_created_using_oidc=True, + oidc_organization=organization, + ) + + if user: + # login user + login(request, user, backend=BACKEND) + # Redirect to the home page + return redirect('pages:home') + + else: + # Handle authentication failure i.e. go back to login + return redirect('accounts:login') + + +def create_unique_username(username): + # Check if the username already exists + if User.objects.filter(username=username).exists(): + # If the username already exists, modify it to make it unique + suffix = 1 + new_username = f"{username}_{suffix}" + while User.objects.filter(username=new_username).exists(): + suffix += 1 + new_username = f"{username}_{suffix}" + return new_username + else: + # If the username doesn't exist, use it as is + return username + + +def get_user_by_email(email): + try: + user = User.objects.get(email=email) + return user + except User.DoesNotExist: + return None diff --git a/src/apps/pages/urls.py b/src/apps/pages/urls.py index 084bd2c7e..bbba17d71 100644 --- a/src/apps/pages/urls.py +++ b/src/apps/pages/urls.py @@ -11,5 +11,6 @@ path('search', views.SearchView.as_view(), name="search"), path('organize', views.OrganizeView.as_view(), name="organize"), path('server_status', views.ServerStatusView.as_view(), name="server_status"), + path('monitor_queues', views.MonitorQueuesView.as_view(), name="monitor_queues"), # path('test', views.CompetitionListTestView.as_view()), ] diff --git a/src/apps/pages/views.py b/src/apps/pages/views.py index a555e80fa..162d47fdf 100644 --- a/src/apps/pages/views.py +++ b/src/apps/pages/views.py @@ -1,13 +1,12 @@ -from datetime import timedelta -from django.utils.timezone import now +from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage from django.views.generic import TemplateView -from django.db.models import Count, Q +from django.db.models import Q -from competitions.models import Competition, Submission, CompetitionParticipant -from profiles.models import User +from competitions.models import Submission from announcements.models import Announcement, NewsPost from django.shortcuts import render +from utils.data import pretty_bytes class HomeView(TemplateView): @@ -16,26 +15,6 @@ class HomeView(TemplateView): def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - data = Competition.objects.aggregate( - count=Count('*'), - published_comps=Count('pk', filter=Q(published=True)), - unpublished_comps=Count('pk', filter=Q(published=False)), - ) - - total_competitions = data['count'] - public_competitions = data['published_comps'] - users = User.objects.all().count() - competition_participants = CompetitionParticipant.objects.all().count() - submissions = Submission.objects.all().count() - - context['general_stats'] = [ - {'label': "Total Competitions", 'count': total_competitions}, - {'label': "Public Competitions", 'count': public_competitions}, - {'label': "Users", 'count': users}, - {'label': "Competition Participants", 'count': competition_participants}, - {'label': "Submissions", 'count': submissions}, - ] - announcement = Announcement.objects.all().first() context['announcement'] = announcement.text if announcement else None @@ -59,59 +38,82 @@ class ServerStatusView(TemplateView): def get_context_data(self, *args, **kwargs): show_child_submissions = self.request.GET.get('show_child_submissions', False) - - # Get all submissions - qs = Submission.objects.all() - - # If user is not super user then: - # filter this user's own submissions - # and - # submissions running on queue which belongs to this user - if not self.request.user.is_superuser: - qs = qs.filter( - Q(owner=self.request.user) | - Q(phase__competition__queue__isnull=False, phase__competition__queue__owner=self.request.user) - ) - - # filter for fetching last 2 days submissions - qs = qs.filter(created_when__gte=now() - timedelta(days=2)) - - # filter out child submissions i.e. submission has no parent + page = self.request.GET.get('page', 1) + submissions_per_page = 50 + + # Start with an empty queryset + qs = Submission.objects.none() + + # Only if user is authenticated + if self.request.user.is_authenticated: + # If user is not super user then: + # filter this user's own submissions + # and + # submissions running on queue which belongs to this user + # NOTE: exclude all soft-deleted submissions + if not self.request.user.is_superuser: + qs = Submission.objects.filter( + Q(is_soft_deleted=False) & + ( + Q(owner=self.request.user) | + Q(phase__competition__queue__isnull=False, phase__competition__queue__owner=self.request.user) + ) + ) + else: + qs = Submission.objects.filter( + Q(is_soft_deleted=False) + ) + + # Filter out child submissions i.e. submission has no parent if not show_child_submissions: qs = qs.filter(parent__isnull=True) qs = qs.order_by('-created_when') qs = qs.select_related('phase__competition', 'owner') + # Paginate the queryset + paginator = Paginator(qs, submissions_per_page) + + try: + submissions = paginator.page(page) + except PageNotAnInteger: + # If page is not an integer, deliver the first page. + submissions = paginator.page(1) + except EmptyPage: + # If page is out of range, deliver last page of results. + submissions = paginator.page(paginator.num_pages) + context = super().get_context_data(*args, **kwargs) - context['submissions'] = qs[:250] + context['submissions'] = submissions context['show_child_submissions'] = show_child_submissions for submission in context['submissions']: # Get filesize from each submissions's data - submission.file_size = self.format_file_size(submission.data.file_size) + if submission.data: + submission.file_size = pretty_bytes(submission.data.file_size) + else: + submission.file_size = pretty_bytes(0) + # Get queue from each submission - queue_name = "*" if submission.queue is None else submission.queue.name + queue_name = "" + # if submission has parent get queue from parent otherwise from the submission iteset + if submission.parent: + queue_name = "*" if submission.parent.queue is None else submission.parent.queue.name + else: + queue_name = "*" if submission.queue is None else submission.queue.name submission.competition_queue = queue_name - return context + # Add submission owner display name + submission.owner_display_name = submission.owner.display_name if submission.owner.display_name else submission.owner.username - def format_file_size(self, file_size): - """ - A custom function to convert file size to KB, MB, GB and return with the unit - """ - try: - n = float(file_size) - except ValueError: - return "" + context['paginator'] = paginator + context['is_paginated'] = paginator.num_pages > 1 + + return context - units = ['KB', 'MB', 'GB'] - i = 0 - while n >= 1000 and i < len(units) - 1: - n /= 1000 - i += 1 - return f"{n:.1f} {units[i]}" +class MonitorQueuesView(TemplateView): + template_name = 'pages/monitor_queues.html' def page_not_found_view(request, exception): diff --git a/src/apps/profiles/admin.py b/src/apps/profiles/admin.py index 2d20ce2f6..cb2c5bee3 100644 --- a/src/apps/profiles/admin.py +++ b/src/apps/profiles/admin.py @@ -1,15 +1,25 @@ from django.contrib import admin -from .models import User, Organization, Membership +from .models import User, DeletedUser, Organization, Membership class UserAdmin(admin.ModelAdmin): # The following two lines are needed for Django-su: change_form_template = "admin/auth/user/change_form.html" change_list_template = "admin/auth/user/change_list.html" + search_fields = ['username', 'email'] + list_filter = ['is_staff', 'is_superuser', 'is_deleted', 'is_bot'] + list_display = ['username', 'email', 'is_staff', 'is_superuser'] + + +class DeletedUserAdmin(admin.ModelAdmin): + list_display = ('user_id', 'username', 'email', 'deleted_at') + search_fields = ('username', 'email') + list_filter = ('deleted_at',) admin.site.register(User, UserAdmin) +admin.site.register(DeletedUser, DeletedUserAdmin) admin.site.register(Organization) admin.site.register(Membership) diff --git a/src/apps/profiles/forms.py b/src/apps/profiles/forms.py index 7b4ad71e8..8f8ca1d2c 100644 --- a/src/apps/profiles/forms.py +++ b/src/apps/profiles/forms.py @@ -1,3 +1,4 @@ +import re from django import forms from django.contrib.auth.forms import UserCreationForm from .models import User @@ -11,18 +12,25 @@ class SignUpForm(UserCreationForm): def clean_username(self): data = self.cleaned_data["username"] - if not data.islower(): - raise forms.ValidationError("Usernames should be in lowercase") - if not data.isalnum(): - raise forms.ValidationError( - "Usernames should not contain special characters." - ) + + # Check if username has allowed characters only + # Allow only lowercase letters, numbers, hyphens, and underscores + if not re.match(r"^[a-z0-9_-]+$", data): + raise forms.ValidationError("Username can only contain lowercase letters, numbers, hyphens, and underscores.") + + # Check username length if (len(data) > 15) or (len(data) < 5): raise forms.ValidationError( "Username must have at least 5 characters and at most 15 characters" ) return data + def clean_email(self): + email = self.cleaned_data["email"] + if "*" in email: + raise forms.ValidationError("Email address cannot contain the '*' character.") + return email + class Meta: model = User @@ -33,3 +41,7 @@ class LoginForm(forms.Form): username = forms.CharField(max_length=150) password = forms.CharField(max_length=150, widget=forms.PasswordInput) + + +class ActivationForm(forms.Form): + email = forms.EmailField(max_length=254, required=True) diff --git a/src/apps/profiles/migrations/0012_user_quota.py b/src/apps/profiles/migrations/0012_user_quota.py new file mode 100644 index 000000000..20d3ac2fe --- /dev/null +++ b/src/apps/profiles/migrations/0012_user_quota.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.17 on 2023-11-22 19:57 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0011_auto_20230902_0936'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='quota', + field=models.BigIntegerField(default=settings.DEFAULT_USER_QUOTA), + ), + ] diff --git a/src/apps/profiles/migrations/0013_auto_20240304_0616.py b/src/apps/profiles/migrations/0013_auto_20240304_0616.py new file mode 100644 index 000000000..121ca477c --- /dev/null +++ b/src/apps/profiles/migrations/0013_auto_20240304_0616.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.17 on 2024-03-04 06:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('oidc_configurations', '0001_initial'), + ('profiles', '0012_user_quota'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_created_using_oidc', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='user', + name='oidc_organization', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='authorized_users', to='oidc_configurations.Auth_Organization'), + ), + ] diff --git a/src/apps/profiles/migrations/0014_auto_20241120_1607.py b/src/apps/profiles/migrations/0014_auto_20241120_1607.py new file mode 100644 index 000000000..b339957c9 --- /dev/null +++ b/src/apps/profiles/migrations/0014_auto_20241120_1607.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.28 on 2024-11-20 16:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0013_auto_20240304_0616'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='user', + name='is_deleted', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/apps/profiles/migrations/0015_deleteduser.py b/src/apps/profiles/migrations/0015_deleteduser.py new file mode 100644 index 000000000..39443aaae --- /dev/null +++ b/src/apps/profiles/migrations/0015_deleteduser.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.28 on 2025-02-04 07:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0014_auto_20241120_1607'), + ] + + operations = [ + migrations.CreateModel( + name='DeletedUser', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=255)), + ('email', models.EmailField(max_length=254)), + ('deleted_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/src/apps/profiles/migrations/0016_deleteduser_user_id.py b/src/apps/profiles/migrations/0016_deleteduser_user_id.py new file mode 100644 index 000000000..e1637c9e6 --- /dev/null +++ b/src/apps/profiles/migrations/0016_deleteduser_user_id.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.28 on 2025-02-04 16:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('profiles', '0015_deleteduser'), + ] + + operations = [ + migrations.AddField( + model_name='deleteduser', + name='user_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/src/apps/profiles/models.py b/src/apps/profiles/models.py index 67abeb665..3a660440d 100644 --- a/src/apps/profiles/models.py +++ b/src/apps/profiles/models.py @@ -7,6 +7,16 @@ from django.utils.text import slugify from utils.data import PathWrapper from django.urls import reverse +from django.conf import settings +from django.db.models import ( + Sum, + F, + Case, + Value, + When, + DecimalField, +) +from oidc_configurations.models import Auth_Organization PROFILE_DATA_BLACKLIST = [ 'password', @@ -23,6 +33,16 @@ def all_objects(self): return super().get_queryset() +class DeletedUser(models.Model): + user_id = models.IntegerField(null=True, blank=True) # Store the same ID as in the User table + username = models.CharField(max_length=255) + email = models.EmailField() + deleted_at = models.DateTimeField(auto_now_add=True) # Automatically sets to current time when the record is created + + def __str__(self): + return f"{self.username} ({self.email})" + + class User(ChaHubSaveMixin, AbstractBaseUser, PermissionsMixin): # Social needs the below setting. Username is not really set to UID. USERNAME_FIELD = 'username' @@ -61,6 +81,11 @@ class User(ChaHubSaveMixin, AbstractBaseUser, PermissionsMixin): date_joined = models.DateTimeField(default=now) is_active = models.BooleanField(default=True) is_staff = models.BooleanField(default=False) + quota = models.BigIntegerField(default=settings.DEFAULT_USER_QUOTA, null=False) + + # Fields for OIDC authentication + is_created_using_oidc = models.BooleanField(default=False) + oidc_organization = models.ForeignKey(Auth_Organization, null=True, blank=True, on_delete=models.SET_NULL, related_name="authorized_users") # Notifications organizer_direct_message_updates = models.BooleanField(default=True) @@ -78,6 +103,10 @@ class User(ChaHubSaveMixin, AbstractBaseUser, PermissionsMixin): # Required for social auth and such to create users objects = ChaHubUserManager() + # Soft deletion + is_deleted = models.BooleanField(default=False) + deleted_at = models.DateTimeField(null=True, blank=True) + def save(self, *args, **kwargs): self.slug = slugify(self.username, allow_unicode=True) super().save(*args, **kwargs) @@ -124,6 +153,138 @@ def get_chahub_is_valid(self): # By default, always push return True + def get_used_storage_space(self, binary=False): + """ + Function to calculate storage used by a user + Returns in bytes + """ + + from datasets.models import Data + from competitions.models import Submission, SubmissionDetails + + storage_used = 0 + + # Datasets + users_datasets = Data.objects.filter( + created_by_id=self.id, file_size__gt=0, file_size__isnull=False + ).aggregate(Sum("file_size"))["file_size__sum"] + + storage_used += users_datasets if users_datasets else 0 + + # Submissions + users_submissions = Submission.objects.filter(owner_id=self.id).aggregate( + size=Sum( + Case( + When( + prediction_result_file_size__gt=0, + then=F("prediction_result_file_size"), + ), + default=Value(0), + output_field=DecimalField(), + ) + + Case( + When( + scoring_result_file_size__gt=0, + then=F("scoring_result_file_size"), + ), + default=Value(0), + output_field=DecimalField(), + ) + + Case( + When( + detailed_result_file_size__gt=0, + then=F("detailed_result_file_size"), + ), + default=Value(0), + output_field=DecimalField(), + ) + ) + ) + + storage_used += users_submissions["size"] if users_submissions["size"] else 0 + + # Submissions details + users_submissions_details = SubmissionDetails.objects.filter( + submission__owner_id=self.id, file_size__gt=0, file_size__isnull=False + ).aggregate(Sum("file_size"))["file_size__sum"] + + storage_used += users_submissions_details if users_submissions_details else 0 + + return storage_used + + def delete(self, *args, **kwargs): + """Soft delete the user and anonymize personal data.""" + from .views import send_user_deletion_notice_to_admin, send_user_deletion_confirmed + from .models import DeletedUser + + # Send a notice to admins + send_user_deletion_notice_to_admin(self) + + # Mark the user as deleted + self.is_deleted = True + self.deleted_at = now() + self.is_active = False + + # Anonymize or removed personal data + user_email = self.email # keep track of the email for the end of the procedure + + # Store the deleted user's data in the DeletedUser table + DeletedUser.objects.create( + user_id=self.id, + username=self.username, + email=self.email + ) + + # Github related + self.github_uid = None + self.avatar_url = None + self.url = None + self.html_url = None + self.name = None + self.company = None + self.bio = None + if self.github_info: + self.github_info.login = None + self.github_info.avatar_url = None + self.github_info.gravatar_id = None + self.github_info.html_url = None + self.github_info.name = None + self.github_info.company = None + self.github_info.bio = None + self.github_info.location = None + + # Any user attribute + self.username = f"deleted_user_{self.id}" + self.slug = f"deleted_slug_{self.id}" + self.photo = None + self.email = None + self.display_name = None + self.first_name = None + self.last_name = None + self.title = None + self.location = None + self.biography = None + self.personal_url = None + self.linkedin_url = None + self.twitter_url = None + self.github_url = None + + # Queues + self.rabbitmq_username = None + self.rabbitmq_password = None + + # Save the changes + self.save() + + # Send a confirmation email notice to the removed user + send_user_deletion_confirmed(user_email) + + def restore(self, *args, **kwargs): + """Restore a soft-deleted user. Note that personal data remains anonymized.""" + self.is_deleted = False + self.deleted_at = None + self.save() + class GithubUserInfo(models.Model): # Required Info diff --git a/src/apps/profiles/quota.py b/src/apps/profiles/quota.py new file mode 100644 index 000000000..3a2e06571 --- /dev/null +++ b/src/apps/profiles/quota.py @@ -0,0 +1,17 @@ +import logging +from .models import User + +logger = logging.getLogger() + + +def reset_all_users_quota_to_gb(): + """ + Converts user quota from bytes to GB if it's stored in bytes. + Skips users whose quota is already in GB. + """ + users = User.objects.all() + for user in users: + # If quota is in bytes (greater than 1 GB in bytes) + if user.quota > 1000 * 1000 * 1000: + user.quota = user.quota / 1e9 # Convert to GB + user.save() diff --git a/src/apps/profiles/tasks.py b/src/apps/profiles/tasks.py new file mode 100644 index 000000000..c8c752adc --- /dev/null +++ b/src/apps/profiles/tasks.py @@ -0,0 +1,68 @@ +import time +import logging +from datetime import timedelta +from django.utils.timezone import now +from celery_config import app +from django.contrib.auth import get_user_model +from profiles.models import DeletedUser +from competitions.models import Competition, Submission + +logger = logging.getLogger() + + +@app.task(queue="site-worker") +def clean_deleted_users(): + starting_time = time.process_time() + logger.info("Task clean_deleted_users Started") + + # Calculate the threshold date (one month ago) + one_month_ago = now() - timedelta(days=30) + + # Delete users who were deleted more than a month ago + deleted_count, _ = DeletedUser.objects.filter(deleted_at__lt=one_month_ago).delete() + + logger.info(f"Deleted {deleted_count} users from DeletedUser table.") + + elapsed_time = time.process_time() - starting_time + logger.info( + "Task clean_deleted_users Completed. Duration = {:.3f} seconds".format(elapsed_time) + ) + + +@app.task(queue="site-worker") +def clean_non_activated_users(): + try: + starting_time = time.process_time() + logger.info("Task clean_non_activated_users Started") + + # Get User model + User = get_user_model() + + # Calculate the threshold date (3 days ago) + three_days_ago = now() - timedelta(days=3) + + # Filter users who are inactive, not deleted and created more than 3 days ago + users_to_delete = User.objects.filter( + is_active=False, + is_deleted=False, + date_joined__lt=three_days_ago + ) + + # Exclude users who have created any competitions or made submissions + users_to_delete = users_to_delete.exclude( + id__in=Competition.objects.values_list('created_by_id', flat=True) + ).exclude( + id__in=Submission.objects.values_list('owner_id', flat=True) + ) + + # Delete users + deleted_count, _ = users_to_delete.delete() + + logger.info(f"Deleted {deleted_count} non activated users from User table.") + + elapsed_time = time.process_time() - starting_time + logger.info( + "Task clean_non_activated_users Completed. Duration = {:.3f} seconds".format(elapsed_time) + ) + except Exception as e: + logger.exception(f"Failed to clean non-activated users\n{e}") diff --git a/src/apps/profiles/tokens.py b/src/apps/profiles/tokens.py index 13439a6b0..2842f2df4 100644 --- a/src/apps/profiles/tokens.py +++ b/src/apps/profiles/tokens.py @@ -4,7 +4,23 @@ class AccountActivationTokenGenerator(PasswordResetTokenGenerator): def _make_hash_value(self, user, timestamp): - return (six.text_type(user.pk) + six.text_type(timestamp) + six.text_type(user.is_active)) + return ( + six.text_type(user.pk) + + six.text_type(timestamp) + + six.text_type(user.is_active) + ) account_activation_token = AccountActivationTokenGenerator() + + +class AccountDeletionTokenGenerator(PasswordResetTokenGenerator): + def _make_hash_value(self, user, timestamp): + return ( + six.text_type(user.pk) + + six.text_type(timestamp) + + six.text_type(user.is_deleted) + ) + + +account_deletion_token = AccountDeletionTokenGenerator() diff --git a/src/apps/profiles/urls_accounts.py b/src/apps/profiles/urls_accounts.py index 03acf8254..a3b5103b2 100644 --- a/src/apps/profiles/urls_accounts.py +++ b/src/apps/profiles/urls_accounts.py @@ -7,14 +7,13 @@ urlpatterns = [ url(r'^signup', views.sign_up, name="signup"), + path('resend_activation/', views.resend_activation, name='resend_activation'), path('login/', views.log_in, name='login'), - # url(r'^user_profile', views.user_profile, name="user_profile"), - # path('login/', auth_views.LoginView.as_view(extra_context=extra_context), name='login'), - # path('login/', views.LoginView.as_view(), name='login'), - # path('logout/', auth_views.LogoutView.as_view(), name='logout'), path('logout/', views.LogoutView.as_view(), name='logout'), path('password_reset/', views.CustomPasswordResetView.as_view(), name='password_reset'), path('password_reset/done/', auth_views.PasswordResetDoneView.as_view(), name='password_reset_done'), path('reset///', views.CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm'), path('reset/done/', auth_views.PasswordResetCompleteView.as_view(), name='password_reset_complete'), + path('user//account/', views.UserAccountView.as_view(), name="user_account"), + path('delete//', views.delete, name='delete'), ] diff --git a/src/apps/profiles/views.py b/src/apps/profiles/views.py index 33ab6235d..9fbc2daba 100644 --- a/src/apps/profiles/views.py +++ b/src/apps/profiles/views.py @@ -19,9 +19,15 @@ from api.serializers.profiles import UserSerializer, OrganizationDetailSerializer, OrganizationEditSerializer, \ UserNotificationSerializer -from .forms import SignUpForm, LoginForm -from .models import User, Organization, Membership -from .tokens import account_activation_token +from .forms import SignUpForm, LoginForm, ActivationForm +from .models import User, DeletedUser, Organization, Membership +from oidc_configurations.models import Auth_Organization +from .tokens import account_activation_token, account_deletion_token +from competitions.models import Competition +from datasets.models import Data, DataGroup +from tasks.models import Task +from forums.models import Post +from utils.email import codalab_send_mail class LoginView(auth_views.LoginView): @@ -81,8 +87,8 @@ def activate(request, uidb64, token): messages.success(request, f'Your account is fully setup! Please login.') return redirect('accounts:login') else: - messages.error(request, f"Activation link is invalid. Please double check your link.") - return redirect('accounts:signup') + messages.error(request, f"Activation link is invalid or expired. Please double check your link.") + return redirect('accounts:resend_activation') return redirect('pages:home') @@ -103,7 +109,97 @@ def activateEmail(request, user, to_email): messages.error(request, f'Problem sending confirmation email to {to_email}, check if you typed it correctly.') +def send_delete_account_confirmation_mail(request, user): + context = { + 'user': user, + 'domain': get_current_site(request).domain, + 'uid': urlsafe_base64_encode(force_bytes(user.pk)), + 'token': account_deletion_token.make_token(user), + 'protocol': 'https' if request.is_secure() else 'http' + } + codalab_send_mail( + context_data=context, + subject=f'Confirm Your Account Deletion Request', + html_file="profiles/emails/template_delete_account.html", + text_file="profiles/emails/template_delete_account.txt", + to_email=[user.email] + ) + messages.success(request, f'Dear {user.username}, please go to your email inbox and click on the link to complete the deletion process. *Note: Check your spam folder.') + + +def send_user_deletion_notice_to_admin(user): + admin_users = User.objects.filter(Q(is_superuser=True) | Q(is_staff=True)) + admin_emails = [user.email for user in admin_users] + + organizations = user.organizations.all() + competitions_organizer = user.competitions.all() + competitions_participation = Competition.objects.filter(participants__user=user) + submissions = user.submission.all() + data = Data.objects.filter(created_by=user) + data_groups = DataGroup.objects.filter(created_by=user) + tasks = Task.objects.filter(created_by=user) + queues = user.queues.all() + posts = Post.objects.filter(posted_by=user) + deleted_user = user + + context = { + 'deleted_user': user, + 'user': "", + 'organizations': organizations, + 'competitions_organizer': competitions_organizer, + 'competitions_participation': competitions_participation, + 'submissions': submissions, + 'data': data, + 'data_groups': data_groups, + 'tasks': tasks, + 'queues': queues, + 'posts': posts, + 'domain': settings.DOMAIN_NAME + } + codalab_send_mail( + context_data=context, + subject=f'Notice: user {deleted_user.username} removed his account', + html_file="profiles/emails/template_delete_account_notice.html", + text_file="profiles/emails/template_delete_account_notice.txt", + to_email=admin_emails + ) + + +def send_user_deletion_confirmed(email): + codalab_send_mail( + context_data={}, + subject=f'Codabench: your account has been successfully removed', + html_file="profiles/emails/template_delete_account_confirmed.html", + text_file="profiles/emails/template_delete_account_confirmed.txt", + to_email=[email] + ) + + +def delete(request, uidb64, token): + try: + uid = force_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(pk=uid) + except User.DoesNotExist: + user = None + messages.error(request, f"User not found.") + return redirect('accounts:user_account') + if user is not None and account_deletion_token.check_token(user, token): + # Soft delete the user + user.delete() + messages.success(request, f'Your account has been removed!') + return redirect('accounts:logout') + else: + messages.error(request, f"Confirmation link is invalid or expired.") + return redirect('pages:home') + + def sign_up(request): + + # If sign up is not enabled then redirect to login + # this is for security as some users may access sign up page using the url + if not settings.ENABLE_SIGN_UP: + return redirect('accounts:login') + context = {} context['chahub_signup_url'] = "{}/profiles/signup?next={}/social/login/chahub".format( settings.SOCIAL_AUTH_CHAHUB_BASE_URL, @@ -112,14 +208,26 @@ def sign_up(request): if request.method == 'POST': form = SignUpForm(request.POST) if form.is_valid(): - form.save() - username = form.cleaned_data.get('username') - raw_password = form.cleaned_data.get('password1') - user = authenticate(username=username, password=raw_password) - user.is_active = False - user.save() - activateEmail(request, user, form.cleaned_data.get('email')) - return redirect('pages:home') + # Check if the email is in the DeletedUser table + email = form.cleaned_data.get('email').lower() + if DeletedUser.objects.filter(email=email).exists(): + messages.error(request, "This email has been previously deleted and cannot be used.") + context['form'] = form + else: + # Update the email field to lowercase before saving + form.cleaned_data['email'] = email + user = form.save(commit=False) # Get the user instance without saving + user.email = email # Ensure email is stored in lowercase + user.is_active = False # Set user as inactive + user.save() # Save user instance with updated email + + # Authenticate and send activation email + username = form.cleaned_data.get('username') + raw_password = form.cleaned_data.get('password1') + user = authenticate(username=username, password=raw_password) + activateEmail(request, user, email) + + return redirect('pages:home') else: context['form'] = form @@ -128,6 +236,31 @@ def sign_up(request): return render(request, 'registration/signup.html', context) +def resend_activation(request): + context = {} + if request.method == 'POST': + form = ActivationForm(request.POST) + if form.is_valid(): + + email = form.cleaned_data.get('email').lower() + user = User.objects.filter(email=email).first() + + if user and not user.is_active: + activateEmail(request, user, email) + return redirect('pages:home') + else: + if not user: + messages.error(request, "No account found with this email.") + elif user.is_active: + messages.error(request, "This account is already active.") + else: + context['form'] = form + + if not context.get('form'): + context['form'] = ActivationForm() + return render(request, 'registration/resend_activation.html', context) + + def log_in(request): # Fectch next redirect page after login @@ -144,12 +277,13 @@ def log_in(request): if form.is_valid(): # Get username and password - username = form.cleaned_data.get('username') + # use lowecased username/email + username = form.cleaned_data.get('username').lower() password = form.cleaned_data.get('password') # Check if the user exists try: - user = User.objects.get(Q(username=username) | Q(email=username)) + user = User.objects.get((Q(username=username) | Q(email=username)) & Q(is_deleted=False)) except User.DoesNotExist: messages.error(request, "User does not exist!") else: @@ -166,12 +300,17 @@ def log_in(request): else: return redirect(next) else: - messages.error(request, "Account is not active. Activate your account using the link sent to you by email.") + context['activation_error'] = "Your account is not activated. Please check your email for the activation link" else: messages.error(request, "Wrong Credentials!") else: context['form'] = form + # Fetch auth_organizations from the database + auth_organizations = Auth_Organization.objects.all() + if auth_organizations: + context['auth_organizations'] = auth_organizations + if not context.get('form'): context['form'] = LoginForm() return render(request, 'registration/login.html', context) @@ -302,3 +441,15 @@ def get_context_data(self, **kwargs): class OrganizationInviteView(LoginRequiredMixin, TemplateView): template_name = 'profiles/organization_invite.html' + + +class UserAccountView(LoginRequiredMixin, DetailView): + queryset = User.objects.all() + template_name = 'profiles/user_account.html' + slug_url_kwarg = 'username' + query_pk_and_slug = True + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['serialized_user'] = json.dumps(UserSerializer(self.get_object()).data) + return context diff --git a/src/apps/tasks/models.py b/src/apps/tasks/models.py index 3b38efe9b..c93d2b60a 100644 --- a/src/apps/tasks/models.py +++ b/src/apps/tasks/models.py @@ -66,10 +66,6 @@ def get_chahub_data(self, include_solutions=True): data['solutions'] = [solution.get_chahub_data(include_tasks=False) for solution in self.solutions.all()] return self.clean_private_data(data) - def save(self, *args, **kwargs): - self.is_public = self.is_public and self._validated - return super().save(*args, **kwargs) - class Solution(ChaHubSaveMixin, models.Model): name = models.CharField(max_length=256) diff --git a/src/apps/tasks/urls.py b/src/apps/tasks/urls.py index 96387c563..5cf5ec428 100644 --- a/src/apps/tasks/urls.py +++ b/src/apps/tasks/urls.py @@ -1,10 +1,12 @@ from django.urls import path from . import views +from api.views.tasks import TaskViewSet app_name = "tasks" urlpatterns = [ path('', views.TaskManagement.as_view(), name='task_management'), - path('/', views.TaskDetailView.as_view(), name='detail') + path('/', views.TaskDetailView.as_view(), name='detail'), + path('upload_task/', TaskViewSet.as_view({'post': 'upload_task'}), name='upload_task'), ] diff --git a/src/celery_config.py b/src/celery_config.py index 76c52a7a4..760614783 100644 --- a/src/celery_config.py +++ b/src/celery_config.py @@ -1,5 +1,8 @@ from celery import Celery from kombu import Queue, Exchange +from django.conf import settings +import urllib.parse +import copy app = Celery() @@ -11,3 +14,25 @@ # Mostly defining queue here so we can set x-max-priority Queue('compute-worker', Exchange('compute-worker'), routing_key='compute-worker', queue_arguments={'x-max-priority': 10}), ] + +_vhost_apps = {} + + +def app_for_vhost(vhost): + # Function to get the app for a vhost + if vhost not in _vhost_apps: + # Take the CELERY_BROKER_URL and replace the vhost with the vhhost for this queue + broker_url = settings.CELERY_BROKER_URL + # This is require to work around https://bugs.python.org/issue18828 + scheme = urllib.parse.urlparse(broker_url).scheme + urllib.parse.uses_relative.append(scheme) + urllib.parse.uses_netloc.append(scheme) + broker_url = urllib.parse.urljoin(broker_url, vhost) + vhost_app = Celery() + # Copy the settings so we can modify the broker url to include the vhost + django_settings = copy.copy(settings) + django_settings.CELERY_BROKER_URL = broker_url + vhost_app.config_from_object(django_settings, namespace='CELERY') + vhost_app.conf.task_queues = app.conf.task_queues + _vhost_apps[vhost] = vhost_app + return _vhost_apps[vhost] diff --git a/src/factories.py b/src/factories.py index d0c47716c..c76384f5c 100644 --- a/src/factories.py +++ b/src/factories.py @@ -154,6 +154,14 @@ class Meta: task = factory.SubFactory(TaskFactory) +class LeaderboardFactory(DjangoModelFactory): + class Meta: + model = Leaderboard + + title = factory.Faker('word') + key = factory.Faker('word') + + class SubmissionFactory(DjangoModelFactory): class Meta: model = Submission @@ -161,6 +169,7 @@ class Meta: owner = factory.SubFactory(UserFactory) phase = factory.SubFactory(PhaseFactory) name = factory.Sequence(lambda n: f'Submission {n}') + leaderboard = factory.SubFactory(LeaderboardFactory) created_when = factory.Faker('date_time_between', start_date='-5y', end_date='now', tzinfo=UTC) data = factory.SubFactory( @@ -182,14 +191,6 @@ class Meta: status = factory.LazyAttribute(lambda n: random.choice(['unknown', 'denied', 'approved', 'pending'])) -class LeaderboardFactory(DjangoModelFactory): - class Meta: - model = Leaderboard - - title = factory.Faker('word') - key = factory.Faker('word') - - class ColumnFactory(DjangoModelFactory): class Meta: model = Column diff --git a/src/settings/base.py b/src/settings/base.py index 62d3871e8..dba14455c 100644 --- a/src/settings/base.py +++ b/src/settings/base.py @@ -1,6 +1,7 @@ import os import sys from datetime import timedelta +from celery.schedules import crontab import dj_database_url @@ -36,7 +37,7 @@ 'rest_framework', 'rest_framework.authtoken', 'oauth2_provider', - 'corsheaders', + # 'corsheaders', 'social_django', 'django_extensions', 'django_filters', @@ -59,6 +60,7 @@ 'health', 'forums', 'announcements', + 'oidc_configurations', ) INSTALLED_APPS = THIRD_PARTY_APPS + OUR_APPS @@ -71,7 +73,8 @@ # 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'corsheaders.middleware.CorsMiddleware', + # 'corsheaders.middleware.CorsMiddleware', # BB + 'django.middleware.common.CommonMiddleware' ) ROOT_URLCONF = 'urls' @@ -208,6 +211,8 @@ CELERY_BROKER_URL = os.environ.get('BROKER_URL') if not CELERY_BROKER_URL: CELERY_BROKER_URL = f'pyamqp://{RABBITMQ_DEFAULT_USER}:{RABBITMQ_DEFAULT_PASS}@{RABBITMQ_HOST}:{RABBITMQ_PORT}//' +CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL", "redis://redis:6379") +CELERY_IGNORE_RESULT = False # Ensure that Celery tracks the state CELERY_TASK_SERIALIZER = 'json' CELERY_ACCEPT_CONTENT = ('json',) CELERY_BEAT_SCHEDULE = { @@ -223,6 +228,22 @@ 'task': 'competitions.tasks.submission_status_cleanup', 'schedule': timedelta(seconds=3600) }, + 'create_storage_analytics_snapshot': { + 'task': 'analytics.tasks.create_storage_analytics_snapshot', + 'schedule': crontab(hour='2', minute='0', day_of_week='sun') # Every Sunday at 02:00 UTC time + }, + 'update_home_page_counters': { + 'task': 'analytics.tasks.update_home_page_counters', + 'schedule': timedelta(days=1), # Run every 24 hours + }, + 'clean_deleted_users': { + 'task': 'profiles.tasks.clean_deleted_users', + 'schedule': timedelta(days=1), # Run every 24 hours + }, + 'clean_non_activated_users': { + 'task': 'profiles.tasks.clean_non_activated_users', + 'schedule': timedelta(days=1), # Run every 24 hours + }, } CELERY_TIMEZONE = 'UTC' CELERY_WORKER_PREFETCH_MULTIPLIER = 1 @@ -321,6 +342,7 @@ } SUBMISSIONS_API_URL = os.environ.get('SUBMISSIONS_API_URL', "http://django/api") +MAX_EXECUTION_TIME_LIMIT = os.environ.get('MAX_EXECUTION_TIME_LIMIT', "600") # time limit of the default queue # ============================================================================= # Storage @@ -397,6 +419,10 @@ GS_PRIVATE_BUCKET_NAME = os.environ.get('GS_PRIVATE_BUCKET_NAME') GS_BUCKET_NAME = GS_PUBLIC_BUCKET_NAME # Default bucket set to public bucket +# Quota +DEFAULT_USER_QUOTA = 15 # 15GB + + # ============================================================================= # Debug # ============================================================================= @@ -457,3 +483,10 @@ # on default queue when number of submissions are < RERUN_SUBMISSION_LIMIT # ============================================================================= RERUN_SUBMISSION_LIMIT = os.environ.get('RERUN_SUBMISSION_LIMIT', 30) + + +# ============================================================================= +# Enable or disbale regular email sign-in an sign-up +# ============================================================================= +ENABLE_SIGN_UP = os.environ.get('ENABLE_SIGN_UP', 'True').lower() == 'true' +ENABLE_SIGN_IN = os.environ.get('ENABLE_SIGN_IN', 'True').lower() == 'true' diff --git a/src/static/img/chalearn-logo.svg b/src/static/img/chalearn-logo.svg new file mode 100644 index 000000000..4533182d4 --- /dev/null +++ b/src/static/img/chalearn-logo.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/static/img/cnrs-logo.svg b/src/static/img/cnrs-logo.svg new file mode 100644 index 000000000..692f4aca3 --- /dev/null +++ b/src/static/img/cnrs-logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/static/img/lisn-logo.svg b/src/static/img/lisn-logo.svg new file mode 100644 index 000000000..e0bfdf4aa --- /dev/null +++ b/src/static/img/lisn-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/static/img/paris-saclay-logo.svg b/src/static/img/paris-saclay-logo.svg new file mode 100644 index 000000000..83ee67740 --- /dev/null +++ b/src/static/img/paris-saclay-logo.svg @@ -0,0 +1,97 @@ + + + + +logo +Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/static/img/partners/4paradigm.jpg b/src/static/img/partners/4paradigm.jpg new file mode 100644 index 000000000..80821eeed Binary files /dev/null and b/src/static/img/partners/4paradigm.jpg differ diff --git a/src/static/img/partners/anr.png b/src/static/img/partners/anr.png new file mode 100644 index 000000000..4665577bc Binary files /dev/null and b/src/static/img/partners/anr.png differ diff --git a/src/static/img/partners/anr2.jpg b/src/static/img/partners/anr2.jpg new file mode 100644 index 000000000..ffeb84ef4 Binary files /dev/null and b/src/static/img/partners/anr2.jpg differ diff --git a/src/static/img/partners/barcelona.png b/src/static/img/partners/barcelona.png new file mode 100644 index 000000000..2ccdf0151 Binary files /dev/null and b/src/static/img/partners/barcelona.png differ diff --git a/src/static/img/partners/chalearn.png b/src/static/img/partners/chalearn.png new file mode 100644 index 000000000..1965dae7a Binary files /dev/null and b/src/static/img/partners/chalearn.png differ diff --git a/src/static/img/partners/cnrs.png b/src/static/img/partners/cnrs.png new file mode 100644 index 000000000..434d6a653 Binary files /dev/null and b/src/static/img/partners/cnrs.png differ diff --git a/src/static/img/partners/eit_health.jpg b/src/static/img/partners/eit_health.jpg new file mode 100644 index 000000000..ab7c32709 Binary files /dev/null and b/src/static/img/partners/eit_health.jpg differ diff --git a/src/static/img/partners/google.jpg b/src/static/img/partners/google.jpg new file mode 100644 index 000000000..133a9dcf7 Binary files /dev/null and b/src/static/img/partners/google.jpg differ diff --git a/src/static/img/partners/idf.jpg b/src/static/img/partners/idf.jpg new file mode 100644 index 000000000..1fafd4bd0 Binary files /dev/null and b/src/static/img/partners/idf.jpg differ diff --git a/src/static/img/partners/inria.png b/src/static/img/partners/inria.png new file mode 100644 index 000000000..5632b6e0e Binary files /dev/null and b/src/static/img/partners/inria.png differ diff --git a/src/static/img/partners/lisn.png b/src/static/img/partners/lisn.png new file mode 100644 index 000000000..a9dd600c4 Binary files /dev/null and b/src/static/img/partners/lisn.png differ diff --git a/src/static/img/partners/microsoft.png b/src/static/img/partners/microsoft.png new file mode 100644 index 000000000..4f9e99c3d Binary files /dev/null and b/src/static/img/partners/microsoft.png differ diff --git a/src/static/img/partners/paris-saclay.png b/src/static/img/partners/paris-saclay.png new file mode 100644 index 000000000..9d63b43ad Binary files /dev/null and b/src/static/img/partners/paris-saclay.png differ diff --git a/src/static/img/partners/stanford.png b/src/static/img/partners/stanford.png new file mode 100644 index 000000000..c1d724694 Binary files /dev/null and b/src/static/img/partners/stanford.png differ diff --git a/src/static/js/ours/client.js b/src/static/js/ours/client.js index 764fb109a..410ec34cf 100644 --- a/src/static/js/ours/client.js +++ b/src/static/js/ours/client.js @@ -93,6 +93,9 @@ CODALAB.api = { delete_submission: function (pk) { return CODALAB.api.request('DELETE', `${URLS.API}submissions/${pk}/`) }, + soft_delete_submission: function (pk) { + return CODALAB.api.request('DELETE', `${URLS.API}submissions/${pk}/soft_delete/`) + }, delete_many_submissions: function (pks) { return CODALAB.api.request('DELETE', `${URLS.API}submissions/delete_many/`, pks) }, @@ -102,6 +105,9 @@ CODALAB.api = { cancel_submission: function (id) { return CODALAB.api.request('GET', `${URLS.API}submissions/${id}/cancel_submission/`) }, + run_submission: function (id) { + return CODALAB.api.request('POST', `${URLS.API}submissions/${id}/run_submission/`) + }, re_run_submission: function (id) { return CODALAB.api.request('POST', `${URLS.API}submissions/${id}/re_run_submission/`) }, @@ -121,6 +127,31 @@ CODALAB.api = { get_submission_detail_result: function (id) { return CODALAB.api.request('GET', `${URLS.API}submissions/${id}/get_detail_result/`) }, + download_many_submissions: function (pks) { + console.log('Request bulk'); + const params = new URLSearchParams({ pks: JSON.stringify(pks) }); + const url = `${URLS.API}submissions/download_many/?${params}`; + return fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }).then(response => { + if (!response.ok) { + throw new Error('Network response was not ok ' + response.statusText); + } + return response.blob(); + }).then(blob => { + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = 'bulk_submissions.zip'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }).catch(error => { + console.error('Error downloading submissions:', error); + }); + }, /*--------------------------------------------------------------------- Leaderboards @@ -165,6 +196,7 @@ CODALAB.api = { // Pass the requested file name for the SAS url metadata.request_sassy_file_name = data_file.name metadata.file_name = data_file.name + metadata.file_size = data_file.size // This will be set on successful dataset creation, then used to complete the dataset upload var dataset = {} @@ -228,6 +260,30 @@ CODALAB.api = { create_task: (data) => { return CODALAB.api.request('POST', `${URLS.API}tasks/`, data) }, + upload_task: (data_file, progress_update_callback) => { + var form_data = new FormData() + form_data.append('file', data_file) + return $.ajax({ + type: 'POST', + url: URLS.API + 'tasks/upload_task/', + data: form_data, + processData: false, + contentType: false, + xhr: function () { + var xhr = new window.XMLHttpRequest(); + // Track upload progress + xhr.upload.addEventListener('progress', function (event) { + if (event.lengthComputable) { + var percent_complete = (event.loaded / event.total) * 100; + if (progress_update_callback) { + progress_update_callback(percent_complete); + } + } + }, false); + return xhr; + } + }); + }, share_task: (pk, data) => { return CODALAB.api.request('PATCH', `${URLS.API}tasks/${pk}/`, data) }, @@ -308,12 +364,33 @@ CODALAB.api = { get_analytics: (filters) => { return CODALAB.api.request('GET', `${URLS.API}analytics/`, filters) }, + get_storage_usage_history: (filters) => { + return CODALAB.api.request('GET', `${URLS.API}analytics/storage_usage_history/`, filters); + }, + get_competitions_usage: (filters) => { + return CODALAB.api.request('GET', `${URLS.API}analytics/competitions_usage/`, filters); + }, + get_users_usage: (filters) => { + return CODALAB.api.request('GET', `${URLS.API}analytics/users_usage/`, filters); + }, + delete_orphan_files: () => { + return CODALAB.api.request('DELETE', `${URLS.API}analytics/delete_orphan_files/`) + }, + get_orphan_files: () => { + return CODALAB.api.request('GET', `${URLS.API}analytics/get_orphan_files/`) + }, + check_orphans_deletion_status: () => { + return CODALAB.api.request('GET', `${URLS.API}analytics/check_orphans_deletion_status/`) + }, /*--------------------------------------------------------------------- User Quota and Cleanup ---------------------------------------------------------------------*/ get_user_quota_cleanup: () => { return CODALAB.api.request('GET', `${URLS.API}user_quota_cleanup/`) }, + get_user_quota: () => { + return CODALAB.api.request('GET', `${URLS.API}user_quota/`) + }, delete_unused_tasks: () => { return CODALAB.api.request('DELETE', `${URLS.API}delete_unused_tasks/`) }, @@ -326,5 +403,10 @@ CODALAB.api = { delete_failed_submissions: () => { return CODALAB.api.request('DELETE', `${URLS.API}delete_failed_submissions/`) }, - + /*--------------------------------------------------------------------- + User Account + ---------------------------------------------------------------------*/ + request_delete_account: (data) => { + return CODALAB.api.request('DELETE', `${URLS.API}delete_account/`, data) + }, } diff --git a/src/static/js/ours/latex_markdown_html.js b/src/static/js/ours/latex_markdown_html.js index 4a0e645c2..eba1b874d 100644 --- a/src/static/js/ours/latex_markdown_html.js +++ b/src/static/js/ours/latex_markdown_html.js @@ -1,28 +1,103 @@ -// Function to render Markdown, HTML and Latex and return updated content +// Function to render Markdown content that may include: +// - Code blocks (```...```) +// - Inline and block LaTeX ($...$ or $$...$$) +// - HTML +// The function returns an array of DOM nodes generated from the fully rendered and processed content. function renderMarkdownWithLatex(content) { - if(content === null){ - return [] + if (!content) return [] // Return empty if content is null or empty + + // --------------------------------------------------------- + // Step 1: Extract and temporarily replace all code blocks + // --------------------------------------------------------- + + // Regex to match code blocks in Markdown: ```[language]\n[code]``` + const codeBlockPattern = /```(\w*)\n([\s\S]*?)```/g + const codeBlocks = [] // Store original code blocks and their tokens + let codeIndex = 0 // Counter to generate unique tokens for code blocks + + // Replace each code block with a unique token and store the original + const contentWithoutCode = content.replace(codeBlockPattern, (_, lang, code) => { + const token = `%%CODE_BLOCK_${codeIndex++}%%` + codeBlocks.push({ token, lang, code }) // Store the token and the original code + return token // Replace the block with its token in the text + }) + + // --------------------------------------------------------- + // Step 2: Extract and replace LaTeX expressions with placeholders + // --------------------------------------------------------- + + // Regex to match inline ($...$) and block ($$...$$) LaTeX formulas + const latexPattern = /\$\$([\s\S]+?)\$\$|\$([^\$\n]+?)\$/g + const latexBlocks = [] // Store rendered LaTeX HTML and their tokens + let latexIndex = 0 // Counter for LaTeX token IDs + + // Replace LaTeX expressions with unique tokens and store rendered HTML + const contentWithLatexPlaceholders = contentWithoutCode.replace(latexPattern, (_, block, inline) => { + const formula = block || inline // Pick block or inline formula content + const displayMode = !!block // Use displayMode for block ($$...$$) + let rendered // Store the rendered HTML from KaTeX + + try { + // Render LaTeX to HTML using KaTeX + rendered = katex.renderToString(formula, { + throwOnError: false, + displayMode, + }) + } catch (e) { + console.error("KaTeX error:", e) + rendered = `${formula}` // If render fails, fallback to raw code } - const parsedHtml = new DOMParser().parseFromString(marked(content), "text/html") - - const traverseAndRenderLatex = (node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - const latexPattern = /\$\$([\s\S]*?)\$\$|\$([^\$\n]*?)\$/g - const hasLatex = latexPattern.test(node.textContent) - if (hasLatex) { - const tempDiv = document.createElement('div') - tempDiv.innerHTML = node.innerHTML.replace(latexPattern, (_, formula1, formula2) => { - const formula = formula1 || formula2 - return katex.renderToString(formula, { throwOnError: false }) - }); - node.innerHTML = tempDiv.innerHTML - } - } - - node.childNodes.forEach(traverseAndRenderLatex) - }; - - traverseAndRenderLatex(parsedHtml.body) - - return parsedHtml.body.childNodes -} \ No newline at end of file + + const token = `%%LATEX_BLOCK_${latexIndex++}%%` + latexBlocks.push({ token, rendered }) // Store the token and rendered HTML + return token // Replace formula with its token in the text + }) + + // --------------------------------------------------------- + // Step 3: Convert Markdown to HTML + // --------------------------------------------------------- + + // Run the Markdown parser on the content (now safe with all code and LaTeX replaced by tokens) + let html = marked(contentWithLatexPlaceholders) + + // --------------------------------------------------------- + // Step 4: Restore rendered LaTeX blocks into the HTML + // --------------------------------------------------------- + + // Replace each LaTeX token with the rendered KaTeX HTML + for (const { token, rendered } of latexBlocks) { + html = html.replace(token, rendered) + } + + // --------------------------------------------------------- + // Step 5: Restore escaped code blocks back into the HTML + // --------------------------------------------------------- + + // Replace each code block token with HTML-safe
 block
+  for (const { token, code, lang } of codeBlocks) {
+    const safeCode = escapeHtml(code) // Escape HTML-sensitive characters inside code
+    html = html.replace(token, `
${safeCode}
`) + } + + + // --------------------------------------------------------- + // Step 6: Convert final HTML string into DOM nodes and return + // --------------------------------------------------------- + + // Parse the final HTML string into actual DOM nodes + const parsedHtml = new DOMParser().parseFromString(html, "text/html") + + // Return child nodes from the parsed HTML body + return parsedHtml.body.childNodes +} + +// Utility function to escape HTML special characters inside code blocks +function escapeHtml(text) { + return text + .replace(/&/g, "&") // escape ampersands + .replace(//g, ">") // escape > + .replace(/"/g, """) // escape double quotes + .replace(/'/g, "'") // escape single quotes +} + diff --git a/src/static/js/ours/utils.js b/src/static/js/ours/utils.js index 7407c5aa2..a70675222 100644 --- a/src/static/js/ours/utils.js +++ b/src/static/js/ours/utils.js @@ -89,6 +89,26 @@ function pretty_date(date_string) { } } +function pretty_bytes(bytes, decimalPlaces = 1, suffix = "B", binary = false) { + + // Ensure bytes is a valid number + bytes = parseFloat(bytes) + if (isNaN(bytes) || bytes < 0) { + return "" // Return empty string for invalid or negative values + } + + const factor = binary ? 1024.0 : 1000.0; + const units = binary ? ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi'] : ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z']; + + for (const unit of units) { + if (Math.abs(bytes) < factor || unit === units[units.length - 1]) { + return bytes.toFixed(decimalPlaces) + ' ' + unit + suffix; + } + bytes /= factor; + } + return bytes.toFixed(decimalPlaces) + ' ' + units[units.length - 1] + suffix; +} + /* ---------------------------------------------------------------------------- Form data helpers ----------------------------------------------------------------------------*/ @@ -151,13 +171,23 @@ const easyMDE_rendering_config = { } } -function create_easyMDE(element) { +function create_easyMDE(element, showToolBar = true, showStatusBar = true, editorHeight = '300px') { + + var toolbarIcons = [] + if(showToolBar){ + toolbarIcons = ["bold", "italic", "heading", "|", "quote", "unordered-list", "ordered-list", "|", "link", "image", "|", "preview", "guide"] + } + let statusItems = ["lines", "words", "cursor"] + + var markdown_editor = new EasyMDE({ element: element, autoRefresh: true, forceSync: true, - hideIcons: ["side-by-side", "fullscreen"], - renderingConfig: easyMDE_rendering_config + toolbar: toolbarIcons, + renderingConfig: easyMDE_rendering_config, + status: showStatusBar ? statusItems : showStatusBar, + minHeight: editorHeight || '300px' // Adjust the height, default is 300 }) element.EASY_MDE = markdown_editor return markdown_editor @@ -300,7 +330,12 @@ function getBase64(file) { debug: $.tablesort.DEBUG, asc: 'sorted ascending', desc: 'sorted descending', - compare: function(a, b) { + compare: function(a, b, settings) { + // Convert the values to numbers for proper sorting + if (!isNaN(parseFloat(a)) && !isNaN(parseFloat(b))) { + var a = parseFloat(a); + var b = parseFloat(b); + } if (a > b) { return 1; } else if (a < b) { diff --git a/src/static/riot/analytics/_competitions_usage.tag b/src/static/riot/analytics/_competitions_usage.tag new file mode 100644 index 000000000..b7897f7a0 --- /dev/null +++ b/src/static/riot/analytics/_competitions_usage.tag @@ -0,0 +1,571 @@ + +
+ + + +

{lastSnapshotDate ? "Last snaphost date: " + pretty_date(lastSnapshotDate) : "No snapshot has been taken yet"}

+
+
+ +
+
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + + + + + + +
CompetitionOrganizerCreation dateDatasets
{ competitionUsage.title }{ competitionUsage.organizer }{ formatDate(competitionUsage.created_when) }{ pretty_bytes(competitionUsage.datasets) }
+ + + + +
\ No newline at end of file diff --git a/src/static/riot/analytics/_usage_history.tag b/src/static/riot/analytics/_usage_history.tag new file mode 100644 index 000000000..6a75a5e53 --- /dev/null +++ b/src/static/riot/analytics/_usage_history.tag @@ -0,0 +1,193 @@ + +
+ + +

{lastSnapshotDate ? "Last snaphost date: " + pretty_date(lastSnapshotDate) : "No snapshot has been taken yet"}

+
+ +
+ +
+ + + + +
\ No newline at end of file diff --git a/src/static/riot/analytics/_users_usage.tag b/src/static/riot/analytics/_users_usage.tag new file mode 100644 index 000000000..62c8d33eb --- /dev/null +++ b/src/static/riot/analytics/_users_usage.tag @@ -0,0 +1,651 @@ + +
+ + + +

{lastSnapshotDate ? "Last snaphost date: " + pretty_date(lastSnapshotDate) : "No snapshot has been taken yet"}

+
+
+ +
+
+
+ + +
+
+
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + +
UserJoined atDatasetsSubmissionsTotal
{ userUsage.name }{ formatDate(userUsage.date_joined) }{ pretty_bytes(userUsage.datasets) }{ pretty_bytes(userUsage.submissions) }{ pretty_bytes(userUsage.datasets + userUsage.submissions) }
+ + + + +
\ No newline at end of file diff --git a/src/static/riot/analytics/analytics.tag b/src/static/riot/analytics/analytics.tag index a1b89f8a3..dde9e6c97 100644 --- a/src/static/riot/analytics/analytics.tag +++ b/src/static/riot/analytics/analytics.tag @@ -1,14 +1,19 @@

Analytics

+ +

Date Range

-
-