diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 47eda00761..38b8cda7b9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -23,6 +23,7 @@ Add the PR description here. ## Checklist -- [ ] **Required:** I read and followed the [contributing guidelines](https://github.com/OWASP/Nest/blob/main/CONTRIBUTING.md) -- [ ] **Required:** I ran `make check-test` locally and all tests passed -- [ ] I used AI for code, documentation, or tests in this PR +- [ ] **Required:** I followed the [contributing workflow](https://github.com/OWASP/Nest/blob/main/CONTRIBUTING.md#contributing-workflow) +- [ ] **Required:** I verified that my code works as intended and resolves the issue as described +- [ ] **Required:** I ran `make check-test` locally: all warnings addressed, tests passed +- [ ] I used AI for code, documentation, tests, or communication related to this PR diff --git a/.github/workflows/check-pr-issue.yaml b/.github/workflows/check-pr-issue.yaml index 3a1f8c199b..b906449e9f 100644 --- a/.github/workflows/check-pr-issue.yaml +++ b/.github/workflows/check-pr-issue.yaml @@ -5,6 +5,8 @@ on: types: - opened +permissions: {} + jobs: check-pr-issue: permissions: @@ -27,3 +29,4 @@ jobs: no_issue_message: 'The PR must be linked to an issue assigned to the PR author.' require_assignee: 'true' skip_users_file_path: '.github/workflows/check-pr-issue-skip-usernames.txt' + timeout-minutes: 5 diff --git a/.github/workflows/label-issues.yaml b/.github/workflows/label-issues.yaml index 53f07ca059..3f86468156 100644 --- a/.github/workflows/label-issues.yaml +++ b/.github/workflows/label-issues.yaml @@ -6,6 +6,8 @@ on: - edited - opened +permissions: {} + jobs: label: permissions: @@ -40,3 +42,4 @@ jobs: repo: context.repo.repo }); } + timeout-minutes: 5 diff --git a/.github/workflows/label-pull-requests.yaml b/.github/workflows/label-pull-requests.yaml index f3616e883a..5b60852b7b 100644 --- a/.github/workflows/label-pull-requests.yaml +++ b/.github/workflows/label-pull-requests.yaml @@ -3,6 +3,8 @@ name: Label Pull Requests on: - pull_request_target +permissions: {} + jobs: labeler: permissions: @@ -14,3 +16,4 @@ jobs: with: configuration-path: .github/labeler.yml sync-labels: true + timeout-minutes: 5 diff --git a/.github/workflows/run-ci-cd.yaml b/.github/workflows/run-ci-cd.yaml index b40825635e..d8ebb2311b 100644 --- a/.github/workflows/run-ci-cd.yaml +++ b/.github/workflows/run-ci-cd.yaml @@ -19,6 +19,8 @@ on: - published workflow_dispatch: +permissions: {} + env: FORCE_COLOR: 1 @@ -85,7 +87,7 @@ jobs: run_install: true - name: Set up Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 with: node-version: 24 cache: 'pnpm' @@ -113,6 +115,19 @@ jobs: - name: Check out repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + - name: Set up Docker buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Build cspell image + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 + with: + cache-from: type=gha,scope=cspell + cache-to: type=gha,scope=cspell,mode=max + context: cspell + file: cspell/Dockerfile + load: true + tags: cspell:ci + - name: Run cspell run: | make check-spelling @@ -130,13 +145,18 @@ jobs: - name: Check out repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + - name: Setup Trivy + uses: aquasecurity/setup-trivy@e6c2c5e321ed9123bda567646e2f96565e34abe1 + with: + cache: true + - name: Run Trivy Repository Scan uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 with: scan-type: repo + skip-setup-trivy: true trivy-config: trivy.yaml - trivyignores: trivyignore.yaml - version: latest + timeout-minutes: 5 scan-ci-dependencies: name: Run CI Dependencies Scan @@ -151,13 +171,18 @@ jobs: - name: Check out repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + - name: Setup Trivy + uses: aquasecurity/setup-trivy@e6c2c5e321ed9123bda567646e2f96565e34abe1 + with: + cache: true + - name: Run Trivy Filesystem Scan uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 with: scan-type: fs + skip-setup-trivy: true trivy-config: trivy.yaml - trivyignores: trivyignore.yaml - version: latest + timeout-minutes: 5 run-backend-tests: name: Run backend tests @@ -191,7 +216,7 @@ jobs: - name: Run backend tests run: | docker run -e DJANGO_SETTINGS_MODULE=settings.test --env-file backend/.env.example owasp/nest:test-backend-latest pytest - timeout-minutes: 10 + timeout-minutes: 5 run-frontend-unit-tests: name: Run frontend unit tests @@ -225,7 +250,7 @@ jobs: - name: Run frontend unit tests run: | docker run --env-file frontend/.env.example owasp/nest:test-frontend-unit-latest pnpm run test:unit - timeout-minutes: 10 + timeout-minutes: 5 run-frontend-e2e-tests: name: Run frontend e2e tests @@ -259,6 +284,40 @@ jobs: docker run --env-file frontend/.env.example owasp/nest:test-frontend-e2e-latest pnpm run test:e2e timeout-minutes: 10 + run-frontend-a11y-tests: + name: Run frontend accessibility tests + needs: + - scan-code + - scan-ci-dependencies + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Build frontend a11y-testing image + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 + with: + cache-from: | + type=gha + type=registry,ref=owasp/nest:test-frontend-a11y-cache + cache-to: | + type=gha,compression=zstd + context: frontend + file: docker/frontend/Dockerfile.a11y.test + load: true + platforms: linux/amd64 + tags: owasp/nest:test-frontend-a11y-latest + + - name: Run frontend a11y tests + run: | + docker run --env-file frontend/.env.example owasp/nest:test-frontend-a11y-latest pnpm run test:a11y + timeout-minutes: 5 + set-release-version: name: Set release version outputs: @@ -274,6 +333,7 @@ jobs: else echo "release_version=$(date '+%y.%-m.%-d')-${GITHUB_SHA::7}" >> $GITHUB_OUTPUT fi + timeout-minutes: 5 build-staging-images: name: Build Staging Images @@ -285,6 +345,7 @@ jobs: github.ref == 'refs/heads/main' needs: - run-backend-tests + - run-frontend-a11y-tests - run-frontend-e2e-tests - run-frontend-unit-tests - set-release-version @@ -392,7 +453,7 @@ jobs: echo "**Backend:** ${{ steps.backend-size.outputs.human_readable }}" echo "**Frontend:** ${{ steps.frontend-size.outputs.human_readable }}" } >> $GITHUB_STEP_SUMMARY - timeout-minutes: 10 + timeout-minutes: 5 scan-staging-images: name: Scan Staging Images @@ -405,27 +466,29 @@ jobs: - name: Check out repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + - name: Setup Trivy + uses: aquasecurity/setup-trivy@e6c2c5e321ed9123bda567646e2f96565e34abe1 + with: + cache: true + - name: Scan backend image - continue-on-error: true uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 with: exit-code: 1 image-ref: owasp/nest:backend-staging scan-type: image + skip-setup-trivy: true trivy-config: trivy.yaml - trivyignores: trivyignore.yaml - version: latest - name: Scan frontend image - continue-on-error: true uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 with: exit-code: 1 image-ref: owasp/nest:frontend-staging scan-type: image + skip-setup-trivy: true trivy-config: trivy.yaml - trivyignores: trivyignore.yaml - version: latest + timeout-minutes: 5 deploy-staging-nest: name: Deploy Nest Staging @@ -544,6 +607,7 @@ jobs: - name: Run Nest deploy working-directory: .github/ansible run: ansible-playbook -i inventory.yaml staging/nest.yaml -e "github_workspace=$GITHUB_WORKSPACE" + timeout-minutes: 5 deploy-staging-nest-proxy: name: Deploy Staging Nest Proxy @@ -579,6 +643,7 @@ jobs: - name: Run proxy deploy working-directory: .github/ansible run: ansible-playbook -i inventory.yaml staging/proxy.yaml -e "github_workspace=$GITHUB_WORKSPACE" + timeout-minutes: 5 run-staging-lighthouse-ci: name: Run Lighthouse CI @@ -598,7 +663,7 @@ jobs: version: 10 - name: Set up Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 with: cache-dependency-path: frontend/pnpm-lock.yaml cache: 'pnpm' @@ -609,33 +674,36 @@ jobs: LHCI_BASE_URL: 'https://nest.owasp.dev' run: | pnpm run lighthouse-ci - timeout-minutes: 15 working-directory: frontend + timeout-minutes: 5 run-staging-zap-baseline-scan: - name: Run ZAP Baseline Scan + name: Run staging ZAP baseline scan needs: - deploy-staging-nest-proxy permissions: contents: read runs-on: ubuntu-latest steps: - - name: Run ZAP Baseline Scan + - name: Check out repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + + - name: Run baseline scan uses: zaproxy/action-baseline@de8ad967d3548d44ef623df22cf95c3b0baf8b25 with: token: ${{ secrets.GITHUB_TOKEN }} target: 'https://nest.owasp.dev' allow_issue_writing: false - fail_action: false - cmd_options: '-a -r zap-report.html' + fail_action: true + cmd_options: '-a -c .zapconfig -r report_html.html' - - name: Upload ZAP report + - name: Upload report if: always() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f with: name: zap-baseline-scan-report-${{ github.run_id }} - path: zap-report.html - + path: report_html.html + timeout-minutes: 5 build-production-images: name: Build Production Images @@ -647,6 +715,7 @@ jobs: github.event.action == 'published' needs: - run-backend-tests + - run-frontend-a11y-tests - run-frontend-e2e-tests - run-frontend-unit-tests - set-release-version @@ -750,7 +819,7 @@ jobs: echo "**Backend:** ${{ steps.backend-size.outputs.human_readable }}" echo "**Frontend:** ${{ steps.frontend-size.outputs.human_readable }}" } >> $GITHUB_STEP_SUMMARY - timeout-minutes: 10 + timeout-minutes: 5 scan-production-images: name: Scan Production Images @@ -767,27 +836,25 @@ jobs: uses: aquasecurity/setup-trivy@e6c2c5e321ed9123bda567646e2f96565e34abe1 with: cache: true - version: v0.62.1 - name: Scan backend image - continue-on-error: true uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 with: exit-code: 1 image-ref: owasp/nest:backend-production + scan-type: image skip-setup-trivy: true trivy-config: trivy.yaml - trivyignores: trivyignore.yaml - name: Scan frontend image - continue-on-error: true uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 with: exit-code: 1 image-ref: owasp/nest:frontend-production + scan-type: image skip-setup-trivy: true trivy-config: trivy.yaml - trivyignores: trivyignore.yaml + timeout-minutes: 5 deploy-production-nest: name: Deploy Nest to Production @@ -917,6 +984,7 @@ jobs: - name: Run Nest deploy working-directory: .github/ansible run: ansible-playbook -i inventory.yaml production/nest.yaml -e "github_workspace=$GITHUB_WORKSPACE" + timeout-minutes: 5 deploy-production-nest-proxy: name: Deploy Production Nest Proxy @@ -952,27 +1020,32 @@ jobs: - name: Run proxy deploy working-directory: .github/ansible run: ansible-playbook -i inventory.yaml production/proxy.yaml -e "github_workspace=$GITHUB_WORKSPACE" + timeout-minutes: 5 run-production-zap-baseline-scan: - name: Run ZAP Baseline Scan + name: Run production ZAP baseline scan needs: - deploy-production-nest-proxy permissions: contents: read runs-on: ubuntu-latest steps: - - name: Run ZAP Baseline Scan + - name: Check out repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 + + - name: Run baseline scan uses: zaproxy/action-baseline@de8ad967d3548d44ef623df22cf95c3b0baf8b25 with: token: ${{ secrets.GITHUB_TOKEN }} target: 'https://nest.owasp.org' allow_issue_writing: false - fail_action: false - cmd_options: '-a -r zap-report.html' + fail_action: true + cmd_options: '-a -c .zapconfig -r report_html.html' - - name: Upload ZAP report + - name: Upload report if: always() uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f with: name: zap-baseline-scan-report-${{ github.run_id }} - path: zap-report.html + path: report_html.html + timeout-minutes: 5 diff --git a/.github/workflows/run-code-ql.yaml b/.github/workflows/run-code-ql.yaml index d97bc4414b..6c756cbc1d 100644 --- a/.github/workflows/run-code-ql.yaml +++ b/.github/workflows/run-code-ql.yaml @@ -12,6 +12,8 @@ on: - main workflow_dispatch: +permissions: {} + jobs: code-ql: name: CodeQL @@ -29,7 +31,7 @@ jobs: uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 - name: Initialize CodeQL - uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 + uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 with: languages: ${{ matrix.language }} @@ -41,7 +43,7 @@ jobs: - name: Set up Node if: matrix.language == 'javascript-typescript' - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 with: node-version: 24 cache: 'pnpm' @@ -53,6 +55,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 + uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 with: category: /language:${{ matrix.language }} + timeout-minutes: 5 diff --git a/.github/workflows/update-nest-test-images.yaml b/.github/workflows/update-nest-test-images.yaml index fbc35c72d4..e56ce330ed 100644 --- a/.github/workflows/update-nest-test-images.yaml +++ b/.github/workflows/update-nest-test-images.yaml @@ -5,6 +5,8 @@ on: - cron: 30 0 * * * workflow_dispatch: +permissions: {} + env: FORCE_COLOR: 1 @@ -13,6 +15,7 @@ jobs: name: Update Nest test images if: ${{ github.repository == 'OWASP/Nest' }} permissions: + actions: write contents: read runs-on: ubuntu-latest steps: @@ -71,3 +74,4 @@ jobs: platforms: linux/amd64 push: true tags: owasp/nest:test-frontend-e2e-latest + timeout-minutes: 15 diff --git a/.gitignore b/.gitignore index 2a4a490621..b67bbd5b5b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ backend/*nest-backend-dev*.zip backend/*nest-backend-staging*.tar.gz backend/*nest-backend-staging*.zip backend/data/backup* +backend/generated_videos/ backend/staticfiles backend/zappa_settings.json design/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index df8beaa5dc..1e66e87172 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/adrienverge/yamllint.git - rev: v1.37.1 + rev: v1.38.0 hooks: - id: yamllint args: @@ -20,7 +20,7 @@ repos: - --args=--config=__GIT_WORKING_DIR__/infrastructure/.tflint.hcl - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.10 + rev: v0.14.11 hooks: - id: ruff-check args: @@ -66,6 +66,7 @@ repos: hooks: - id: check-ast - id: check-builtin-literals + - id: fix-byte-order-marker - id: check-case-conflict - id: check-executables-have-shebangs - id: check-json diff --git a/.zapconfig b/.zapconfig new file mode 100644 index 0000000000..aedaf93b93 --- /dev/null +++ b/.zapconfig @@ -0,0 +1,8 @@ +# Rule description: comment. +# rule_idACTION(URL regex pattern) + +# Information Disclosure - Suspicious Comments: source map and debug ID comments. +10027 IGNORE https://nest.owasp.(dev|org)/_next/static/chunks/[a-f0-9]{16}.js + +# PII disclosure: false positive credicard number. +10062 IGNORE https://nest.owasp.(dev|org)/sitemap.xml diff --git a/Makefile b/Makefile index 1f19aa0a53..faba777dac 100644 --- a/Makefile +++ b/Makefile @@ -55,6 +55,10 @@ run: docker compose -f docker-compose/local/compose.yaml --project-name nest-local build && \ docker compose -f docker-compose/local/compose.yaml --project-name nest-local up --remove-orphans +scan-images: \ + scan-backend-image \ + scan-frontend-image + test: \ test-nest-app diff --git a/backend/.env.example b/backend/.env.example index 2916e696d1..193d9df930 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -10,6 +10,7 @@ DJANGO_DB_NAME=None DJANGO_DB_PASSWORD=None DJANGO_DB_PORT=None DJANGO_DB_USER=None +DJANGO_ELEVENLABS_API_KEY=None DJANGO_OPEN_AI_SECRET_KEY=None DJANGO_PUBLIC_IP_ADDRESS="127.0.0.1" DJANGO_REDIS_HOST=None diff --git a/backend/Makefile b/backend/Makefile index 6deb16130c..8a470d8be8 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -5,6 +5,15 @@ include backend/apps/nest/Makefile include backend/apps/owasp/Makefile include backend/apps/slack/Makefile +SHELL := /bin/bash + +build-backend-local-image: + @DOCKER_BUILDKIT=1 docker build \ + --no-cache \ + -f docker/backend/Dockerfile \ + -t nest-backend-local \ + backend + clean-backend-dependencies: @rm -rf backend/.cache @rm -rf backend/.local @@ -17,7 +26,9 @@ clean-backend-docker: @docker container rm -f nest-cache >/dev/null 2>&1 || true @docker container rm -f nest-db >/dev/null 2>&1 || true @docker container rm -f nest-worker >/dev/null 2>&1 || true + @docker image rm -f nest-backend:base >/dev/null 2>&1 || true @docker image rm -f nest-local-backend >/dev/null 2>&1 || true + @docker image rm -f nest-snapshot-video >/dev/null 2>&1 || true @docker volume rm -f nest-local_backend-venv >/dev/null 2>&1 || true @docker volume rm -f nest-local_cache-data >/dev/null 2>&1 || true @@ -113,6 +124,14 @@ save-backup: @CMD="python manage.py dumpdata --natural-primary --natural-foreign --indent=2" $(MAKE) exec-backend-command > backend/data/backup.json @gzip backend/data/backup.json +scan-backend-image: build-backend-local-image + @trivy image \ + --config trivy.yaml \ + --docker-host $$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null) \ + --exit-code 1 \ + --severity CRITICAL,HIGH \ + nest-backend-local + shell-backend: @CMD="/bin/sh" $(MAKE) exec-backend-command-it diff --git a/backend/apps/common/eleven_labs.py b/backend/apps/common/eleven_labs.py new file mode 100644 index 0000000000..e81313980b --- /dev/null +++ b/backend/apps/common/eleven_labs.py @@ -0,0 +1,225 @@ +"""ElevenLabs API module.""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +from elevenlabs.client import ElevenLabs as ElevenLabsClient +from elevenlabs.types.voice_settings import VoiceSettings + +if TYPE_CHECKING: + from pathlib import Path + +logger: logging.Logger = logging.getLogger(__name__) + + +class ElevenLabs: + """ElevenLabs communication class.""" + + def __init__( + self, + model_id: str = "eleven_multilingual_v2", + output_format: str = "mp3_44100_128", + similarity_boost: float = 0.75, + speed: float = 1.0, + stability: float = 0.5, + style: float = 0.0, + voice_id: str = "1SM7GgM6IMuvQlz2BwM3", # cspell:disable-line + *, + use_speaker_boost: bool = True, + ) -> None: + """ElevenLabs constructor. + + Args: + model_id (str): The model to use. + output_format (str): Audio output format. + similarity_boost (float): Voice consistency (0.0-1.0). + speed (float): Speech speed (0.25-4.0, default 1.0). + stability (float): Voice stability (0.0-1.0). + style (float): Style exaggeration (0.0-1.0). + use_speaker_boost (bool): Enable speaker clarity boost. + voice_id (str): The voice ID to use. + + Raises: + ValueError: If the ElevenLabs API key is not set. + + """ + if not (api_key := os.getenv("DJANGO_ELEVENLABS_API_KEY")): + error_msg = "DJANGO_ELEVENLABS_API_KEY environment variable not set" + raise ValueError(error_msg) + + self.client = ElevenLabsClient( + api_key=api_key, + timeout=30, + ) + + self.model_id = model_id + self.output_format = output_format + self.similarity_boost = similarity_boost + self.speed = speed + self.stability = stability + self.style = style + self.use_speaker_boost = use_speaker_boost + self.voice_id = voice_id + + def set_model_id(self, model_id: str) -> ElevenLabs: + """Set model ID. + + Args: + model_id (str): The model ID. + + Returns: + ElevenLabs: The current instance. + + """ + self.model_id = model_id + + return self + + def set_output_format(self, output_format: str) -> ElevenLabs: + """Set output format. + + Args: + output_format (str): The audio output format. + + Returns: + ElevenLabs: The current instance. + + """ + self.output_format = output_format + + return self + + def set_similarity_boost(self, similarity_boost: float) -> ElevenLabs: + """Set similarity boost. + + Args: + similarity_boost (float): Voice consistency (0.0-1.0). + + Returns: + ElevenLabs: The current instance. + + """ + self.similarity_boost = similarity_boost + + return self + + def set_speed(self, speed: float) -> ElevenLabs: + """Set speech speed. + + Args: + speed (float): Speech speed (0.25-4.0, default 1.0). + + Returns: + ElevenLabs: The current instance. + + """ + self.speed = speed + + return self + + def set_stability(self, stability: float) -> ElevenLabs: + """Set stability. + + Args: + stability (float): Voice stability (0.0-1.0). + + Returns: + ElevenLabs: The current instance. + + """ + self.stability = stability + + return self + + def set_style(self, style: float) -> ElevenLabs: + """Set style exaggeration. + + Args: + style (float): Style exaggeration (0.0-1.0). + + Returns: + ElevenLabs: The current instance. + + """ + self.style = style + + return self + + def set_text(self, text: str) -> ElevenLabs: + """Set the text to convert to speech. + + Args: + text (str): The text content. + + Returns: + ElevenLabs: The current instance. + + """ + self.text = text + + return self + + def set_use_speaker_boost(self, *, use_speaker_boost: bool) -> ElevenLabs: + """Set speaker boost. + + Args: + use_speaker_boost (bool): Enable speaker clarity boost. + + Returns: + ElevenLabs: The current instance. + + """ + self.use_speaker_boost = use_speaker_boost + + return self + + def set_voice_id(self, voice_id: str) -> ElevenLabs: + """Set voice ID. + + Args: + voice_id (str): The voice ID. + + Returns: + ElevenLabs: The current instance. + + """ + self.voice_id = voice_id + + return self + + def generate(self) -> bytes: + """Generate audio from text. + + Returns: + bytes: The audio bytes. + + """ + audio_iterator = self.client.text_to_speech.convert( + model_id=self.model_id, + output_format=self.output_format, + text=self.text, + voice_id=self.voice_id, + voice_settings=VoiceSettings( + similarity_boost=self.similarity_boost, + speed=self.speed, + stability=self.stability, + style=self.style, + use_speaker_boost=self.use_speaker_boost, + ), + ) + + return b"".join(audio_iterator) + + def save(self, contents: bytes, file_path: Path) -> None: + """Save audio contents to file. + + Args: + contents (bytes): The audio content to save. + file_path (Path): Path to save the audio file. + + """ + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_bytes(contents) diff --git a/backend/apps/common/template_loader.py b/backend/apps/common/template_loader.py new file mode 100644 index 0000000000..5eadd805b2 --- /dev/null +++ b/backend/apps/common/template_loader.py @@ -0,0 +1,12 @@ +"""Jinja2 template environment loader.""" + +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader + +BASE_DIR = Path(__file__).resolve().parent.parent +SLACK_TEMPLATES_DIR = BASE_DIR / "slack" / "templates" +VIDEO_TEMPLATES_DIR = BASE_DIR / "owasp" / "templates" / "video" + +slack_env = Environment(loader=FileSystemLoader(SLACK_TEMPLATES_DIR), autoescape=True) +video_env = Environment(loader=FileSystemLoader(VIDEO_TEMPLATES_DIR), autoescape=True) diff --git a/backend/apps/github/models/organization.py b/backend/apps/github/models/organization.py index 5e2d37920e..b1fadf90d6 100644 --- a/backend/apps/github/models/organization.py +++ b/backend/apps/github/models/organization.py @@ -86,9 +86,9 @@ def get_logins(): return set(Organization.objects.values_list("login", flat=True)) @staticmethod - def bulk_save(organizations) -> None: # type: ignore[override] + def bulk_save(organizations, fields=None) -> None: # type: ignore[override] """Bulk save organizations.""" - BulkSaveModel.bulk_save(Organization, organizations) + BulkSaveModel.bulk_save(Organization, organizations, fields=fields) @staticmethod def update_data(gh_organization, *, save: bool = True) -> Organization: diff --git a/backend/apps/mentorship/api/internal/mutations/module.py b/backend/apps/mentorship/api/internal/mutations/module.py index f83ab80668..96e95c3dd4 100644 --- a/backend/apps/mentorship/api/internal/mutations/module.py +++ b/backend/apps/mentorship/api/internal/mutations/module.py @@ -20,6 +20,9 @@ from apps.nest.api.internal.permissions import IsAuthenticated from apps.owasp.models import Project +ISSUE_NOT_FOUND_MSG = "Issue not found in this module." +MODULE_NOT_FOUND_MSG = "Module not found." + logger = logging.getLogger(__name__) @@ -138,7 +141,7 @@ def assign_issue_to_user( .first() ) if module is None: - raise ObjectDoesNotExist(msg="Module not found.") + raise ObjectDoesNotExist(msg=MODULE_NOT_FOUND_MSG) mentor = Mentor.objects.filter(nest_user=user).first() if mentor is None: @@ -152,7 +155,7 @@ def assign_issue_to_user( issue = module.issues.filter(number=issue_number).first() if issue is None: - raise ObjectDoesNotExist(msg="Issue not found in this module.") + raise ObjectDoesNotExist(msg=ISSUE_NOT_FOUND_MSG) issue.assignees.add(gh_user) @@ -180,7 +183,7 @@ def unassign_issue_from_user( .first() ) if module is None: - raise ObjectDoesNotExist + raise ObjectDoesNotExist(msg=MODULE_NOT_FOUND_MSG) mentor = Mentor.objects.filter(nest_user=user).first() if mentor is None: @@ -220,7 +223,7 @@ def set_task_deadline( .first() ) if module is None: - raise ObjectDoesNotExist(msg="Module not found.") + raise ObjectDoesNotExist(msg=MODULE_NOT_FOUND_MSG) mentor = Mentor.objects.filter(nest_user=user).first() if mentor is None: @@ -235,7 +238,7 @@ def set_task_deadline( .first() ) if issue is None: - raise ObjectDoesNotExist(msg="Issue not found in this module.") + raise ObjectDoesNotExist(msg=ISSUE_NOT_FOUND_MSG) assignees = issue.assignees.all() if not assignees.exists(): @@ -282,7 +285,7 @@ def clear_task_deadline( .first() ) if module is None: - raise ObjectDoesNotExist(msg="Module not found.") + raise ObjectDoesNotExist(msg=MODULE_NOT_FOUND_MSG) mentor = Mentor.objects.filter(nest_user=user).first() if mentor is None: @@ -297,7 +300,7 @@ def clear_task_deadline( .first() ) if issue is None: - raise ObjectDoesNotExist(msg="Issue not found in this module.") + raise ObjectDoesNotExist(msg=ISSUE_NOT_FOUND_MSG) assignees = issue.assignees.all() if not assignees.exists(): @@ -328,8 +331,7 @@ def update_module(self, info: strawberry.Info, input_data: UpdateModuleInput) -> key=input_data.key, program__key=input_data.program_key ) except Module.DoesNotExist as e: - msg = "Module not found." - raise ObjectDoesNotExist(msg) from e + raise ObjectDoesNotExist(msg=MODULE_NOT_FOUND_MSG) from e try: creator_as_mentor = Mentor.objects.get(nest_user=user) diff --git a/backend/apps/nest/Makefile b/backend/apps/nest/Makefile index bef915bba0..1469e0ae9a 100644 --- a/backend/apps/nest/Makefile +++ b/backend/apps/nest/Makefile @@ -1,3 +1,11 @@ -nest-update-badges: - @echo "Updating Nest user badges" - @CMD="python manage.py nest_update_badges" $(MAKE) exec-backend-command +nest-update-project-leader-badges: + @echo "Updating OWASP Project Leader badges" + @CMD="python manage.py nest_update_project_leader_badges" $(MAKE) exec-backend-command + +nest-update-staff-badges: + @echo "Updating OWASP Staff badges" + @CMD="python manage.py nest_update_staff_badges" $(MAKE) exec-backend-command + +nest-update-badges: \ + nest-update-project-leader-badges \ + nest-update-staff-badges diff --git a/backend/apps/nest/management/commands/base_badge_command.py b/backend/apps/nest/management/commands/base_badge_command.py new file mode 100644 index 0000000000..cf335697d4 --- /dev/null +++ b/backend/apps/nest/management/commands/base_badge_command.py @@ -0,0 +1,90 @@ +"""Base command for badge management.""" + +import logging +from abc import ABC, abstractmethod + +from django.core.management.base import BaseCommand +from django.db.models import QuerySet +from django.template.defaultfilters import pluralize + +from apps.github.models.user import User +from apps.nest.models.badge import Badge +from apps.nest.models.user_badge import UserBadge + +logger = logging.getLogger(__name__) + + +class BaseBadgeCommand(BaseCommand, ABC): + """Base class for badge sync commands.""" + + badge_css_class: str | None = None + badge_description: str | None = None + badge_name: str | None = None + badge_weight: int | None = None + + @abstractmethod + def get_eligible_users(self) -> QuerySet[User]: + """Return users who should have this badge.""" + + def _log(self, message): + logger.info(message) + self.stdout.write(message) + + def handle(self, *args, **options): + if not self.badge_name: + msg = "Badge name must be set" + raise ValueError(msg) + + self.stdout.write(f"Syncing {self.badge_name}...") + + try: + badge, created = Badge.objects.get_or_create( + name=self.badge_name, + defaults={ + "css_class": self.badge_css_class, + "description": self.badge_description, + "weight": self.badge_weight, + }, + ) + + if created: + self._log(f"Created badge: '{badge.name}'") + else: + badge.css_class = self.badge_css_class + badge.description = self.badge_description + badge.weight = self.badge_weight + badge.save(update_fields=["css_class", "description", "weight"]) + + eligible_users = self.get_eligible_users() + users_to_add_ids = eligible_users.exclude( + user_badges__badge=badge, + user_badges__is_active=True, + ) + + new_badges = [ + UserBadge(user=user, badge=badge, is_active=True) for user in users_to_add_ids + ] + UserBadge.bulk_save(new_badges, fields=["is_active"]) + added_count = len(new_badges) + self._log( + f"Added '{self.badge_name}' badge to {added_count} user{pluralize(added_count)}" + ) + + users_to_remove = UserBadge.objects.filter( + badge=badge, + is_active=True, + ).exclude(user__in=eligible_users) + + removed_count = users_to_remove.count() + if removed_count: + users_to_remove.update(is_active=False) + self._log( + f"Removed '{self.badge_name}' badge from {removed_count} " + f"user{pluralize(removed_count)}" + ) + + self.stdout.write(self.style.SUCCESS(f"{self.badge_name} synced successfully")) + except Exception: + logger.exception("Failed to sync %s", self.badge_name) + self.stdout.write(self.style.ERROR(f"{self.badge_name} sync failed")) + raise diff --git a/backend/apps/nest/management/commands/nest_update_badges.py b/backend/apps/nest/management/commands/nest_update_badges.py deleted file mode 100644 index 485469c90d..0000000000 --- a/backend/apps/nest/management/commands/nest_update_badges.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Management command to sync badges for users based on their roles/attributes.""" - -import logging - -from django.contrib.contenttypes.models import ContentType -from django.core.management.base import BaseCommand - -from apps.github.models.user import User -from apps.nest.models.badge import Badge -from apps.nest.models.user_badge import UserBadge -from apps.owasp.models.entity_member import EntityMember -from apps.owasp.models.project import Project - -logger = logging.getLogger(__name__) - -OWASP_STAFF_BADGE_NAME = "OWASP Staff" -OWASP_PROJECT_LEADER_BADGE_NAME = "OWASP Project Leader" - - -class Command(BaseCommand): - """Sync badges for users based on their roles and attributes.""" - - help = "Sync badges for users based on their roles and attributes" - - def handle(self, *args, **options): - """Execute the command.""" - self.stdout.write("Syncing user badges...") - self.update_owasp_staff_badge() - self.update_owasp_project_leader_badge() - self.stdout.write(self.style.SUCCESS("User badges sync completed")) - - def update_owasp_staff_badge(self): - """Sync OWASP Staff badge for users.""" - # Get or create the OWASP Staff badge - badge, created = Badge.objects.get_or_create( - name=OWASP_STAFF_BADGE_NAME, - defaults={ - "description": "Official OWASP Staff", - "css_class": "fa-user-shield", - "weight": 100, # High weight for importance - }, - ) - - if created: - logger.info("Created '%s' badge", OWASP_STAFF_BADGE_NAME) - self.stdout.write(f"Created badge: {badge.name}") - - # Assign badge to employees who don't have it. - employees_without_badge = User.objects.filter( - is_owasp_staff=True, - ).exclude( - user_badges__badge=badge, - ) - count = employees_without_badge.count() - - if count: - for user in employees_without_badge: - user_badge, created = UserBadge.objects.get_or_create(user=user, badge=badge) - if not user_badge.is_active: - user_badge.is_active = True - user_badge.save(update_fields=["is_active"]) - - logger.info("Added '%s' badge to %s users", OWASP_STAFF_BADGE_NAME, count) - self.stdout.write(f"Added badge to {count} employees") - - # Remove badge from non-OWASP employees. - non_employees = User.objects.filter( - is_owasp_staff=False, - user_badges__badge=badge, - ).distinct() - removed_count = non_employees.count() - - if removed_count: - UserBadge.objects.filter( - user_id__in=non_employees.values_list("id", flat=True), - badge=badge, - ).update(is_active=False) - - logger.info("Removed '%s' badge from %s users", OWASP_STAFF_BADGE_NAME, removed_count) - self.stdout.write(f"Removed badge from {removed_count} non-employees") - - def update_owasp_project_leader_badge(self): - """Sync OWASP Project Leader badge for users.""" - badge, created = Badge.objects.get_or_create( - name=OWASP_PROJECT_LEADER_BADGE_NAME, - defaults={ - "description": "Official OWASP Project Leader", - "css_class": "fa-user-shield", - "weight": 90, - }, - ) - - if created: - logger.info("Created '%s' badge", OWASP_PROJECT_LEADER_BADGE_NAME) - self.stdout.write(f"Created badge: {badge.name}") - - project_leaders_without_badge = ( - User.objects.filter( - id__in=EntityMember.objects.filter( - entity_type=ContentType.objects.get_for_model(Project), - role=EntityMember.Role.LEADER, - is_active=True, - is_reviewed=True, - member__isnull=False, - ).values_list("member_id", flat=True), - ) - .distinct() - .exclude( - user_badges__badge=badge, - ) - ) - - count = project_leaders_without_badge.count() - - if count: - for user in project_leaders_without_badge: - user_badge, created = UserBadge.objects.get_or_create( - user=user, - badge=badge, - ) - if not user_badge.is_active: - user_badge.is_active = True - user_badge.save(update_fields=["is_active"]) - - logger.info("Added '%s' badge to %s users", OWASP_PROJECT_LEADER_BADGE_NAME, count) - self.stdout.write(f"Added badge to {count} project leaders") - - # Remove badge from users who are no longer project leaders. - current_leader_ids = ( - EntityMember.objects.filter( - entity_type=ContentType.objects.get_for_model(Project), - role=EntityMember.Role.LEADER, - is_active=True, - is_reviewed=True, - member__isnull=False, - ) - .values_list("member_id", flat=True) - .distinct() - ) - - non_leaders = UserBadge.objects.filter( - badge=badge, - is_active=True, - ).exclude(user_id__in=current_leader_ids) - removed_count = non_leaders.count() - - if removed_count: - non_leaders.update(is_active=False) - - logger.info( - "Removed '%s' badge from %s users", OWASP_PROJECT_LEADER_BADGE_NAME, removed_count - ) - self.stdout.write(f"Removed badge from {removed_count} non-project leaders") diff --git a/backend/apps/nest/management/commands/nest_update_project_leader_badges.py b/backend/apps/nest/management/commands/nest_update_project_leader_badges.py new file mode 100644 index 0000000000..346f277750 --- /dev/null +++ b/backend/apps/nest/management/commands/nest_update_project_leader_badges.py @@ -0,0 +1,29 @@ +"""Sync OWASP Project Leader badges.""" + +from django.contrib.contenttypes.models import ContentType +from django.db.models import QuerySet + +from apps.github.models.user import User +from apps.nest.management.commands.base_badge_command import BaseBadgeCommand +from apps.owasp.models.entity_member import EntityMember +from apps.owasp.models.project import Project + + +class Command(BaseBadgeCommand): + help = "Sync OWASP Project Leader badges" + + badge_css_class = "fa-user-shield" + badge_description = "Official OWASP Project Leader" + badge_name = "OWASP Project Leader" + badge_weight = 90 + + def get_eligible_users(self) -> QuerySet[User]: + return User.objects.filter( + id__in=EntityMember.objects.filter( + entity_type=ContentType.objects.get_for_model(Project), + is_active=True, + is_reviewed=True, + member__isnull=False, + role=EntityMember.Role.LEADER, + ).values_list("member_id", flat=True) + ).distinct() diff --git a/backend/apps/nest/management/commands/nest_update_staff_badges.py b/backend/apps/nest/management/commands/nest_update_staff_badges.py new file mode 100644 index 0000000000..12080c2816 --- /dev/null +++ b/backend/apps/nest/management/commands/nest_update_staff_badges.py @@ -0,0 +1,18 @@ +"""Sync OWASP Staff badges.""" + +from django.db.models import QuerySet + +from apps.github.models.user import User +from apps.nest.management.commands.base_badge_command import BaseBadgeCommand + + +class Command(BaseBadgeCommand): + help = "Sync OWASP Staff badges" + + badge_css_class = "fa-user-shield" + badge_description = "Official OWASP Staff" + badge_name = "OWASP Staff" + badge_weight = 100 + + def get_eligible_users(self) -> QuerySet[User]: + return User.objects.filter(is_owasp_staff=True) diff --git a/backend/apps/nest/models/user_badge.py b/backend/apps/nest/models/user_badge.py index dc821a2875..d034cba97f 100644 --- a/backend/apps/nest/models/user_badge.py +++ b/backend/apps/nest/models/user_badge.py @@ -48,3 +48,8 @@ class Meta: def __str__(self) -> str: """Return a human-readable representation of the user badge.""" return f"{self.user.login} - {self.badge.name}" + + @staticmethod + def bulk_save(user_badges, fields=None) -> None: # type: ignore[override] + """Bulk save user badges.""" + BulkSaveModel.bulk_save(UserBadge, user_badges, fields=fields) diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index e88a5b7325..75bdba3b70 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -27,6 +27,22 @@ owasp-enrich-projects: @echo "Enriching OWASP projects" @CMD="python manage.py owasp_enrich_projects" $(MAKE) exec-backend-command +owasp-generate-community-snapshot-video: + @docker build \ + -f docker/backend/Dockerfile backend/ \ + -t nest-backend:base + @docker build \ + -f docker/backend/Dockerfile.video backend/ \ + -t nest-snapshot-video + @mkdir -p backend/generated_videos + @docker run \ + --env-file backend/.env \ + --mount type=bind,src="$(PWD)/backend/generated_videos",dst=/home/owasp/generated_videos \ + --network nest-local_nest-network \ + --rm \ + nest-snapshot-video \ + python manage.py owasp_generate_community_snapshot_video $(snapshot_key) /home/owasp/generated_videos + owasp-process-snapshots: @echo "Processing OWASP snapshots" @CMD="python manage.py owasp_process_snapshots" $(MAKE) exec-backend-command diff --git a/backend/apps/owasp/management/commands/owasp_generate_community_snapshot_video.py b/backend/apps/owasp/management/commands/owasp_generate_community_snapshot_video.py new file mode 100644 index 0000000000..ae609b8c62 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_generate_community_snapshot_video.py @@ -0,0 +1,82 @@ +"""A command to generate OWASP community snapshot video.""" + +import logging +import tempfile +from pathlib import Path + +from django.core.management.base import BaseCommand + +from apps.owasp.models.snapshot import Snapshot +from apps.owasp.video import SlideBuilder, VideoGenerator + +logger = logging.getLogger(__name__) + +VIDEO_DIR_PREFIX = "community_snapshot_video" + + +class Command(BaseCommand): + help = "Generate OWASP community snapshot video." + + def add_arguments(self, parser) -> None: + """Add command-line arguments to the parser. + + Args: + parser (argparse.ArgumentParser): The argument parser instance. + + """ + parser.add_argument( + "snapshot_key", + type=str, + help="Snapshot key (e.g., '2025-06' for month or '2025' for year).", + ) + parser.add_argument( + "output_dir", + type=str, + help="Directory to save the video to.", + ) + + def handle(self, *args, **options) -> None: + """Handle the command execution.""" + snapshot_key = options["snapshot_key"] + output_dir = options["output_dir"] + + snapshots = Snapshot.objects.filter( + key__startswith=snapshot_key, status=Snapshot.Status.COMPLETED + ).order_by("start_at") + + if not snapshots: + logger.error("No completed snapshots found for key: %s", snapshot_key) + return + + self.stdout.write(f"Generating a video for snapshot: {snapshot_key}") + + dir_name = f"{VIDEO_DIR_PREFIX}_{snapshot_key}" + video_output_dir = Path(output_dir) / dir_name + temp_output_dir = Path(tempfile.gettempdir()) / dir_name + video_output_dir.mkdir(parents=True, exist_ok=True) + temp_output_dir.mkdir(parents=True, exist_ok=True) + + slide_builder = SlideBuilder(snapshots, output_dir=temp_output_dir) + generator = VideoGenerator() + + slides_to_add = [ + slide_builder.add_intro_slide(), + slide_builder.add_sponsors_slide(), + slide_builder.add_projects_slide(), + slide_builder.add_chapters_slide(), + slide_builder.add_releases_slide(), + slide_builder.add_thank_you_slide(), + ] + + for slide in slides_to_add: + if slide is not None: + generator.append_slide(slide) + + video_path = generator.generate_video(video_output_dir, f"{snapshot_key}_snapshot") + generator.cleanup() + + self.stdout.write( + self.style.SUCCESS( + f"Generated video at backend/generated_videos/{dir_name}/{video_path.name}" + ) + ) diff --git a/backend/apps/owasp/models/event.py b/backend/apps/owasp/models/event.py index 69385abb40..bc67f49117 100644 --- a/backend/apps/owasp/models/event.py +++ b/backend/apps/owasp/models/event.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING from apps.core.models.prompt import Prompt @@ -9,6 +10,8 @@ if TYPE_CHECKING: # pragma: no cover from datetime import date +from datetime import datetime + from dateutil import parser from django.db import models from django.db.models import Q @@ -68,7 +71,7 @@ def __str__(self) -> str: def upcoming_events(): """Get upcoming events. - Returns + Returns: QuerySet: A queryset of upcoming Event instances ordered by start date. """ @@ -103,7 +106,7 @@ def bulk_save( # type: ignore[override] # TODO(arkid15r): refactor this when there is a chance. @staticmethod - def parse_dates(dates: str, start_date: date) -> date | None: + def parse_dates(dates: str, start_date: date) -> date | None: # noqa: PLR0911 """Parse event dates. Args: @@ -117,41 +120,41 @@ def parse_dates(dates: str, start_date: date) -> date | None: if not dates: return None - # Handle single-day events (e.g., "March 14, 2025") - if "," in dates and "-" not in dates: - try: - return parser.parse(dates).date() - except ValueError: - return None - - # Handle date ranges (e.g., "May 26-30, 2025" or "November 2-6, 2026") - if "-" in dates and "," in dates: - try: - # Split the date range into parts - date_part, year_part = dates.rsplit(", ", 1) - parts = date_part.split() - - # Extract month and day range - month = parts[0] - day_range = "".join(parts[1:]) - - # Extract end day from the range - end_day = int(day_range.split("-")[-1]) - - # Parse the year - year = int(year_part.strip()) + max_day_length = 2 - # Use the start_date to determine the month if provided - if start_date: - start_date_parsed = start_date - month = start_date_parsed.strftime("%B") # Full month name (e.g., "May") + # Normalize dashes (hyphen, en-dash, em-dash). + clean_dates = re.sub(r"[\-\–\—]", "-", dates) # noqa: RUF001 - # Parse the full end date string - return parser.parse(f"{month} {end_day}, {year}").date() - except (ValueError, IndexError, AttributeError): + # Guard against ISO-like single dates (e.g. "2025-05-26"). + if re.match(r"^\d{4}-\d{2}-\d{2}$", clean_dates.strip()): + try: + return parser.parse(clean_dates.strip()).date() + except (TypeError, ValueError): return None - return None + try: + if "-" in clean_dates: + parts = clean_dates.split("-") + end_str = parts[-1].strip() + + day_match = re.match(r"^(\d{1,2})", end_str) + if day_match: + day_value = day_match.group(1) + if len(end_str) <= max_day_length or "," in end_str: + return start_date.replace(day=int(day_value)) + + end_date = parser.parse( + end_str, default=datetime.combine(start_date, datetime.min.time()) + ).date() + # Handle year crossover: if end_date is before start_date, assume next year. + if end_date < start_date: + end_date = end_date.replace(year=end_date.year + 1) + return end_date + + return parser.parse(clean_dates.strip()).date() + + except (IndexError, OverflowError, TypeError, ValueError): + return None @staticmethod def update_data(category, data, *, save: bool = True) -> Event | None: @@ -174,7 +177,7 @@ def update_data(category, data, *, save: bool = True) -> Event | None: try: event.from_dict(category, data) - except KeyError: # No start date. + except KeyError: return None if save: diff --git a/backend/apps/owasp/templates/video/base.jinja b/backend/apps/owasp/templates/video/base.jinja new file mode 100644 index 0000000000..74128a76cc --- /dev/null +++ b/backend/apps/owasp/templates/video/base.jinja @@ -0,0 +1,252 @@ + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/backend/apps/owasp/templates/video/slides/chapters.jinja b/backend/apps/owasp/templates/video/slides/chapters.jinja new file mode 100644 index 0000000000..3f478d4f18 --- /dev/null +++ b/backend/apps/owasp/templates/video/slides/chapters.jinja @@ -0,0 +1,17 @@ +{% extends "base.jinja" %} +{% block content %} +
+

{{ title }}

+
+ {% for chapter in chapters[:24] %} +
+
{{ chapter.name }}
+
{{ chapter.location }}
+
+ {% endfor %} +
+ {% if chapters|length > 24 %} +

+ {{ chapters|length - 24 }} more chapters not shown

+ {% endif %} +
+{% endblock %} diff --git a/backend/apps/owasp/templates/video/slides/intro.jinja b/backend/apps/owasp/templates/video/slides/intro.jinja new file mode 100644 index 0000000000..0bf4940051 --- /dev/null +++ b/backend/apps/owasp/templates/video/slides/intro.jinja @@ -0,0 +1,7 @@ +{% extends "base.jinja" %} +{% block content %} +
+

{{ name }} OWASP Community Snapshot

+

{{ formatted_start }} - {{ formatted_end }}

+
+{% endblock %} diff --git a/backend/apps/owasp/templates/video/slides/projects.jinja b/backend/apps/owasp/templates/video/slides/projects.jinja new file mode 100644 index 0000000000..3bc6a299ac --- /dev/null +++ b/backend/apps/owasp/templates/video/slides/projects.jinja @@ -0,0 +1,20 @@ +{% extends "base.jinja" %} +{% block content %} +
+

{{ title }}

+
+ {% for project in projects[:16] %} +
+
{{ project.name }}
+
+ Stars: {{ project.stars_count }} • Forks: {{ project.forks_count }} • Contributors: {{ project.contributors_count }} +
+ {% if project.leaders %}
Leaders: {{ project.leaders }}
{% endif %} +
+ {% endfor %} +
+ {% if projects|length > 16 %} +

+ {{ projects|length - 16 }} more projects not shown

+ {% endif %} +
+{% endblock %} diff --git a/backend/apps/owasp/templates/video/slides/releases.jinja b/backend/apps/owasp/templates/video/slides/releases.jinja new file mode 100644 index 0000000000..9b74e4c4f4 --- /dev/null +++ b/backend/apps/owasp/templates/video/slides/releases.jinja @@ -0,0 +1,18 @@ +{% extends "base.jinja" %} +{% block content %} +
+

{{ title }}

+
+ {% for release in releases %} +
+ {{ release.name }} +
+
+ {{ release.count }} +
+
+ {% endfor %} +
+
+{% endblock %} diff --git a/backend/apps/owasp/templates/video/slides/sponsors.jinja b/backend/apps/owasp/templates/video/slides/sponsors.jinja new file mode 100644 index 0000000000..a52f047029 --- /dev/null +++ b/backend/apps/owasp/templates/video/slides/sponsors.jinja @@ -0,0 +1,15 @@ +{% extends "base.jinja" %} +{% block content %} +
+

{{ title }}

+
+ {% for sponsor in sponsors %} +
+ {{ sponsor.name }} +
+ {% endfor %} +
+
+{% endblock %} diff --git a/backend/apps/owasp/templates/video/slides/thank_you.jinja b/backend/apps/owasp/templates/video/slides/thank_you.jinja new file mode 100644 index 0000000000..8561f40289 --- /dev/null +++ b/backend/apps/owasp/templates/video/slides/thank_you.jinja @@ -0,0 +1,11 @@ +{% extends "base.jinja" %} +{% block content %} +
+

Thank You!

+
+

nest.owasp.org

+

owasp.org

+
+

*Audio generated using AI

+
+{% endblock %} diff --git a/backend/apps/owasp/video.py b/backend/apps/owasp/video.py new file mode 100644 index 0000000000..b39f3933ba --- /dev/null +++ b/backend/apps/owasp/video.py @@ -0,0 +1,388 @@ +"""OWASP Community Snapshots Video Generator.""" + +import logging +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import ffmpeg +import pypdfium2 as pdfium +from django.db.models import QuerySet +from weasyprint import HTML + +from apps.common.eleven_labs import ElevenLabs +from apps.common.template_loader import video_env +from apps.github.models.release import Release +from apps.owasp.models.chapter import Chapter +from apps.owasp.models.project import Project +from apps.owasp.models.snapshot import Snapshot +from apps.owasp.models.sponsor import Sponsor + +logger = logging.getLogger(__name__) + +AUDIO_CODEC = "copy" +AUDIO_EXTENSION = "mp3" +ELEVENLABS_SPEED = 0.85 +IMAGE_EXTENSION = "png" +IMAGE_FORMAT = "PNG" +PDF_RENDER_SCALE = 2 +RELEASES_LIMIT = 12 +TRANSCRIPT_NAMES_LIMIT = 3 +VIDEO_EXTENSION = "mkv" +VIDEO_FRAMERATE = 60 + + +@dataclass +class Slide: + """Represents a single video slide.""" + + context: dict[str, Any] + name: str + output_dir: Path + template_name: str + transcript: str + + @property + def audio_path(self) -> Path: + """Return audio file path.""" + return self.output_dir / f"{self.name}.{AUDIO_EXTENSION}" + + @property + def image_path(self) -> Path: + """Return image file path.""" + return self.output_dir / f"{self.name}.{IMAGE_EXTENSION}" + + @property + def video_path(self) -> Path: + """Return video file path.""" + return self.output_dir / f"{self.name}.{VIDEO_EXTENSION}" + + def render_and_save_image(self) -> None: + """Render an HTML template as an image.""" + page = None + pdf = None + try: + html = HTML(string=video_env.get_template(self.template_name).render(self.context)) + + pdf = pdfium.PdfDocument(html.write_pdf()) + page = pdf[0] + bitmap = page.render(scale=PDF_RENDER_SCALE) + pil_image = bitmap.to_pil() + + self.image_path.parent.mkdir(parents=True, exist_ok=True) + pil_image.save(self.image_path, IMAGE_FORMAT) + finally: + if page is not None: + page.close() + if pdf is not None: + pdf.close() + + def generate_and_save_audio(self, eleven_labs: ElevenLabs) -> None: + """Generate audio for the transcript. + + Args: + eleven_labs (ElevenLabs): ElevenLabs client instance. + + """ + eleven_labs.set_text(self.transcript) + audio = eleven_labs.generate() + eleven_labs.save(audio, self.audio_path) + + def generate_and_save_video(self) -> None: + """Generate video for the slide. + + Raises: + ffmpeg.Error: If video generation fails. + + """ + audio_stream = ffmpeg.input(self.audio_path) + image_stream = ffmpeg.input(self.image_path, loop=1, framerate=VIDEO_FRAMERATE) + + try: + ffmpeg.output( + image_stream, + audio_stream, + filename=self.video_path, + acodec=AUDIO_CODEC, + vcodec="libx264", + pix_fmt="yuv420p", + shortest=None, + ).run(overwrite_output=True, capture_stdout=True, capture_stderr=True) + + except ffmpeg.Error: + logger.exception("Error generating video for %s:", self.name) + raise + + +class SlideBuilder: + """Slide builder for community snapshot video.""" + + def __init__(self, snapshots: QuerySet[Snapshot], output_dir: Path) -> None: + """Initialize SlideBuilder. + + Args: + snapshots (QuerySet[Snapshot]): QuerySet of completed snapshots to process. + output_dir (Path): Directory where slide assets will be saved. + + """ + self.snapshots = snapshots + self.output_dir = output_dir + + @staticmethod + def format_names_for_transcript(names: list[str]) -> str: + """Format a list of names for transcript, stripping 'OWASP ' prefix. + + Args: + names (list[str]): List of project/chapter names to format. + + Returns: + str: Formatted string with comma-separated names and 'and' before the last name + or empty string if list is empty. + + """ + cleaned = [name.replace("OWASP ", "") for name in names[:TRANSCRIPT_NAMES_LIMIT]] + if len(cleaned) > 1: + return f"{', '.join(cleaned[:-1])}, and {cleaned[-1]}" + if cleaned: + return cleaned[0] + return "" + + def add_intro_slide(self) -> Slide: + """Add an introduction slide.""" + first = self.snapshots.first() + last = self.snapshots.last() + name = first.start_at.strftime("%B") if first == last else first.start_at.strftime("%Y") + + return Slide( + context={ + "formatted_start": first.start_at.strftime("%b %d"), + "formatted_end": last.end_at.strftime("%b %d, %Y"), + "name": name, + }, + name="intro", + output_dir=self.output_dir, + template_name="slides/intro.jinja", + transcript=f"Hey, everyone... Welcome to the Owasp Nest {name} community snapshot...", + ) + + def add_sponsors_slide(self) -> Slide: + """Add a sponsors slide.""" + sponsors = [ + {"image_url": s.image_url, "name": s.name} + for s in sorted( + Sponsor.objects.all(), + key=lambda x: { + Sponsor.SponsorType.DIAMOND: 1, + Sponsor.SponsorType.PLATINUM: 2, + Sponsor.SponsorType.GOLD: 3, + Sponsor.SponsorType.SILVER: 4, + Sponsor.SponsorType.SUPPORTER: 5, + Sponsor.SponsorType.NOT_SPONSOR: 6, + }[x.sponsor_type], + ) + ] + + return Slide( + context={ + "sponsors": sponsors, + "title": "OWASP Foundation Sponsors", + }, + name="sponsors", + output_dir=self.output_dir, + template_name="slides/sponsors.jinja", + transcript="A HUGE thanks to the sponsors who make our work possible!", + ) + + def add_projects_slide(self) -> Slide | None: + """Add a projects slide.""" + new_projects = ( + Project.objects.filter(snapshots__in=self.snapshots) + .distinct() + .order_by("-stars_count") + ) + if not new_projects.exists(): + return None + + project_count = new_projects.count() + projects_data = [ + { + "contributors_count": project.contributors_count, + "forks_count": project.forks_count, + "leaders": ", ".join(project.leaders_raw) if project.leaders_raw else None, + "name": project.name, + "stars_count": project.stars_count, + } + for project in new_projects + ] + + formatted_project_names = self.format_names_for_transcript([p.name for p in new_projects]) + + return Slide( + context={ + "projects": projects_data, + "title": f"{project_count} New Projects", + }, + name="projects", + output_dir=self.output_dir, + template_name="slides/projects.jinja", + transcript=f"So... this time we've welcomed {project_count} new projects! " + f"including Owasp {formatted_project_names}.", + ) + + def add_chapters_slide(self) -> Slide | None: + """Add a chapters slide.""" + new_chapters = ( + Chapter.objects.filter(snapshots__in=self.snapshots).distinct().order_by("name") + ) + if not new_chapters.exists(): + return None + + chapter_count = new_chapters.count() + chapters = [ + { + "location": c.suggested_location, + "name": c.name, + } + for c in new_chapters + ] + + formatted_names = self.format_names_for_transcript([c.name for c in new_chapters]) + + return Slide( + context={ + "chapters": chapters, + "title": f"{chapter_count} New Chapters", + }, + output_dir=self.output_dir, + name="chapters", + template_name="slides/chapters.jinja", + transcript=f"We've also welcomed {chapter_count} new chapters! " + f"including {formatted_names}.", + ) + + def add_releases_slide(self) -> Slide | None: + """Add a releases slide.""" + releases = Release.objects.filter(snapshots__in=self.snapshots).distinct() + if not releases.exists(): + return None + + project_counts: Counter = Counter() + for release in releases: + if not release.repository: + continue + project = Project.objects.filter(repositories=release.repository).first() + if project: + project_counts[project.name] += 1 + + top_projects = project_counts.most_common(RELEASES_LIMIT) + if not top_projects: + return None + + max_count = top_projects[0][1] if top_projects else 1 + releases_data = [ + { + "bar_width": int((count / max_count) * 100), + "count": count, + "name": name, + } + for name, count in top_projects + ] + + release_count = releases.count() + formatted_names = self.format_names_for_transcript([name for name, _ in top_projects]) + + return Slide( + context={ + "releases": releases_data, + "title": f"{release_count} New Releases", + }, + name="releases", + output_dir=self.output_dir, + template_name="slides/releases.jinja", + transcript=f"There were {release_count} new releases across our projects this time... " + f"with {formatted_names} leading the way.", + ) + + def add_thank_you_slide(self) -> Slide: + """Add a thank you slide.""" + return Slide( + context={}, + name="thank_you", + output_dir=self.output_dir, + template_name="slides/thank_you.jinja", + transcript="Thanks for tuning in!... Visit Nest dot Owasp dot org " + "for more community updates!... See you next time!!", + ) + + +class VideoGenerator: + """Video generator for community snapshot.""" + + def __init__(self) -> None: + """Initialize Video Generator.""" + self.eleven_labs = ElevenLabs(speed=ELEVENLABS_SPEED) + self.slides: list[Slide] = [] + + def append_slide(self, slide: Slide) -> None: + """Append a slide to list. + + Args: + slide (Slide): The slide to add to the video generation queue. + + """ + self.slides.append(slide) + + def generate_video(self, output_dir: Path, filename: str) -> Path: + """Generate video. + + Args: + output_dir (Path): The directory to save the video in. + filename (str): The filename without extension. + + Returns: + Path: The full path to the generated video. + + """ + for slide in self.slides: + slide.render_and_save_image() + slide.generate_and_save_audio(self.eleven_labs) + slide.generate_and_save_video() + + output_path = output_dir / f"{filename}.{VIDEO_EXTENSION}" + self.merge_videos(output_path) + + return output_path + + def merge_videos(self, output_path: Path) -> None: + """Concatenate video and audio streams from all slides into a single output file. + + Args: + output_path (Path): Path where the final merged video will be saved. + + Raises: + ffmpeg.Error: If video merging fails. + + """ + inputs = [ffmpeg.input(str(slide.video_path)) for slide in self.slides] + + audio_streams = [inp.audio for inp in inputs] + video_streams = [inp.video for inp in inputs] + + merged_audio = ffmpeg.concat(*audio_streams, v=0, a=1) + merged_video = ffmpeg.concat(*video_streams, v=1, a=0) + + try: + ffmpeg.output(merged_video, merged_audio, str(output_path)).overwrite_output().run( + capture_stdout=True, capture_stderr=True + ) + except ffmpeg.Error: + logger.exception("Error merging slide videos into final output") + raise + + def cleanup(self) -> None: + """Remove intermediate files for all slides (audio, images, per-slide videos).""" + for slide in self.slides: + for path in (slide.audio_path, slide.image_path, slide.video_path): + if path.exists(): + path.unlink() diff --git a/backend/apps/slack/commands/command.py b/backend/apps/slack/commands/command.py index 92b4ec256d..548cf8e65c 100644 --- a/backend/apps/slack/commands/command.py +++ b/backend/apps/slack/commands/command.py @@ -7,11 +7,11 @@ from jinja2 import Template from apps.common.constants import NL +from apps.common.template_loader import slack_env as env from apps.common.utils import convert_to_snake_case from apps.slack.apps import SlackConfig from apps.slack.blocks import DIVIDER, SECTION_BREAK, markdown from apps.slack.constants import FEEDBACK_SHARING_INVITE, NEST_BOT_NAME -from apps.slack.template_loader import env from apps.slack.utils import get_text logger = logging.getLogger(__name__) diff --git a/backend/apps/slack/events/event.py b/backend/apps/slack/events/event.py index 1902c1a719..46ba633292 100644 --- a/backend/apps/slack/events/event.py +++ b/backend/apps/slack/events/event.py @@ -10,11 +10,11 @@ from slack_sdk.errors import SlackApiError from apps.common.constants import NL, TAB +from apps.common.template_loader import slack_env as env from apps.common.utils import convert_to_snake_case from apps.slack.apps import SlackConfig from apps.slack.blocks import DIVIDER, SECTION_BREAK, markdown from apps.slack.constants import FEEDBACK_SHARING_INVITE, NEST_BOT_NAME -from apps.slack.template_loader import env from apps.slack.utils import get_text logger = logging.getLogger(__name__) diff --git a/backend/apps/slack/template_loader.py b/backend/apps/slack/template_loader.py deleted file mode 100644 index 0406198430..0000000000 --- a/backend/apps/slack/template_loader.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Jinja2 template environment loader for Slack bot commands.""" - -from pathlib import Path - -from jinja2 import Environment, FileSystemLoader - -BASE_DIR = Path(__file__).resolve().parent -TEMPLATES_DIR = BASE_DIR / "templates" - -env = Environment(loader=FileSystemLoader(TEMPLATES_DIR), autoescape=True) diff --git a/backend/data/nest.json.gz b/backend/data/nest.json.gz index 69f10d28f8..ae49e43887 100644 Binary files a/backend/data/nest.json.gz and b/backend/data/nest.json.gz differ diff --git a/backend/generated_videos/community_snapshot_video_2025/2025_snapshot.mkv b/backend/generated_videos/community_snapshot_video_2025/2025_snapshot.mkv new file mode 100644 index 0000000000..e56e51725d Binary files /dev/null and b/backend/generated_videos/community_snapshot_video_2025/2025_snapshot.mkv differ diff --git a/backend/poetry.lock b/backend/poetry.lock index b5d49659cb..dab5091e13 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -171,14 +171,14 @@ frozenlist = ">=1.1.0" [[package]] name = "algoliasearch" -version = "4.35.2" +version = "4.35.3" description = "A fully-featured and blazing-fast Python API client to interact with Algolia." optional = false python-versions = ">=3.8.1" groups = ["main"] files = [ - {file = "algoliasearch-4.35.2-py3-none-any.whl", hash = "sha256:bb079506dd6527d26640929e4d35fafb64338437444dc23efb2ebbf98061e977"}, - {file = "algoliasearch-4.35.2.tar.gz", hash = "sha256:6108a4195dbf07a3762a026907e486228692e8026b9db568c9093c280235fd5d"}, + {file = "algoliasearch-4.35.3-py3-none-any.whl", hash = "sha256:5cfd74be60f4844677e5a95737b04044f05815f744447c5ec0eec9459c064c45"}, + {file = "algoliasearch-4.35.3.tar.gz", hash = "sha256:14ece549317cb6ca6c435cc1324c2ab4f5425598bc1b35fa48b1d1d01a0ca39c"}, ] [package.dependencies] @@ -210,7 +210,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -218,14 +218,14 @@ files = [ [[package]] name = "anyio" -version = "4.12.0" +version = "4.12.1" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "video"] files = [ - {file = "anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb"}, - {file = "anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0"}, + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, ] [package.dependencies] @@ -290,18 +290,18 @@ files = [ [[package]] name = "boto3" -version = "1.42.21" +version = "1.42.29" description = "The AWS SDK for Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "boto3-1.42.21-py3-none-any.whl", hash = "sha256:1885f252d715a5810bb4e0c5bbebfa8e9018b025febf5be3d58540626e7b43d2"}, - {file = "boto3-1.42.21.tar.gz", hash = "sha256:9b92943d253bc837323079fe88460e741cb2eb80abaebcb558b2446bdb4049d6"}, + {file = "boto3-1.42.29-py3-none-any.whl", hash = "sha256:6c9c4dece67bf72d82ba7dff48e33a56a87cdf9b16c8887f88ca7789a95d3317"}, + {file = "boto3-1.42.29.tar.gz", hash = "sha256:247e54f24116ad6792cfc14b274288383af3ec3433b0547da8a14a8bd6e81950"}, ] [package.dependencies] -botocore = ">=1.42.21,<1.43.0" +botocore = ">=1.42.29,<1.43.0" jmespath = ">=0.7.1,<2.0.0" s3transfer = ">=0.16.0,<0.17.0" @@ -310,14 +310,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.42.21" +version = "1.42.29" description = "Low-level, data-driven core of boto 3." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "botocore-1.42.21-py3-none-any.whl", hash = "sha256:6b59973a3ba8c3cfd5123f2656fef2339beee9f6483b8bc12bb00c5453ea2c6d"}, - {file = "botocore-1.42.21.tar.gz", hash = "sha256:db8f99d186156da42feb4fd2098017383d9b155097290cc53da7258f6e652c39"}, + {file = "botocore-1.42.29-py3-none-any.whl", hash = "sha256:b45f8dfc1de5106a9d040c5612f267582e68b2b2c5237477dff85c707c1c5d11"}, + {file = "botocore-1.42.29.tar.gz", hash = "sha256:0fe869227a1dfe818f691a31b8c1693e39be8056a6dff5d6d4b3fc5b3a5e7d42"}, ] [package.dependencies] @@ -328,13 +328,148 @@ urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version > [package.extras] crt = ["awscrt (==0.29.2)"] +[[package]] +name = "brotli" +version = "1.2.0" +description = "Python bindings for the Brotli compression library" +optional = false +python-versions = "*" +groups = ["video"] +markers = "platform_python_implementation == \"CPython\"" +files = [ + {file = "brotli-1.2.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:99cfa69813d79492f0e5d52a20fd18395bc82e671d5d40bd5a91d13e75e468e8"}, + {file = "brotli-1.2.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:3ebe801e0f4e56d17cd386ca6600573e3706ce1845376307f5d2cbd32149b69a"}, + {file = "brotli-1.2.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:a387225a67f619bf16bd504c37655930f910eb03675730fc2ad69d3d8b5e7e92"}, + {file = "brotli-1.2.0-cp27-cp27m-win32.whl", hash = "sha256:b908d1a7b28bc72dfb743be0d4d3f8931f8309f810af66c906ae6cd4127c93cb"}, + {file = "brotli-1.2.0-cp27-cp27m-win_amd64.whl", hash = "sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f"}, + {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:7e9053f5fb4e0dfab89243079b3e217f2aea4085e4d58c5c06115fc34823707f"}, + {file = "brotli-1.2.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4735a10f738cb5516905a121f32b24ce196ab82cfc1e4ba2e3ad1b371085fd46"}, + {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e"}, + {file = "brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947"}, + {file = "brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d"}, + {file = "brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1"}, + {file = "brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997"}, + {file = "brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196"}, + {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744"}, + {file = "brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe"}, + {file = "brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3"}, + {file = "brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae"}, + {file = "brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03"}, + {file = "brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24"}, + {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84"}, + {file = "brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca"}, + {file = "brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7"}, + {file = "brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036"}, + {file = "brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161"}, + {file = "brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44"}, + {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab"}, + {file = "brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6"}, + {file = "brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18"}, + {file = "brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5"}, + {file = "brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a"}, + {file = "brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8"}, + {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21"}, + {file = "brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7"}, + {file = "brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361"}, + {file = "brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888"}, + {file = "brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d"}, + {file = "brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3"}, + {file = "brotli-1.2.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:82676c2781ecf0ab23833796062786db04648b7aae8be139f6b8065e5e7b1518"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c16ab1ef7bb55651f5836e8e62db1f711d55b82ea08c3b8083ff037157171a69"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e85190da223337a6b7431d92c799fca3e2982abd44e7b8dec69938dcc81c8e9e"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:d8c05b1dfb61af28ef37624385b0029df902ca896a639881f594060b30ffc9a7"}, + {file = "brotli-1.2.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:465a0d012b3d3e4f1d6146ea019b5c11e3e87f03d1676da1cc3833462e672fb0"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:96fbe82a58cdb2f872fa5d87dedc8477a12993626c446de794ea025bbda625ea"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:1b71754d5b6eda54d16fbbed7fce2d8bc6c052a1b91a35c320247946ee103502"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:66c02c187ad250513c2f4fce973ef402d22f80e0adce734ee4e4efd657b6cb64"}, + {file = "brotli-1.2.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:ba76177fd318ab7b3b9bf6522be5e84c2ae798754b6cc028665490f6e66b5533"}, + {file = "brotli-1.2.0-cp36-cp36m-win32.whl", hash = "sha256:c1702888c9f3383cc2f09eb3e88b8babf5965a54afb79649458ec7c3c7a63e96"}, + {file = "brotli-1.2.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13"}, + {file = "brotli-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e80a28f2b150774844c8b454dd288be90d76ba6109670fe33d7ff54d96eb5cb8"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b1b799f45da91292ffaa21a473ab3a3054fa78560e8ff67082a185274431c8"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29b7e6716ee4ea0c59e3b241f682204105f7da084d6254ec61886508efeb43bc"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:640fe199048f24c474ec6f3eae67c48d286de12911110437a36a87d7c89573a6"}, + {file = "brotli-1.2.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:92edab1e2fd6cd5ca605f57d4545b6599ced5dea0fd90b2bcdf8b247a12bd190"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7274942e69b17f9cef76691bcf38f2b2d4c8a5f5dba6ec10958363dcb3308a0a"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:a56ef534b66a749759ebd091c19c03ef81eb8cd96f0d1d16b59127eaf1b97a12"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:5732eff8973dd995549a18ecbd8acd692ac611c5c0bb3f59fa3541ae27b33be3"}, + {file = "brotli-1.2.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:598e88c736f63a0efec8363f9eb34e5b5536b7b6b1821e401afcb501d881f59a"}, + {file = "brotli-1.2.0-cp37-cp37m-win32.whl", hash = "sha256:7ad8cec81f34edf44a1c6a7edf28e7b7806dfb8886e371d95dcf789ccd4e4982"}, + {file = "brotli-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16"}, + {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ac27a70bda257ae3f380ec8310b0a06680236bea547756c277b5dfe55a2452a8"}, + {file = "brotli-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e813da3d2d865e9793ef681d3a6b66fa4b7c19244a45b817d0cceda67e615990"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fe11467c42c133f38d42289d0861b6b4f9da31e8087ca2c0d7ebb4543625526"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c0d6770111d1879881432f81c369de5cde6e9467be7c682a983747ec800544e2"}, + {file = "brotli-1.2.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eda5a6d042c698e28bda2507a89b16555b9aa954ef1d750e1c20473481aff675"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3173e1e57cebb6d1de186e46b5680afbd82fd4301d7b2465beebe83ed317066d"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:71a66c1c9be66595d628467401d5976158c97888c2c9379c034e1e2312c5b4f5"}, + {file = "brotli-1.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:1e68cdf321ad05797ee41d1d09169e09d40fdf51a725bb148bff892ce04583d7"}, + {file = "brotli-1.2.0-cp38-cp38-win32.whl", hash = "sha256:f16dace5e4d3596eaeb8af334b4d2c820d34b8278da633ce4a00020b2eac981c"}, + {file = "brotli-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470"}, + {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8d4f47f284bdd28629481c97b5f29ad67544fa258d9091a6ed1fda47c7347cd1"}, + {file = "brotli-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2881416badd2a88a7a14d981c103a52a23a276a553a8aacc1346c2ff47c8dc17"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d39b54b968f4b49b5e845758e202b1035f948b0561ff5e6385e855c96625971"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95db242754c21a88a79e01504912e537808504465974ebb92931cfca2510469e"}, + {file = "brotli-1.2.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bba6e7e6cfe1e6cb6eb0b7c2736a6059461de1fa2c0ad26cf845de6c078d16c8"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:88ef7d55b7bcf3331572634c3fd0ed327d237ceb9be6066810d39020a3ebac7a"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:7fa18d65a213abcfbb2f6cafbb4c58863a8bd6f2103d65203c520ac117d1944b"}, + {file = "brotli-1.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:09ac247501d1909e9ee47d309be760c89c990defbb2e0240845c892ea5ff0de4"}, + {file = "brotli-1.2.0-cp39-cp39-win32.whl", hash = "sha256:c25332657dee6052ca470626f18349fc1fe8855a56218e19bd7a8c6ad4952c49"}, + {file = "brotli-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937"}, + {file = "brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a"}, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.0" +description = "Python CFFI bindings to the Brotli library" +optional = false +python-versions = ">=3.8" +groups = ["video"] +markers = "platform_python_implementation != \"CPython\"" +files = [ + {file = "brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4"}, + {file = "brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7"}, + {file = "brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990"}, + {file = "brotlicffi-1.2.0.0-cp38-abi3-win32.whl", hash = "sha256:23e5c912fdc6fd37143203820230374d24babd078fc054e18070a647118158f6"}, + {file = "brotlicffi-1.2.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:f139a7cdfe4ae7859513067b736eb44d19fae1186f9e99370092f6915216451b"}, + {file = "brotlicffi-1.2.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fa102a60e50ddbd08de86a63431a722ea216d9bc903b000bf544149cc9b823dc"}, + {file = "brotlicffi-1.2.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d3c4332fc808a94e8c1035950a10d04b681b03ab585ce897ae2a360d479037c"}, + {file = "brotlicffi-1.2.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb4eb5830026b79a93bf503ad32b2c5257315e9ffc49e76b2715cffd07c8e3db"}, + {file = "brotlicffi-1.2.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3832c66e00d6d82087f20a972b2fc03e21cd99ef22705225a6f8f418a9158ecc"}, + {file = "brotlicffi-1.2.0.0.tar.gz", hash = "sha256:34345d8d1f9d534fcac2249e57a4c3c8801a33c9942ff9f8574f67a175e17adb"}, +] + +[package.dependencies] +cffi = {version = ">=1.17.0", markers = "python_version >= \"3.13\""} + [[package]] name = "certifi" version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, @@ -346,8 +481,7 @@ version = "2.0.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.9" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\"" +groups = ["main", "video"] files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -434,6 +568,7 @@ files = [ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"}, {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"}, ] +markers = {main = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} @@ -473,7 +608,7 @@ version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, @@ -741,14 +876,14 @@ pytz = ">2021.1" [[package]] name = "cross-web" -version = "0.4.0" +version = "0.4.1" description = "A library for working with web frameworks" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "cross_web-0.4.0-py3-none-any.whl", hash = "sha256:0c675bd26e91428cab31e3e927929b42da94aa96da92974e57c78f9a732d0e9b"}, - {file = "cross_web-0.4.0.tar.gz", hash = "sha256:4ae65619ddfcd06d6803432c0366342d7e8aeba10194b4e144d73a662e75370c"}, + {file = "cross_web-0.4.1-py3-none-any.whl", hash = "sha256:41b07c3a38253c517ec0603c1a366353aff77538946092b0f9a2235033f192c2"}, + {file = "cross_web-0.4.1.tar.gz", hash = "sha256:0466295028dcae98c9ab3d18757f90b0e74fac2ff90efbe87e74657546d9993d"}, ] [package.dependencies] @@ -848,6 +983,26 @@ editorconfig = ">=0.12.2" jsbeautifier = "*" six = ">=1.13.0" +[[package]] +name = "cssselect2" +version = "0.8.0" +description = "CSS selectors for Python ElementTree" +optional = false +python-versions = ">=3.9" +groups = ["video"] +files = [ + {file = "cssselect2-0.8.0-py3-none-any.whl", hash = "sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e"}, + {file = "cssselect2-0.8.0.tar.gz", hash = "sha256:7674ffb954a3b46162392aee2a3a0aedb2e14ecf99fcc28644900f4e6e3e9d3a"}, +] + +[package.dependencies] +tinycss2 = "*" +webencodings = "*" + +[package.extras] +doc = ["furo", "sphinx"] +test = ["pytest", "ruff"] + [[package]] name = "dataclasses-json" version = "0.6.7" @@ -890,14 +1045,14 @@ files = [ [[package]] name = "django" -version = "6.0" +version = "6.0.1" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." optional = false python-versions = ">=3.12" groups = ["main"] files = [ - {file = "django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d"}, - {file = "django-6.0.tar.gz", hash = "sha256:7b0c1f50c0759bbe6331c6a39c89ae022a84672674aeda908784617ef47d8e26"}, + {file = "django-6.0.1-py3-none-any.whl", hash = "sha256:a92a4ff14f664a896f9849009cb8afaca7abe0d6fc53325f3d1895a15253433d"}, + {file = "django-6.0.1.tar.gz", hash = "sha256:ed76a7af4da21551573b3d9dfc1f53e20dd2e6c7d70a3adc93eedb6338130a5f"}, ] [package.dependencies] @@ -949,14 +1104,14 @@ django = ">=4.2" [[package]] name = "django-ninja" -version = "1.5.2" +version = "1.5.3" description = "Django Ninja - Fast Django REST framework" optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "django_ninja-1.5.2-py3-none-any.whl", hash = "sha256:5faf09cf6e64298e822305c3f4b933608b4fce30643e36cc58f67660f20f4cac"}, - {file = "django_ninja-1.5.2.tar.gz", hash = "sha256:1554bebc28e9bbc8412f49bae4698937781b54d828c84af30c40dfced407b8cb"}, + {file = "django_ninja-1.5.3-py3-none-any.whl", hash = "sha256:0a6ead5b4e57ec1050b584eb6f36f105f256b8f4ac70d12e774d8b6dd91e2198"}, + {file = "django_ninja-1.5.3.tar.gz", hash = "sha256:974803944965ad0566071633ffd4999a956f2ad1ecbed815c0de37c1c969592b"}, ] [package.dependencies] @@ -1104,6 +1259,29 @@ files = [ [package.extras] dev = ["mypy (>=1.15)"] +[[package]] +name = "elevenlabs" +version = "2.30.0" +description = "" +optional = false +python-versions = "<4.0,>=3.8" +groups = ["video"] +files = [ + {file = "elevenlabs-2.30.0-py3-none-any.whl", hash = "sha256:eeee92703a27e7ecd0e8ba4d547f730bcef4ecb02485efa59aeecab3a904d024"}, + {file = "elevenlabs-2.30.0.tar.gz", hash = "sha256:a6a0474e045b93475fcd5f5829b67438d5a6aef9698b6f8758e7148ac03c2b12"}, +] + +[package.dependencies] +httpx = ">=0.21.2" +pydantic = ">=1.9.2" +pydantic-core = ">=2.18.2" +requests = ">=2.20" +typing_extensions = ">=4.0.0" +websockets = ">=11.0" + +[package.extras] +pyaudio = ["pyaudio (>=0.2.14)"] + [[package]] name = "emoji" version = "2.15.0" @@ -1134,18 +1312,114 @@ files = [ [package.extras] testing = ["hatch", "pre-commit", "pytest", "tox"] +[[package]] +name = "ffmpeg-python" +version = "0.2.0" +description = "Python bindings for FFmpeg - with complex filtering support" +optional = false +python-versions = "*" +groups = ["video"] +files = [ + {file = "ffmpeg-python-0.2.0.tar.gz", hash = "sha256:65225db34627c578ef0e11c8b1eb528bb35e024752f6f10b78c011f6f64c4127"}, + {file = "ffmpeg_python-0.2.0-py3-none-any.whl", hash = "sha256:ac441a0404e053f8b6a1113a77c0f452f1cfc62f6344a769475ffdc0f56c23c5"}, +] + +[package.dependencies] +future = "*" + +[package.extras] +dev = ["Sphinx (==2.1.0)", "future (==0.17.1)", "numpy (==1.16.4)", "pytest (==4.6.1)", "pytest-mock (==1.10.4)", "tox (==3.12.1)"] + [[package]] name = "filelock" -version = "3.20.2" +version = "3.20.3" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8"}, - {file = "filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64"}, + {file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"}, + {file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"}, +] + +[[package]] +name = "fonttools" +version = "4.61.1" +description = "Tools to manipulate font files" +optional = false +python-versions = ">=3.10" +groups = ["video"] +files = [ + {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c7db70d57e5e1089a274cbb2b1fd635c9a24de809a231b154965d415d6c6d24"}, + {file = "fonttools-4.61.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5fe9fd43882620017add5eabb781ebfbc6998ee49b35bd7f8f79af1f9f99a958"}, + {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8db08051fc9e7d8bc622f2112511b8107d8f27cd89e2f64ec45e9825e8288da"}, + {file = "fonttools-4.61.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a76d4cb80f41ba94a6691264be76435e5f72f2cb3cab0b092a6212855f71c2f6"}, + {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a13fc8aeb24bad755eea8f7f9d409438eb94e82cf86b08fe77a03fbc8f6a96b1"}, + {file = "fonttools-4.61.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b846a1fcf8beadeb9ea4f44ec5bdde393e2f1569e17d700bfc49cd69bde75881"}, + {file = "fonttools-4.61.1-cp310-cp310-win32.whl", hash = "sha256:78a7d3ab09dc47ac1a363a493e6112d8cabed7ba7caad5f54dbe2f08676d1b47"}, + {file = "fonttools-4.61.1-cp310-cp310-win_amd64.whl", hash = "sha256:eff1ac3cc66c2ac7cda1e64b4e2f3ffef474b7335f92fc3833fc632d595fcee6"}, + {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c6604b735bb12fef8e0efd5578c9fb5d3d8532d5001ea13a19cddf295673ee09"}, + {file = "fonttools-4.61.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5ce02f38a754f207f2f06557523cd39a06438ba3aafc0639c477ac409fc64e37"}, + {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77efb033d8d7ff233385f30c62c7c79271c8885d5c9657d967ede124671bbdfb"}, + {file = "fonttools-4.61.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:75c1a6dfac6abd407634420c93864a1e274ebc1c7531346d9254c0d8f6ca00f9"}, + {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0de30bfe7745c0d1ffa2b0b7048fb7123ad0d71107e10ee090fa0b16b9452e87"}, + {file = "fonttools-4.61.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:58b0ee0ab5b1fc9921eccfe11d1435added19d6494dde14e323f25ad2bc30c56"}, + {file = "fonttools-4.61.1-cp311-cp311-win32.whl", hash = "sha256:f79b168428351d11e10c5aeb61a74e1851ec221081299f4cf56036a95431c43a"}, + {file = "fonttools-4.61.1-cp311-cp311-win_amd64.whl", hash = "sha256:fe2efccb324948a11dd09d22136fe2ac8a97d6c1347cf0b58a911dcd529f66b7"}, + {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f3cb4a569029b9f291f88aafc927dd53683757e640081ca8c412781ea144565e"}, + {file = "fonttools-4.61.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41a7170d042e8c0024703ed13b71893519a1a6d6e18e933e3ec7507a2c26a4b2"}, + {file = "fonttools-4.61.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10d88e55330e092940584774ee5e8a6971b01fc2f4d3466a1d6c158230880796"}, + {file = "fonttools-4.61.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15acc09befd16a0fb8a8f62bc147e1a82817542d72184acca9ce6e0aeda9fa6d"}, + {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e6bcdf33aec38d16508ce61fd81838f24c83c90a1d1b8c68982857038673d6b8"}, + {file = "fonttools-4.61.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5fade934607a523614726119164ff621e8c30e8fa1ffffbbd358662056ba69f0"}, + {file = "fonttools-4.61.1-cp312-cp312-win32.whl", hash = "sha256:75da8f28eff26defba42c52986de97b22106cb8f26515b7c22443ebc9c2d3261"}, + {file = "fonttools-4.61.1-cp312-cp312-win_amd64.whl", hash = "sha256:497c31ce314219888c0e2fce5ad9178ca83fe5230b01a5006726cdf3ac9f24d9"}, + {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8c56c488ab471628ff3bfa80964372fc13504ece601e0d97a78ee74126b2045c"}, + {file = "fonttools-4.61.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dc492779501fa723b04d0ab1f5be046797fee17d27700476edc7ee9ae535a61e"}, + {file = "fonttools-4.61.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:64102ca87e84261419c3747a0d20f396eb024bdbeb04c2bfb37e2891f5fadcb5"}, + {file = "fonttools-4.61.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c1b526c8d3f615a7b1867f38a9410849c8f4aef078535742198e942fba0e9bd"}, + {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41ed4b5ec103bd306bb68f81dc166e77409e5209443e5773cb4ed837bcc9b0d3"}, + {file = "fonttools-4.61.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b501c862d4901792adaec7c25b1ecc749e2662543f68bb194c42ba18d6eec98d"}, + {file = "fonttools-4.61.1-cp313-cp313-win32.whl", hash = "sha256:4d7092bb38c53bbc78e9255a59158b150bcdc115a1e3b3ce0b5f267dc35dd63c"}, + {file = "fonttools-4.61.1-cp313-cp313-win_amd64.whl", hash = "sha256:21e7c8d76f62ab13c9472ccf74515ca5b9a761d1bde3265152a6dc58700d895b"}, + {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fff4f534200a04b4a36e7ae3cb74493afe807b517a09e99cb4faa89a34ed6ecd"}, + {file = "fonttools-4.61.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d9203500f7c63545b4ce3799319fe4d9feb1a1b89b28d3cb5abd11b9dd64147e"}, + {file = "fonttools-4.61.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa646ecec9528bef693415c79a86e733c70a4965dd938e9a226b0fc64c9d2e6c"}, + {file = "fonttools-4.61.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f35ad7805edba3aac1a3710d104592df59f4b957e30108ae0ba6c10b11dd75"}, + {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b931ae8f62db78861b0ff1ac017851764602288575d65b8e8ff1963fed419063"}, + {file = "fonttools-4.61.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b148b56f5de675ee16d45e769e69f87623a4944f7443850bf9a9376e628a89d2"}, + {file = "fonttools-4.61.1-cp314-cp314-win32.whl", hash = "sha256:9b666a475a65f4e839d3d10473fad6d47e0a9db14a2f4a224029c5bfde58ad2c"}, + {file = "fonttools-4.61.1-cp314-cp314-win_amd64.whl", hash = "sha256:4f5686e1fe5fce75d82d93c47a438a25bf0d1319d2843a926f741140b2b16e0c"}, + {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e76ce097e3c57c4bcb67c5aa24a0ecdbd9f74ea9219997a707a4061fbe2707aa"}, + {file = "fonttools-4.61.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9cfef3ab326780c04d6646f68d4b4742aae222e8b8ea1d627c74e38afcbc9d91"}, + {file = "fonttools-4.61.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a75c301f96db737e1c5ed5fd7d77d9c34466de16095a266509e13da09751bd19"}, + {file = "fonttools-4.61.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:91669ccac46bbc1d09e9273546181919064e8df73488ea087dcac3e2968df9ba"}, + {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c33ab3ca9d3ccd581d58e989d67554e42d8d4ded94ab3ade3508455fe70e65f7"}, + {file = "fonttools-4.61.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:664c5a68ec406f6b1547946683008576ef8b38275608e1cee6c061828171c118"}, + {file = "fonttools-4.61.1-cp314-cp314t-win32.whl", hash = "sha256:aed04cabe26f30c1647ef0e8fbb207516fd40fe9472e9439695f5c6998e60ac5"}, + {file = "fonttools-4.61.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2180f14c141d2f0f3da43f3a81bc8aa4684860f6b0e6f9e165a4831f24e6a23b"}, + {file = "fonttools-4.61.1-py3-none-any.whl", hash = "sha256:17d2bf5d541add43822bcf0c43d7d847b160c9bb01d15d5007d84e2217aaa371"}, + {file = "fonttools-4.61.1.tar.gz", hash = "sha256:6675329885c44657f826ef01d9e4fb33b9158e9d93c537d84ad8399539bc6f69"}, ] +[package.dependencies] +brotli = {version = ">=1.0.1", optional = true, markers = "platform_python_implementation == \"CPython\" and extra == \"woff\""} +brotlicffi = {version = ">=0.8.0", optional = true, markers = "platform_python_implementation != \"CPython\" and extra == \"woff\""} +zopfli = {version = ">=0.1.4", optional = true, markers = "extra == \"woff\""} + +[package.extras] +all = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "lxml (>=4.0)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\"", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.45.0)", "unicodedata2 (>=17.0.0) ; python_version <= \"3.14\"", "xattr ; sys_platform == \"darwin\"", "zopfli (>=0.1.4)"] +graphite = ["lz4 (>=1.7.4.2)"] +interpolatable = ["munkres ; platform_python_implementation == \"PyPy\"", "pycairo", "scipy ; platform_python_implementation != \"PyPy\""] +lxml = ["lxml (>=4.0)"] +pathops = ["skia-pathops (>=0.5.0)"] +plot = ["matplotlib"] +repacker = ["uharfbuzz (>=0.45.0)"] +symfont = ["sympy"] +type1 = ["xattr ; sys_platform == \"darwin\""] +unicode = ["unicodedata2 (>=17.0.0) ; python_version <= \"3.14\""] +woff = ["brotli (>=1.0.1) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\"", "zopfli (>=0.1.4)"] + [[package]] name = "frozenlist" version = "1.8.0" @@ -1286,6 +1560,18 @@ files = [ {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, ] +[[package]] +name = "future" +version = "1.0.0" +description = "Clean single-source support for Python 3 and 2" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["video"] +files = [ + {file = "future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216"}, + {file = "future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05"}, +] + [[package]] name = "geographiclib" version = "2.1" @@ -1425,7 +1711,7 @@ version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -1449,7 +1735,7 @@ version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -1471,7 +1757,7 @@ version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1519,14 +1805,14 @@ tests = ["freezegun", "pytest", "pytest-cov"] [[package]] name = "identify" -version = "2.6.15" +version = "2.6.16" description = "File identification library for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757"}, - {file = "identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf"}, + {file = "identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0"}, + {file = "identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980"}, ] [package.extras] @@ -1538,7 +1824,7 @@ version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, @@ -1758,21 +2044,21 @@ files = [ [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" description = "An implementation of JSON Schema validation for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"}, - {file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"}, + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, ] [package.dependencies] attrs = ">=22.2.0" jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" -rpds-py = ">=0.7.1" +rpds-py = ">=0.25.0" [package.extras] format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] @@ -1879,14 +2165,14 @@ tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" [[package]] name = "langchain-core" -version = "0.3.81" +version = "0.3.83" description = "Building applications with LLMs through composability" optional = false python-versions = "<4.0.0,>=3.9.0" groups = ["main"] files = [ - {file = "langchain_core-0.3.81-py3-none-any.whl", hash = "sha256:d0f34c88254d78ccb1b9a038f860d13dea90186045026fd8fc3e1265eed73a4e"}, - {file = "langchain_core-0.3.81.tar.gz", hash = "sha256:0473e7cffbba06017393f2f1ee116da098c298e4ec478ada91d321f42dff921c"}, + {file = "langchain_core-0.3.83-py3-none-any.whl", hash = "sha256:8c92506f8b53fc1958b1c07447f58c5783eb8833dd3cb6dc75607c80891ab1ae"}, + {file = "langchain_core-0.3.83.tar.gz", hash = "sha256:a0a4c7b6ea1c446d3b432116f405dc2afa1fe7891c44140d3d5acca221909415"}, ] [package.dependencies] @@ -1897,6 +2183,7 @@ pydantic = ">=2.7.4,<3.0.0" PyYAML = ">=5.3.0,<7.0.0" tenacity = ">=8.1.0,<8.4.0 || >8.4.0,<10.0.0" typing-extensions = ">=4.7.0,<5.0.0" +uuid-utils = ">=0.12.0,<1.0" [[package]] name = "langchain-text-splitters" @@ -1983,14 +2270,14 @@ orjson = ">=3.10.1" [[package]] name = "langsmith" -version = "0.6.0" +version = "0.6.4" description = "Client library to connect to the LangSmith Observability and Evaluation Platform." optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "langsmith-0.6.0-py3-none-any.whl", hash = "sha256:f7570175aed705b1f4c4dae724c07980a737b8b565252444d11394dda9931e8c"}, - {file = "langsmith-0.6.0.tar.gz", hash = "sha256:b60f1785aed4dac5e01f24db01aa18fa1af258bad4531e045e739438daa3f8c2"}, + {file = "langsmith-0.6.4-py3-none-any.whl", hash = "sha256:ac4835860160be371042c7adbba3cb267bcf8d96a5ea976c33a8a4acad6c5486"}, + {file = "langsmith-0.6.4.tar.gz", hash = "sha256:36f7223a01c218079fbb17da5e536ebbaf5c1468c028abe070aa3ae59bc99ec8"}, ] [package.dependencies] @@ -2484,96 +2771,96 @@ files = [ [[package]] name = "numpy" -version = "2.4.0" +version = "2.4.1" description = "Fundamental package for array computing in Python" optional = false python-versions = ">=3.11" groups = ["main"] files = [ - {file = "numpy-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:316b2f2584682318539f0bcaca5a496ce9ca78c88066579ebd11fd06f8e4741e"}, - {file = "numpy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2718c1de8504121714234b6f8241d0019450353276c88b9453c9c3d92e101db"}, - {file = "numpy-2.4.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:21555da4ec4a0c942520ead42c3b0dc9477441e085c42b0fbdd6a084869a6f6b"}, - {file = "numpy-2.4.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:413aa561266a4be2d06cd2b9665e89d9f54c543f418773076a76adcf2af08bc7"}, - {file = "numpy-2.4.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0feafc9e03128074689183031181fac0897ff169692d8492066e949041096548"}, - {file = "numpy-2.4.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8fdfed3deaf1928fb7667d96e0567cdf58c2b370ea2ee7e586aa383ec2cb346"}, - {file = "numpy-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06a922a469cae9a57100864caf4f8a97a1026513793969f8ba5b63137a35d25"}, - {file = "numpy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:927ccf5cd17c48f801f4ed43a7e5673a2724bd2171460be3e3894e6e332ef83a"}, - {file = "numpy-2.4.0-cp311-cp311-win32.whl", hash = "sha256:882567b7ae57c1b1a0250208cc21a7976d8cbcc49d5a322e607e6f09c9e0bd53"}, - {file = "numpy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b986403023c8f3bf8f487c2e6186afda156174d31c175f747d8934dfddf3479"}, - {file = "numpy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:3f3096405acc48887458bbf9f6814d43785ac7ba2a57ea6442b581dedbc60ce6"}, - {file = "numpy-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a8b6bb8369abefb8bd1801b054ad50e02b3275c8614dc6e5b0373c305291037"}, - {file = "numpy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e284ca13d5a8367e43734148622caf0b261b275673823593e3e3634a6490f83"}, - {file = "numpy-2.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:49ff32b09f5aa0cd30a20c2b39db3e669c845589f2b7fc910365210887e39344"}, - {file = "numpy-2.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:36cbfb13c152b1c7c184ddac43765db8ad672567e7bafff2cc755a09917ed2e6"}, - {file = "numpy-2.4.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35ddc8f4914466e6fc954c76527aa91aa763682a4f6d73249ef20b418fe6effb"}, - {file = "numpy-2.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc578891de1db95b2a35001b695451767b580bb45753717498213c5ff3c41d63"}, - {file = "numpy-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98e81648e0b36e325ab67e46b5400a7a6d4a22b8a7c8e8bbfe20e7db7906bf95"}, - {file = "numpy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d57b5046c120561ba8fa8e4030fbb8b822f3063910fa901ffadf16e2b7128ad6"}, - {file = "numpy-2.4.0-cp312-cp312-win32.whl", hash = "sha256:92190db305a6f48734d3982f2c60fa30d6b5ee9bff10f2887b930d7b40119f4c"}, - {file = "numpy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:680060061adb2d74ce352628cb798cfdec399068aa7f07ba9fb818b2b3305f98"}, - {file = "numpy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:39699233bc72dd482da1415dcb06076e32f60eddc796a796c5fb6c5efce94667"}, - {file = "numpy-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a152d86a3ae00ba5f47b3acf3b827509fd0b6cb7d3259665e63dafbad22a75ea"}, - {file = "numpy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39b19251dec4de8ff8496cd0806cbe27bf0684f765abb1f4809554de93785f2d"}, - {file = "numpy-2.4.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:009bd0ea12d3c784b6639a8457537016ce5172109e585338e11334f6a7bb88ee"}, - {file = "numpy-2.4.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5fe44e277225fd3dff6882d86d3d447205d43532c3627313d17e754fb3905a0e"}, - {file = "numpy-2.4.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f935c4493eda9069851058fa0d9e39dbf6286be690066509305e52912714dbb2"}, - {file = "numpy-2.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8cfa5f29a695cb7438965e6c3e8d06e0416060cf0d709c1b1c1653a939bf5c2a"}, - {file = "numpy-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba0cb30acd3ef11c94dc27fbfba68940652492bc107075e7ffe23057f9425681"}, - {file = "numpy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60e8c196cd82cbbd4f130b5290007e13e6de3eca79f0d4d38014769d96a7c475"}, - {file = "numpy-2.4.0-cp313-cp313-win32.whl", hash = "sha256:5f48cb3e88fbc294dc90e215d86fbaf1c852c63dbdb6c3a3e63f45c4b57f7344"}, - {file = "numpy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:a899699294f28f7be8992853c0c60741f16ff199205e2e6cdca155762cbaa59d"}, - {file = "numpy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9198f447e1dc5647d07c9a6bbe2063cc0132728cc7175b39dbc796da5b54920d"}, - {file = "numpy-2.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74623f2ab5cc3f7c886add4f735d1031a1d2be4a4ae63c0546cfd74e7a31ddf6"}, - {file = "numpy-2.4.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:0804a8e4ab070d1d35496e65ffd3cf8114c136a2b81f61dfab0de4b218aacfd5"}, - {file = "numpy-2.4.0-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:02a2038eb27f9443a8b266a66911e926566b5a6ffd1a689b588f7f35b81e7dc3"}, - {file = "numpy-2.4.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1889b3a3f47a7b5bee16bc25a2145bd7cb91897f815ce3499db64c7458b6d91d"}, - {file = "numpy-2.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85eef4cb5625c47ee6425c58a3502555e10f45ee973da878ac8248ad58c136f3"}, - {file = "numpy-2.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6dc8b7e2f4eb184b37655195f421836cfae6f58197b67e3ffc501f1333d993fa"}, - {file = "numpy-2.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:44aba2f0cafd287871a495fb3163408b0bd25bbce135c6f621534a07f4f7875c"}, - {file = "numpy-2.4.0-cp313-cp313t-win32.whl", hash = "sha256:20c115517513831860c573996e395707aa9fb691eb179200125c250e895fcd93"}, - {file = "numpy-2.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b48e35f4ab6f6a7597c46e301126ceba4c44cd3280e3750f85db48b082624fa4"}, - {file = "numpy-2.4.0-cp313-cp313t-win_arm64.whl", hash = "sha256:4d1cfce39e511069b11e67cd0bd78ceff31443b7c9e5c04db73c7a19f572967c"}, - {file = "numpy-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c95eb6db2884917d86cde0b4d4cf31adf485c8ec36bf8696dd66fa70de96f36b"}, - {file = "numpy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:65167da969cd1ec3a1df31cb221ca3a19a8aaa25370ecb17d428415e93c1935e"}, - {file = "numpy-2.4.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3de19cfecd1465d0dcf8a5b5ea8b3155b42ed0b639dba4b71e323d74f2a3be5e"}, - {file = "numpy-2.4.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6c05483c3136ac4c91b4e81903cb53a8707d316f488124d0398499a4f8e8ef51"}, - {file = "numpy-2.4.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36667db4d6c1cea79c8930ab72fadfb4060feb4bfe724141cd4bd064d2e5f8ce"}, - {file = "numpy-2.4.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a818668b674047fd88c4cddada7ab8f1c298812783e8328e956b78dc4807f9f"}, - {file = "numpy-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ee32359fb7543b7b7bd0b2f46294db27e29e7bbdf70541e81b190836cd83ded"}, - {file = "numpy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e493962256a38f58283de033d8af176c5c91c084ea30f15834f7545451c42059"}, - {file = "numpy-2.4.0-cp314-cp314-win32.whl", hash = "sha256:6bbaebf0d11567fa8926215ae731e1d58e6ec28a8a25235b8a47405d301332db"}, - {file = "numpy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d857f55e7fdf7c38ab96c4558c95b97d1c685be6b05c249f5fdafcbd6f9899e"}, - {file = "numpy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:bb50ce5fb202a26fd5404620e7ef820ad1ab3558b444cb0b55beb7ef66cd2d63"}, - {file = "numpy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:355354388cba60f2132df297e2d53053d4063f79077b67b481d21276d61fc4df"}, - {file = "numpy-2.4.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:1d8f9fde5f6dc1b6fc34df8162f3b3079365468703fee7f31d4e0cc8c63baed9"}, - {file = "numpy-2.4.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e0434aa22c821f44eeb4c650b81c7fbdd8c0122c6c4b5a576a76d5a35625ecd9"}, - {file = "numpy-2.4.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40483b2f2d3ba7aad426443767ff5632ec3156ef09742b96913787d13c336471"}, - {file = "numpy-2.4.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6a7664ddd9746e20b7325351fe1a8408d0a2bf9c63b5e898290ddc8f09544"}, - {file = "numpy-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ecb0019d44f4cdb50b676c5d0cb4b1eae8e15d1ed3d3e6639f986fc92b2ec52c"}, - {file = "numpy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d0ffd9e2e4441c96a9c91ec1783285d80bf835b677853fc2770a89d50c1e48ac"}, - {file = "numpy-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:77f0d13fa87036d7553bf81f0e1fe3ce68d14c9976c9851744e4d3e91127e95f"}, - {file = "numpy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b1f5b45829ac1848893f0ddf5cb326110604d6df96cdc255b0bf9edd154104d4"}, - {file = "numpy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:23a3e9d1a6f360267e8fbb38ba5db355a6a7e9be71d7fce7ab3125e88bb646c8"}, - {file = "numpy-2.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b54c83f1c0c0f1d748dca0af516062b8829d53d1f0c402be24b4257a9c48ada6"}, - {file = "numpy-2.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:aabb081ca0ec5d39591fc33018cd4b3f96e1a2dd6756282029986d00a785fba4"}, - {file = "numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:8eafe7c36c8430b7794edeab3087dec7bf31d634d92f2af9949434b9d1964cba"}, - {file = "numpy-2.4.0-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2f585f52b2baf07ff3356158d9268ea095e221371f1074fadea2f42544d58b4d"}, - {file = "numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ed06d0fe9cae27d8fb5f400c63ccee72370599c75e683a6358dd3a4fb50aaf"}, - {file = "numpy-2.4.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:57c540ed8fb1f05cb997c6761cd56db72395b0d6985e90571ff660452ade4f98"}, - {file = "numpy-2.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a39fb973a726e63223287adc6dafe444ce75af952d711e400f3bf2b36ef55a7b"}, - {file = "numpy-2.4.0.tar.gz", hash = "sha256:6e504f7b16118198f138ef31ba24d985b124c2c469fe8467007cf30fd992f934"}, + {file = "numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5"}, + {file = "numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425"}, + {file = "numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba"}, + {file = "numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501"}, + {file = "numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a"}, + {file = "numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509"}, + {file = "numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc"}, + {file = "numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82"}, + {file = "numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0"}, + {file = "numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574"}, + {file = "numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73"}, + {file = "numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2"}, + {file = "numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8"}, + {file = "numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a"}, + {file = "numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0"}, + {file = "numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c"}, + {file = "numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02"}, + {file = "numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162"}, + {file = "numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9"}, + {file = "numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f"}, + {file = "numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87"}, + {file = "numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8"}, + {file = "numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b"}, + {file = "numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f"}, + {file = "numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9"}, + {file = "numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e"}, + {file = "numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5"}, + {file = "numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8"}, + {file = "numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c"}, + {file = "numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2"}, + {file = "numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d"}, + {file = "numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb"}, + {file = "numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5"}, + {file = "numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7"}, + {file = "numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d"}, + {file = "numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15"}, + {file = "numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9"}, + {file = "numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2"}, + {file = "numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505"}, + {file = "numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2"}, + {file = "numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4"}, + {file = "numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510"}, + {file = "numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261"}, + {file = "numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc"}, + {file = "numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3"}, + {file = "numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220"}, + {file = "numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee"}, + {file = "numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556"}, + {file = "numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844"}, + {file = "numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3"}, + {file = "numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205"}, + {file = "numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745"}, + {file = "numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d"}, + {file = "numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df"}, + {file = "numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f"}, + {file = "numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0"}, + {file = "numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c"}, + {file = "numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93"}, + {file = "numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42"}, + {file = "numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01"}, + {file = "numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b"}, + {file = "numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a"}, + {file = "numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2"}, + {file = "numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295"}, + {file = "numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1"}, + {file = "numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344"}, + {file = "numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e"}, + {file = "numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426"}, + {file = "numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696"}, + {file = "numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be"}, + {file = "numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33"}, + {file = "numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690"}, ] [[package]] name = "openai" -version = "2.14.0" +version = "2.15.0" description = "The official Python library for the openai API" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "openai-2.14.0-py3-none-any.whl", hash = "sha256:7ea40aca4ffc4c4a776e77679021b47eec1160e341f42ae086ba949c9dcc9183"}, - {file = "openai-2.14.0.tar.gz", hash = "sha256:419357bedde9402d23bf8f2ee372fca1985a73348debba94bddff06f19459952"}, + {file = "openai-2.15.0-py3-none-any.whl", hash = "sha256:6ae23b932cd7230f7244e52954daa6602716d6b9bf235401a107af731baea6c3"}, + {file = "openai-2.15.0.tar.gz", hash = "sha256:42eb8cbb407d84770633f31bf727d4ffb4138711c670565a41663d9439174fba"}, ] [package.dependencies] @@ -2778,16 +3065,22 @@ files = [ [[package]] name = "pathspec" -version = "0.12.1" +version = "1.0.3" description = "Utility library for gitignore style pattern matching of file paths." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, + {file = "pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c"}, + {file = "pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d"}, ] +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] +tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] + [[package]] name = "pgvector" version = "0.4.2" @@ -2809,7 +3102,7 @@ version = "12.1.0" description = "Python Imaging Library (fork)" optional = false python-versions = ">=3.10" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd"}, {file = "pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0"}, @@ -3190,12 +3483,12 @@ version = "2.23" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["main"] -markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +groups = ["main", "video"] files = [ {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"}, {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] +markers = {main = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", video = "implementation_name != \"PyPy\""} [[package]] name = "pydantic" @@ -3203,7 +3496,7 @@ version = "2.12.5" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, @@ -3225,7 +3518,7 @@ version = "2.41.5" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"}, {file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"}, @@ -3377,6 +3670,22 @@ gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] toml = ["tomli (>=2.0.1)"] yaml = ["pyyaml (>=6.0.1)"] +[[package]] +name = "pydyf" +version = "0.12.1" +description = "A low-level PDF generator." +optional = false +python-versions = ">=3.10" +groups = ["video"] +files = [ + {file = "pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc"}, + {file = "pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095"}, +] + +[package.extras] +doc = ["furo", "sphinx"] +test = ["pillow", "pytest", "ruff"] + [[package]] name = "pygithub" version = "2.8.1" @@ -3488,6 +3797,54 @@ files = [ [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pypdfium2" +version = "5.3.0" +description = "Python bindings to PDFium" +optional = false +python-versions = ">=3.6" +groups = ["video"] +files = [ + {file = "pypdfium2-5.3.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:885df6c78d41600cb086dc0c76b912d165b5bd6931ca08138329ea5a991b3540"}, + {file = "pypdfium2-5.3.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:6e53dee6b333ee77582499eff800300fb5aa0c7eb8f52f95ccb5ca35ebc86d48"}, + {file = "pypdfium2-5.3.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ce4466bdd62119fe25a5f74d107acc9db8652062bf217057630c6ff0bb419523"}, + {file = "pypdfium2-5.3.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:cc2647fd03db42b8a56a8835e8bc7899e604e2042cd6fedeea53483185612907"}, + {file = "pypdfium2-5.3.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35e205f537ddb4069e4b4e22af7ffe84fcf2d686c3fee5e5349f73268a0ef1ca"}, + {file = "pypdfium2-5.3.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5795298f44050797ac030994fc2525ea35d2d714efe70058e0ee22e5f613f27"}, + {file = "pypdfium2-5.3.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7cd43dfceb77137e69e74c933d41506da1dddaff70f3a794fb0ad0d73e90d75"}, + {file = "pypdfium2-5.3.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5956867558fd3a793e58691cf169718864610becb765bfe74dd83f05cbf1ae3"}, + {file = "pypdfium2-5.3.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ff1071e9a782625822658dfe6e29e3a644a66960f8713bb17819f5a0ac5987"}, + {file = "pypdfium2-5.3.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f319c46ead49d289ab8c1ed2ea63c91e684f35bdc4cf4dc52191c441182ac481"}, + {file = "pypdfium2-5.3.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6dc67a186da0962294321cace6ccc0a4d212dbc5e9522c640d35725a812324b8"}, + {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0ad0afd3d2b5b54d86287266fd6ae3fef0e0a1a3df9d2c4984b3e3f8f70e6330"}, + {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1afe35230dc3951b3e79b934c0c35a2e79e2372d06503fce6cf1926d2a816f47"}, + {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:00385793030cadce08469085cd21b168fd8ff981b009685fef3103bdc5fc4686"}, + {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:d911e82676398949697fef80b7f412078df14d725a91c10e383b727051530285"}, + {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:ca1dc625ed347fac3d9002a3ed33d521d5803409bd572e7b3f823c12ab2ef58f"}, + {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:ea4f9db2d3575f22cd41f4c7a855240ded842f135e59a961b5b1351a65ce2b6e"}, + {file = "pypdfium2-5.3.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0ea24409613df350223c6afc50911c99dca0d43ddaf2616c5a1ebdffa3e1bcb5"}, + {file = "pypdfium2-5.3.0-py3-none-win32.whl", hash = "sha256:5bf695d603f9eb8fdd7c1786add5cf420d57fbc81df142ed63c029ce29614df9"}, + {file = "pypdfium2-5.3.0-py3-none-win_amd64.whl", hash = "sha256:8365af22a39d4373c265f8e90e561cd64d4ddeaf5e6a66546a8caed216ab9574"}, + {file = "pypdfium2-5.3.0-py3-none-win_arm64.whl", hash = "sha256:0b2c6bf825e084d91d34456be54921da31e9199d9530b05435d69d1a80501a12"}, + {file = "pypdfium2-5.3.0.tar.gz", hash = "sha256:2873ffc95fcb01f329257ebc64a5fdce44b36447b6b171fe62f7db5dc3269885"}, +] + +[[package]] +name = "pyphen" +version = "0.17.2" +description = "Pure Python module to hyphenate text" +optional = false +python-versions = ">=3.9" +groups = ["video"] +files = [ + {file = "pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd"}, + {file = "pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3"}, +] + +[package.extras] +doc = ["sphinx", "sphinx_rtd_theme"] +test = ["pytest", "ruff"] + [[package]] name = "pytest" version = "9.0.2" @@ -3863,139 +4220,155 @@ rpds-py = ">=0.7.0" [[package]] name = "regex" -version = "2025.11.3" +version = "2026.1.15" description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2b441a4ae2c8049106e8b39973bfbddfb25a179dda2bdb99b0eeb60c40a6a3af"}, - {file = "regex-2025.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2fa2eed3f76677777345d2f81ee89f5de2f5745910e805f7af7386a920fa7313"}, - {file = "regex-2025.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8b4a27eebd684319bdf473d39f1d79eed36bf2cd34bd4465cdb4618d82b3d56"}, - {file = "regex-2025.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cf77eac15bd264986c4a2c63353212c095b40f3affb2bc6b4ef80c4776c1a28"}, - {file = "regex-2025.11.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f9ee819f94c6abfa56ec7b1dbab586f41ebbdc0a57e6524bd5e7f487a878c7"}, - {file = "regex-2025.11.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:838441333bc90b829406d4a03cb4b8bf7656231b84358628b0406d803931ef32"}, - {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe6d3f0c9e3b7e8c0c694b24d25e677776f5ca26dce46fd6b0489f9c8339391"}, - {file = "regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ab815eb8a96379a27c3b6157fcb127c8f59c36f043c1678110cea492868f1d5"}, - {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:728a9d2d173a65b62bdc380b7932dd8e74ed4295279a8fe1021204ce210803e7"}, - {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:509dc827f89c15c66a0c216331260d777dd6c81e9a4e4f830e662b0bb296c313"}, - {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:849202cd789e5f3cf5dcc7822c34b502181b4824a65ff20ce82da5524e45e8e9"}, - {file = "regex-2025.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b6f78f98741dcc89607c16b1e9426ee46ce4bf31ac5e6b0d40e81c89f3481ea5"}, - {file = "regex-2025.11.3-cp310-cp310-win32.whl", hash = "sha256:149eb0bba95231fb4f6d37c8f760ec9fa6fabf65bab555e128dde5f2475193ec"}, - {file = "regex-2025.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:ee3a83ce492074c35a74cc76cf8235d49e77b757193a5365ff86e3f2f93db9fd"}, - {file = "regex-2025.11.3-cp310-cp310-win_arm64.whl", hash = "sha256:38af559ad934a7b35147716655d4a2f79fcef2d695ddfe06a06ba40ae631fa7e"}, - {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031"}, - {file = "regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4"}, - {file = "regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50"}, - {file = "regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f"}, - {file = "regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118"}, - {file = "regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2"}, - {file = "regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e"}, - {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0"}, - {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58"}, - {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab"}, - {file = "regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e"}, - {file = "regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf"}, - {file = "regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a"}, - {file = "regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc"}, - {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41"}, - {file = "regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36"}, - {file = "regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1"}, - {file = "regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7"}, - {file = "regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69"}, - {file = "regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48"}, - {file = "regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c"}, - {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695"}, - {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98"}, - {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74"}, - {file = "regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0"}, - {file = "regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204"}, - {file = "regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9"}, - {file = "regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26"}, - {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4"}, - {file = "regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76"}, - {file = "regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a"}, - {file = "regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361"}, - {file = "regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160"}, - {file = "regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe"}, - {file = "regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850"}, - {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc"}, - {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9"}, - {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b"}, - {file = "regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7"}, - {file = "regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c"}, - {file = "regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5"}, - {file = "regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467"}, - {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281"}, - {file = "regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39"}, - {file = "regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7"}, - {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed"}, - {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19"}, - {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b"}, - {file = "regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a"}, - {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6"}, - {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce"}, - {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd"}, - {file = "regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2"}, - {file = "regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a"}, - {file = "regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c"}, - {file = "regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e"}, - {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6"}, - {file = "regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4"}, - {file = "regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73"}, - {file = "regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f"}, - {file = "regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d"}, - {file = "regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be"}, - {file = "regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db"}, - {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62"}, - {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f"}, - {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02"}, - {file = "regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed"}, - {file = "regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4"}, - {file = "regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad"}, - {file = "regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f"}, - {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc"}, - {file = "regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49"}, - {file = "regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536"}, - {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95"}, - {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009"}, - {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9"}, - {file = "regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d"}, - {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6"}, - {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154"}, - {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267"}, - {file = "regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379"}, - {file = "regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38"}, - {file = "regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de"}, - {file = "regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801"}, - {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:81519e25707fc076978c6143b81ea3dc853f176895af05bf7ec51effe818aeec"}, - {file = "regex-2025.11.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3bf28b1873a8af8bbb58c26cc56ea6e534d80053b41fb511a35795b6de507e6a"}, - {file = "regex-2025.11.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:856a25c73b697f2ce2a24e7968285579e62577a048526161a2c0f53090bea9f9"}, - {file = "regex-2025.11.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a3d571bd95fade53c86c0517f859477ff3a93c3fde10c9e669086f038e0f207"}, - {file = "regex-2025.11.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:732aea6de26051af97b94bc98ed86448821f839d058e5d259c72bf6d73ad0fc0"}, - {file = "regex-2025.11.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:51c1c1847128238f54930edb8805b660305dca164645a9fd29243f5610beea34"}, - {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22dd622a402aad4558277305350699b2be14bc59f64d64ae1d928ce7d072dced"}, - {file = "regex-2025.11.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f3b5a391c7597ffa96b41bd5cbd2ed0305f515fcbb367dfa72735679d5502364"}, - {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:cc4076a5b4f36d849fd709284b4a3b112326652f3b0466f04002a6c15a0c96c1"}, - {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a295ca2bba5c1c885826ce3125fa0b9f702a1be547d821c01d65f199e10c01e2"}, - {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:b4774ff32f18e0504bfc4e59a3e71e18d83bc1e171a3c8ed75013958a03b2f14"}, - {file = "regex-2025.11.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22e7d1cdfa88ef33a2ae6aa0d707f9255eb286ffbd90045f1088246833223aee"}, - {file = "regex-2025.11.3-cp39-cp39-win32.whl", hash = "sha256:74d04244852ff73b32eeede4f76f51c5bcf44bc3c207bc3e6cf1c5c45b890708"}, - {file = "regex-2025.11.3-cp39-cp39-win_amd64.whl", hash = "sha256:7a50cd39f73faa34ec18d6720ee25ef10c4c1839514186fcda658a06c06057a2"}, - {file = "regex-2025.11.3-cp39-cp39-win_arm64.whl", hash = "sha256:43b4fb020e779ca81c1b5255015fe2b82816c76ec982354534ad9ec09ad7c9e3"}, - {file = "regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01"}, + {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e"}, + {file = "regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f"}, + {file = "regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618"}, + {file = "regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13"}, + {file = "regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3"}, + {file = "regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218"}, + {file = "regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a"}, + {file = "regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3"}, + {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a"}, + {file = "regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f"}, + {file = "regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026"}, + {file = "regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2"}, + {file = "regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1"}, + {file = "regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569"}, + {file = "regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7"}, + {file = "regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec"}, + {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1"}, + {file = "regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681"}, + {file = "regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5"}, + {file = "regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d"}, + {file = "regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22"}, + {file = "regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913"}, + {file = "regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a"}, + {file = "regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056"}, + {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e"}, + {file = "regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10"}, + {file = "regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6"}, + {file = "regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31"}, + {file = "regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3"}, + {file = "regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f"}, + {file = "regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e"}, + {file = "regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337"}, + {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be"}, + {file = "regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8"}, + {file = "regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09"}, + {file = "regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2"}, + {file = "regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60"}, + {file = "regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952"}, + {file = "regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10"}, + {file = "regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829"}, + {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac"}, + {file = "regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6"}, + {file = "regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde"}, + {file = "regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160"}, + {file = "regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1"}, + {file = "regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1"}, + {file = "regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903"}, + {file = "regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705"}, + {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8"}, + {file = "regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf"}, + {file = "regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a"}, + {file = "regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521"}, + {file = "regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db"}, + {file = "regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e"}, + {file = "regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf"}, + {file = "regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70"}, + {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:55b4ea996a8e4458dd7b584a2f89863b1655dd3d17b88b46cbb9becc495a0ec5"}, + {file = "regex-2026.1.15-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7e1e28be779884189cdd57735e997f282b64fd7ccf6e2eef3e16e57d7a34a815"}, + {file = "regex-2026.1.15-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0057de9eaef45783ff69fa94ae9f0fd906d629d0bd4c3217048f46d1daa32e9b"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7cd0b2be0f0269283a45c0d8b2c35e149d1319dcb4a43c9c3689fa935c1ee6"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8db052bbd981e1666f09e957f3790ed74080c2229007c1dd67afdbf0b469c48b"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:343db82cb3712c31ddf720f097ef17c11dab2f67f7a3e7be976c4f82eba4e6df"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55e9d0118d97794367309635df398bdfd7c33b93e2fdfa0b239661cd74b4c14e"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:008b185f235acd1e53787333e5690082e4f156c44c87d894f880056089e9bc7c"}, + {file = "regex-2026.1.15-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fd65af65e2aaf9474e468f9e571bd7b189e1df3a61caa59dcbabd0000e4ea839"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f42e68301ff4afee63e365a5fc302b81bb8ba31af625a671d7acb19d10168a8c"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:f7792f27d3ee6e0244ea4697d92b825f9a329ab5230a78c1a68bd274e64b5077"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:dbaf3c3c37ef190439981648ccbf0c02ed99ae066087dd117fcb616d80b010a4"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:adc97a9077c2696501443d8ad3fa1b4fc6d131fc8fd7dfefd1a723f89071cf0a"}, + {file = "regex-2026.1.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:069f56a7bf71d286a6ff932a9e6fb878f151c998ebb2519a9f6d1cee4bffdba3"}, + {file = "regex-2026.1.15-cp39-cp39-win32.whl", hash = "sha256:ea4e6b3566127fda5e007e90a8fd5a4169f0cf0619506ed426db647f19c8454a"}, + {file = "regex-2026.1.15-cp39-cp39-win_amd64.whl", hash = "sha256:cda1ed70d2b264952e88adaa52eea653a33a1b98ac907ae2f86508eb44f65cdc"}, + {file = "regex-2026.1.15-cp39-cp39-win_arm64.whl", hash = "sha256:b325d4714c3c48277bfea1accd94e193ad6ed42b4bad79ad64f3b8f8a31260a5"}, + {file = "regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5"}, ] [[package]] name = "reportlab" -version = "4.4.7" +version = "4.4.9" description = "The Reportlab Toolkit" optional = false python-versions = "<4,>=3.9" groups = ["main"] files = [ - {file = "reportlab-4.4.7-py3-none-any.whl", hash = "sha256:8fa05cbf468e0e76745caf2029a4770276edb3c8e86a0b71e0398926baf50673"}, - {file = "reportlab-4.4.7.tar.gz", hash = "sha256:41e8287af965e5996764933f3e75e7f363c3b6f252ba172f9429e81658d7b170"}, + {file = "reportlab-4.4.9-py3-none-any.whl", hash = "sha256:68e2d103ae8041a37714e8896ec9b79a1c1e911d68c3bd2ea17546568cf17bfd"}, + {file = "reportlab-4.4.9.tar.gz", hash = "sha256:7cf487764294ee791a4781f5a157bebce262a666ae4bbb87786760a9676c9378"}, ] [package.dependencies] @@ -4015,7 +4388,7 @@ version = "2.32.5" description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -4190,31 +4563,31 @@ redis = ">=3.5,<6 || >6" [[package]] name = "ruff" -version = "0.14.10" +version = "0.14.13" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49"}, - {file = "ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f"}, - {file = "ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d"}, - {file = "ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77"}, - {file = "ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a"}, - {file = "ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f"}, - {file = "ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935"}, - {file = "ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e"}, - {file = "ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d"}, - {file = "ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f"}, - {file = "ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f"}, - {file = "ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d"}, - {file = "ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405"}, - {file = "ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60"}, - {file = "ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830"}, - {file = "ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6"}, - {file = "ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154"}, - {file = "ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6"}, - {file = "ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4"}, + {file = "ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b"}, + {file = "ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed"}, + {file = "ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841"}, + {file = "ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c"}, + {file = "ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b"}, + {file = "ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae"}, + {file = "ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e"}, + {file = "ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c"}, + {file = "ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680"}, + {file = "ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef"}, + {file = "ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247"}, + {file = "ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47"}, ] [[package]] @@ -4237,14 +4610,14 @@ crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] [[package]] name = "sentry-sdk" -version = "2.48.0" +version = "2.49.0" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "sentry_sdk-2.48.0-py2.py3-none-any.whl", hash = "sha256:6b12ac256769d41825d9b7518444e57fa35b5642df4c7c5e322af4d2c8721172"}, - {file = "sentry_sdk-2.48.0.tar.gz", hash = "sha256:5213190977ff7fdff8a58b722fb807f8d5524a80488626ebeda1b5676c0c1473"}, + {file = "sentry_sdk-2.49.0-py2.py3-none-any.whl", hash = "sha256:6ea78499133874445a20fe9c826c9e960070abeb7ae0cdf930314ab16bb97aa0"}, + {file = "sentry_sdk-2.49.0.tar.gz", hash = "sha256:c1878599cde410d481c04ef50ee3aedd4f600e4d0d253f4763041e468b332c30"}, ] [package.dependencies] @@ -4483,18 +4856,18 @@ doc = ["sphinx"] [[package]] name = "strawberry-graphql" -version = "0.288.2" +version = "0.289.0" description = "A library for creating GraphQL APIs" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "strawberry_graphql-0.288.2-py3-none-any.whl", hash = "sha256:ad72d7904582db333158568751bb6186a872380a8cc6671159d011d279382542"}, - {file = "strawberry_graphql-0.288.2.tar.gz", hash = "sha256:853dbab407e3f5099f3a27dbf37786535894a0fbf150df5dde145fc290db607e"}, + {file = "strawberry_graphql-0.289.0-py3-none-any.whl", hash = "sha256:8fd30c3de55bb518dae68088d43259c831a68a1a688870733a6fcba7512368d0"}, + {file = "strawberry_graphql-0.289.0.tar.gz", hash = "sha256:cb5b6718d7f6a99b4a5d6edf0d76a6b4e414e04b1ad93662dff402b52ed565f2"}, ] [package.dependencies] -asgiref = {version = ">=3.2,<4.0", optional = true, markers = "extra == \"django\""} +asgiref = {version = ">=3.2", optional = true, markers = "extra == \"django\""} cross-web = ">=0.4.0" Django = {version = ">=3.2", optional = true, markers = "extra == \"django\""} graphql-core = ">=3.2.0,<3.4.0" @@ -4505,12 +4878,12 @@ typing-extensions = ">=4.5.0" [package.extras] aiohttp = ["aiohttp (>=3.7.4.post0,<4)"] asgi = ["python-multipart (>=0.0.7)", "starlette (>=0.18.0)"] -chalice = ["chalice (>=1.22,<2.0)"] -channels = ["asgiref (>=3.2,<4.0)", "channels (>=3.0.5)"] -cli = ["libcst", "pygments (>=2.3,<3.0)", "python-multipart (>=0.0.7)", "rich (>=12.0.0)", "starlette (>=0.18.0)", "typer (>=0.12.4)", "uvicorn (>=0.11.6)", "websockets (>=15.0.1,<16)"] +chalice = ["chalice (>=1.22)"] +channels = ["asgiref (>=3.2)", "channels (>=3.0.5)"] +cli = ["libcst", "pygments (>=2.3)", "python-multipart (>=0.0.7)", "rich (>=12.0.0)", "starlette (>=0.18.0)", "typer (>=0.12.4)", "uvicorn (>=0.11.6)", "websockets (>=15.0.1,<16)"] debug = ["libcst", "rich (>=12.0.0)"] debug-server = ["libcst", "pygments (>=2.3)", "python-multipart (>=0.0.7)", "rich (>=12.0.0)", "starlette (>=0.18.0)", "typer (>=0.12.4)", "uvicorn (>=0.11.6)", "websockets (>=15.0.1,<16)"] -django = ["Django (>=3.2)", "asgiref (>=3.2,<4.0)"] +django = ["Django (>=3.2)", "asgiref (>=3.2)"] fastapi = ["fastapi (>=0.65.2)", "python-multipart (>=0.0.7)"] flask = ["flask (>=1.1)"] litestar = ["litestar (>=2) ; python_version ~= \"3.10\""] @@ -4522,14 +4895,14 @@ sanic = ["sanic (>=20.12.2)"] [[package]] name = "strawberry-graphql-django" -version = "0.73.0" +version = "0.73.1" description = "Strawberry GraphQL Django extension" optional = false python-versions = "<4.0,>=3.10" groups = ["main"] files = [ - {file = "strawberry_graphql_django-0.73.0-py3-none-any.whl", hash = "sha256:f85cd9fc4a03ca4a430ce787c84cfc5e79e0b075e83d89ed8e9eaf76d035ce13"}, - {file = "strawberry_graphql_django-0.73.0.tar.gz", hash = "sha256:39f7205ca28a29763e3260548f7739ad6729a717d3f64b851208f57940c6360b"}, + {file = "strawberry_graphql_django-0.73.1-py3-none-any.whl", hash = "sha256:cf073975ade25c9c54557fad093113dfb2a4884fdd0abca1df2e320e2bae40e0"}, + {file = "strawberry_graphql_django-0.73.1.tar.gz", hash = "sha256:b3096c2e6abbfa8db6b25fe52393150b1dbe24f13a4c2eb6ac80625b0d933c3b"}, ] [package.dependencies] @@ -4584,6 +4957,44 @@ files = [ [package.dependencies] rapidfuzz = ">=3.0.0,<4.0.0" +[[package]] +name = "tinycss2" +version = "1.5.1" +description = "A tiny CSS parser" +optional = false +python-versions = ">=3.10" +groups = ["video"] +files = [ + {file = "tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661"}, + {file = "tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957"}, +] + +[package.dependencies] +webencodings = ">=0.4" + +[package.extras] +doc = ["furo", "sphinx"] +test = ["pytest", "ruff"] + +[[package]] +name = "tinyhtml5" +version = "2.0.0" +description = "HTML parser based on the WHATWG HTML specification" +optional = false +python-versions = ">=3.9" +groups = ["video"] +files = [ + {file = "tinyhtml5-2.0.0-py3-none-any.whl", hash = "sha256:13683277c5b176d070f82d099d977194b7a1e26815b016114f581a74bbfbf47e"}, + {file = "tinyhtml5-2.0.0.tar.gz", hash = "sha256:086f998833da24c300c414d9fe81d9b368fd04cb9d2596a008421cbc705fcfcc"}, +] + +[package.dependencies] +webencodings = ">=0.5.1" + +[package.extras] +doc = ["sphinx", "sphinx_rtd_theme"] +test = ["pytest", "ruff"] + [[package]] name = "toml" version = "0.10.2" @@ -4642,7 +5053,7 @@ version = "4.15.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, @@ -4670,7 +5081,7 @@ version = "0.4.2" description = "Runtime typing introspection tools" optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "video"] files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, @@ -4694,14 +5105,14 @@ files = [ [[package]] name = "urllib3" -version = "2.6.2" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["main", "video"] files = [ - {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, - {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] @@ -4712,34 +5123,34 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uuid-utils" -version = "0.12.0" -description = "Drop-in replacement for Python UUID with bindings in Rust" +version = "0.13.0" +description = "Fast, drop-in replacement for Python's uuid module, powered by Rust." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "uuid_utils-0.12.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:3b9b30707659292f207b98f294b0e081f6d77e1fbc760ba5b41331a39045f514"}, - {file = "uuid_utils-0.12.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:add3d820c7ec14ed37317375bea30249699c5d08ff4ae4dbee9fc9bce3bfbf65"}, - {file = "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b8fce83ecb3b16af29c7809669056c4b6e7cc912cab8c6d07361645de12dd79"}, - {file = "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec921769afcb905035d785582b0791d02304a7850fbd6ce924c1a8976380dfc6"}, - {file = "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f3b060330f5899a92d5c723547dc6a95adef42433e9748f14c66859a7396664"}, - {file = "uuid_utils-0.12.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:908dfef7f0bfcf98d406e5dc570c25d2f2473e49b376de41792b6e96c1d5d291"}, - {file = "uuid_utils-0.12.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c6a24148926bd0ca63e8a2dabf4cc9dc329a62325b3ad6578ecd60fbf926506"}, - {file = "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:64a91e632669f059ef605f1771d28490b1d310c26198e46f754e8846dddf12f4"}, - {file = "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:93c082212470bb4603ca3975916c205a9d7ef1443c0acde8fbd1e0f5b36673c7"}, - {file = "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:431b1fb7283ba974811b22abd365f2726f8f821ab33f0f715be389640e18d039"}, - {file = "uuid_utils-0.12.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd7838c40149100299fa37cbd8bab5ee382372e8e65a148002a37d380df7c8"}, - {file = "uuid_utils-0.12.0-cp39-abi3-win32.whl", hash = "sha256:487f17c0fee6cbc1d8b90fe811874174a9b1b5683bf2251549e302906a50fed3"}, - {file = "uuid_utils-0.12.0-cp39-abi3-win_amd64.whl", hash = "sha256:9598e7c9da40357ae8fffc5d6938b1a7017f09a1acbcc95e14af8c65d48c655a"}, - {file = "uuid_utils-0.12.0-cp39-abi3-win_arm64.whl", hash = "sha256:c9bea7c5b2aa6f57937ebebeee4d4ef2baad10f86f1b97b58a3f6f34c14b4e84"}, - {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e2209d361f2996966ab7114f49919eb6aaeabc6041672abbbbf4fdbb8ec1acc0"}, - {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d9636bcdbd6cfcad2b549c352b669412d0d1eb09be72044a2f13e498974863cd"}, - {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cd8543a3419251fb78e703ce3b15fdfafe1b7c542cf40caf0775e01db7e7674"}, - {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e98db2d8977c052cb307ae1cb5cc37a21715e8d415dbc65863b039397495a013"}, - {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8f2bdf5e4ffeb259ef6d15edae92aed60a1d6f07cbfab465d836f6b12b48da8"}, - {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c3ec53c0cb15e1835870c139317cc5ec06e35aa22843e3ed7d9c74f23f23898"}, - {file = "uuid_utils-0.12.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:84e5c0eba209356f7f389946a3a47b2cc2effd711b3fc7c7f155ad9f7d45e8a3"}, - {file = "uuid_utils-0.12.0.tar.gz", hash = "sha256:252bd3d311b5d6b7f5dfce7a5857e27bb4458f222586bb439463231e5a9cbd64"}, + {file = "uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:83628283e977fb212e756bc055df8fdd2f9f589a2e539ba1abe755b8ce8df7a4"}, + {file = "uuid_utils-0.13.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c47638ed6334ab19d80f73664f153b04bbb04ab8ce4298d10da6a292d4d21c47"}, + {file = "uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:b276b538c57733ed406948584912da422a604313c71479654848b84b9e19c9b0"}, + {file = "uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_armv7l.whl", hash = "sha256:bdaf2b77e34b199cf04cde28399495fd1ed951de214a4ece1f3919b2f945bb06"}, + {file = "uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_i686.whl", hash = "sha256:eb2f0baf81e82f9769a7684022dca8f3bf801ca1574a3e94df1876e9d6f9271e"}, + {file = "uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_ppc64le.whl", hash = "sha256:6be6c4d11275f5cc402a4fdba6c2b1ce45fd3d99bb78716cd1cc2cbf6802b2ce"}, + {file = "uuid_utils-0.13.0-cp39-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:77621cf6ceca7f42173a642a01c01c216f9eaec3b7b65d093d2d6a433ca0a83d"}, + {file = "uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a5a9eb06c2bb86dd876cd7b2fe927fc8543d14c90d971581db6ffda4a02526f"}, + {file = "uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:775347c6110fb71360df17aac74132d8d47c1dbe71233ac98197fc872a791fd2"}, + {file = "uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf95f6370ad1a0910ee7b5ad5228fd19c4ae32fe3627389006adaf519408c41e"}, + {file = "uuid_utils-0.13.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5a88e23e0b2f4203fefe2ccbca5736ee06fcad10e61b5e7e39c8d7904bc13300"}, + {file = "uuid_utils-0.13.0-cp39-abi3-win32.whl", hash = "sha256:3e4f2cc54e6a99c0551158100ead528479ad2596847478cbad624977064ffce3"}, + {file = "uuid_utils-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:046cb2756e1597b3de22d24851b769913e192135830486a0a70bf41327f0360c"}, + {file = "uuid_utils-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:5447a680df6ef8a5a353976aaf4c97cc3a3a22b1ee13671c44227b921e3ae2a9"}, + {file = "uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e5182e2d95f38e65f2e5bce90648ef56987443da13e145afcd747e584f9bc69c"}, + {file = "uuid_utils-0.13.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:e3909a8a1fbd79d7c8bdc874eeb83e23ccb7a7cb0aa821a49596cc96c0cce84b"}, + {file = "uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:5dc4c9f749bd2511b8dcbf0891e658d7d86880022963db050722ad7b502b5e22"}, + {file = "uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_armv7l.whl", hash = "sha256:516adf07f5b2cdb88d50f489c702b5f1a75ae8b2639bfd254f4192d5f7ee261f"}, + {file = "uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_i686.whl", hash = "sha256:aeee3bd89e8de6184a3ab778ce19f5ce9ad32849d1be549516e0ddb257562d8d"}, + {file = "uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_ppc64le.whl", hash = "sha256:97985256c2e59b7caa51f5c8515f64d777328562a9c900ec65e9d627baf72737"}, + {file = "uuid_utils-0.13.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:b7ccaa20e24c5f60f41a69ef571ed820737f9b0ade4cbeef56aaa8f80f5aa475"}, + {file = "uuid_utils-0.13.0.tar.gz", hash = "sha256:4c17df6427a9e23a4cd7fb9ee1efb53b8abb078660b9bdb2524ca8595022dfe1"}, ] [[package]] @@ -4759,35 +5170,144 @@ crypto-eth-addresses = ["eth-hash[pycryptodome] (>=0.7.0)"] [[package]] name = "virtualenv" -version = "20.35.4" +version = "20.36.1" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"}, - {file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"}, + {file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"}, + {file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"}, ] [package.dependencies] distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" +filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" [package.extras] docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] +[[package]] +name = "weasyprint" +version = "67.0" +description = "The Awesome Document Factory" +optional = false +python-versions = ">=3.10" +groups = ["video"] +files = [ + {file = "weasyprint-67.0-py3-none-any.whl", hash = "sha256:abc2f40872ea01c29c11f7799dafc4b23c078335bf7777f72a8affeb36e1d201"}, + {file = "weasyprint-67.0.tar.gz", hash = "sha256:fdfbccf700e8086c8fd1607ec42e25d4b584512c29af2d9913587a4e448dead4"}, +] + +[package.dependencies] +cffi = ">=0.6" +cssselect2 = ">=0.8.0" +fonttools = {version = ">=4.59.2", extras = ["woff"]} +Pillow = ">=9.1.0" +pydyf = ">=0.11.0" +Pyphen = ">=0.9.1" +tinycss2 = ">=1.5.0" +tinyhtml5 = ">=2.0.0b1" + +[package.extras] +doc = ["furo", "sphinx"] +test = ["pytest", "ruff"] + +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = false +python-versions = "*" +groups = ["video"] +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 = "16.0" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.10" +groups = ["video"] +files = [ + {file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"}, + {file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"}, + {file = "websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957"}, + {file = "websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72"}, + {file = "websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3"}, + {file = "websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3"}, + {file = "websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9"}, + {file = "websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8"}, + {file = "websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad"}, + {file = "websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d"}, + {file = "websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe"}, + {file = "websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5"}, + {file = "websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64"}, + {file = "websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6"}, + {file = "websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00"}, + {file = "websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79"}, + {file = "websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39"}, + {file = "websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c"}, + {file = "websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1"}, + {file = "websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2"}, + {file = "websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89"}, + {file = "websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9"}, + {file = "websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230"}, + {file = "websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c"}, + {file = "websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5"}, + {file = "websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8"}, + {file = "websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f"}, + {file = "websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a"}, + {file = "websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0"}, + {file = "websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904"}, + {file = "websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4"}, + {file = "websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e"}, + {file = "websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1"}, + {file = "websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3"}, + {file = "websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8"}, + {file = "websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244"}, + {file = "websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e"}, + {file = "websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641"}, + {file = "websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8"}, + {file = "websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944"}, + {file = "websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206"}, + {file = "websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6"}, + {file = "websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d"}, + {file = "websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da"}, + {file = "websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c"}, + {file = "websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767"}, + {file = "websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec"}, + {file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"}, +] + [[package]] name = "werkzeug" -version = "3.1.4" +version = "3.1.5" description = "The comprehensive WSGI web application library." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "werkzeug-3.1.4-py3-none-any.whl", hash = "sha256:2ad50fb9ed09cc3af22c54698351027ace879a0b60a3b5edf5730b2f7d876905"}, - {file = "werkzeug-3.1.4.tar.gz", hash = "sha256:cd3cd98b1b92dc3b7b3995038826c68097dcb16f9baa63abe35f20eafeb9fe5e"}, + {file = "werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc"}, + {file = "werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67"}, ] [package.dependencies] @@ -5138,6 +5658,31 @@ troposphere = ">=3.0" werkzeug = "*" wheel = "*" +[[package]] +name = "zopfli" +version = "0.4.0" +description = "Zopfli module for python" +optional = false +python-versions = ">=3.10" +groups = ["video"] +files = [ + {file = "zopfli-0.4.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d1b98ad47c434ef213444a03ef2f826eeec100144d64f6a57504b9893d3931ce"}, + {file = "zopfli-0.4.0-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:18b5f1570f64d4988482e4466f10ef5f2a30f687c19ad62a64560f2152dc89eb"}, + {file = "zopfli-0.4.0-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b72a010d205d00b2855acc2302772067362f9ab5a012e3550662aec60d28e6b3"}, + {file = "zopfli-0.4.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c3ba02a9a6ca90481d2b2f68bab038b310d63a1e3b5ae305e95a6599787ed941"}, + {file = "zopfli-0.4.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7d66337be6d5613dec55213e9ac28f378c41e2cc04fbad4a10748e4df774ca85"}, + {file = "zopfli-0.4.0-cp310-abi3-win32.whl", hash = "sha256:03181d48e719fcb6cf8340189c61e8f9883d8bbbdf76bf5212a74457f7d083c1"}, + {file = "zopfli-0.4.0-cp310-abi3-win_amd64.whl", hash = "sha256:f94e4dd7d76b4fe9f5d9229372be20d7f786164eea5152d1af1c34298c3d5975"}, + {file = "zopfli-0.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b8bdb41fbfdc4738b7bdc09ed7c1e951579fae192391a5e694d59bb186cdbec7"}, + {file = "zopfli-0.4.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9097e8e1dfdb7f5aea5464e469946857e80502b6d29ba1b232450916bd4a74d1"}, + {file = "zopfli-0.4.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f67d04280065e24cb9a4174cb6b3d1f763687f8cb2963aa135ad8f57c6995f5a"}, + {file = "zopfli-0.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:25e4863b8dc30e5d5309f87c106b0b7d3da4ed0e340b8a52b36d4471e797589f"}, + {file = "zopfli-0.4.0.tar.gz", hash = "sha256:a8ee992b2549e090cd3f0178bf606dd41a29e0613a04cdf5054224662c72dce6"}, +] + +[package.extras] +test = ["pytest"] + [[package]] name = "zstandard" version = "0.25.0" @@ -5253,4 +5798,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt [metadata] lock-version = "2.1" python-versions = "^3.13" -content-hash = "5401f036ef6e58493aafd538a4d405d6e14bf61a7b517692bdb6071a84263074" +content-hash = "5b8e6111b2a60c75778666b6250cadf8b6bcb74494340e586ae0b3a0358369b6" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index a38c0df65e..34c38dfb63 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -46,7 +46,7 @@ requests = "^2.32.5" sentry-sdk = { extras = [ "django" ], version = "^2.20.0" } slack-bolt = "^1.22.0" slack-sdk = "^3.37.0" -strawberry-graphql = { extras = [ "django" ], version = "^0.288.1" } +strawberry-graphql = { extras = [ "django" ], version = "^0.289.0" } strawberry-graphql-django = "^0.73.0" thefuzz = "^0.22.1" pyparsing = "^3.2.3" @@ -65,6 +65,13 @@ pytest-mock = "^3.0" pytest-xdist = "^3.0" python-dotenv = "^1.0.1" +[tool.poetry.group.video.dependencies] +elevenlabs = "^2.27.0" +ffmpeg-python = "^0.2.0" +pillow = "^12.1.0" +pypdfium2 = "^5.2.0" +weasyprint = "^67.0" + [tool.ruff] target-version = "py313" diff --git a/backend/tests/apps/common/eleven_labs_test.py b/backend/tests/apps/common/eleven_labs_test.py new file mode 100644 index 0000000000..3113debfc1 --- /dev/null +++ b/backend/tests/apps/common/eleven_labs_test.py @@ -0,0 +1,176 @@ +from unittest.mock import patch + +import pytest + +from apps.common.eleven_labs import ElevenLabs + +DEFAULT_API_KEY = "test_api_key" +DEFAULT_MODEL_ID = "eleven_multilingual_v2" +DEFAULT_OUTPUT_FORMAT = "mp3_44100_128" +DEFAULT_SIMILARITY_BOOST = 0.75 +DEFAULT_SPEED = 1.0 +DEFAULT_STABILITY = 0.5 +DEFAULT_STYLE = 0.0 +DEFAULT_TIMEOUT = 30 +DEFAULT_USE_SPEAKER_BOOST = True +DEFAULT_VOICE_ID = "1SM7GgM6IMuvQlz2BwM3" # cspell:disable-line + + +class TestElevenLabs: + @pytest.fixture + def elevenlabs_instance(self): + with ( + patch.dict("os.environ", {"DJANGO_ELEVENLABS_API_KEY": DEFAULT_API_KEY}), + patch("apps.common.eleven_labs.ElevenLabsClient"), + ): + return ElevenLabs() + + @patch("apps.common.eleven_labs.ElevenLabsClient") + def test_init(self, mock_client): + with patch.dict("os.environ", {"DJANGO_ELEVENLABS_API_KEY": DEFAULT_API_KEY}): + instance = ElevenLabs() + + mock_client.assert_called_once_with(api_key=DEFAULT_API_KEY, timeout=DEFAULT_TIMEOUT) + assert instance.model_id == DEFAULT_MODEL_ID + assert instance.output_format == DEFAULT_OUTPUT_FORMAT + assert instance.similarity_boost == DEFAULT_SIMILARITY_BOOST + assert instance.speed == DEFAULT_SPEED + assert instance.stability == DEFAULT_STABILITY + assert instance.style == DEFAULT_STYLE + assert instance.use_speaker_boost == DEFAULT_USE_SPEAKER_BOOST + assert instance.voice_id == DEFAULT_VOICE_ID + + def test_init_raises_without_api_key(self): + """Test init raises ValueError when API key not set.""" + with ( + patch.dict("os.environ", {}, clear=True), + pytest.raises(ValueError, match="DJANGO_ELEVENLABS_API_KEY"), + ): + ElevenLabs() + + @pytest.mark.parametrize( + ("model_id", "expected_model_id"), + [("eleven_monolingual_v1", "eleven_monolingual_v1")], + ) + def test_set_model_id(self, elevenlabs_instance, model_id, expected_model_id): + result = elevenlabs_instance.set_model_id(model_id) + assert result.model_id == expected_model_id + + @pytest.mark.parametrize( + ("output_format", "expected_output_format"), + [("mp3_22050_32", "mp3_22050_32")], + ) + def test_set_output_format(self, elevenlabs_instance, output_format, expected_output_format): + result = elevenlabs_instance.set_output_format(output_format) + assert result.output_format == expected_output_format + + @pytest.mark.parametrize( + ("similarity_boost", "expected_similarity_boost"), + [(0.9, 0.9)], + ) + def test_set_similarity_boost( + self, elevenlabs_instance, similarity_boost, expected_similarity_boost + ): + result = elevenlabs_instance.set_similarity_boost(similarity_boost) + assert result.similarity_boost == expected_similarity_boost + + @pytest.mark.parametrize( + ("speed", "expected_speed"), + [(0.85, 0.85)], + ) + def test_set_speed(self, elevenlabs_instance, speed, expected_speed): + result = elevenlabs_instance.set_speed(speed) + assert result.speed == expected_speed + + @pytest.mark.parametrize( + ("stability", "expected_stability"), + [(0.8, 0.8)], + ) + def test_set_stability(self, elevenlabs_instance, stability, expected_stability): + result = elevenlabs_instance.set_stability(stability) + assert result.stability == expected_stability + + @pytest.mark.parametrize( + ("style", "expected_style"), + [(0.5, 0.5)], + ) + def test_set_style(self, elevenlabs_instance, style, expected_style): + result = elevenlabs_instance.set_style(style) + assert result.style == expected_style + + @pytest.mark.parametrize( + ("text", "expected_text"), + [("Hello, world!", "Hello, world!")], + ) + def test_set_text(self, elevenlabs_instance, text, expected_text): + result = elevenlabs_instance.set_text(text) + assert result.text == expected_text + + @pytest.mark.parametrize( + ("use_speaker_boost", "expected_use_speaker_boost"), + [(False, False)], + ) + def test_set_use_speaker_boost( + self, elevenlabs_instance, use_speaker_boost, expected_use_speaker_boost + ): + result = elevenlabs_instance.set_use_speaker_boost(use_speaker_boost=use_speaker_boost) + assert result.use_speaker_boost == expected_use_speaker_boost + + @pytest.mark.parametrize( + ("voice_id", "expected_voice_id"), + [("test_voice_id", "test_voice_id")], + ) + def test_set_voice_id(self, elevenlabs_instance, voice_id, expected_voice_id): + result = elevenlabs_instance.set_voice_id(voice_id) + assert result.voice_id == expected_voice_id + + @patch("apps.common.eleven_labs.ElevenLabsClient") + def test_generate_raises_on_api_error(self, mock_client): + """Test generate raises exception on API error.""" + mock_client.return_value.text_to_speech.convert.side_effect = Exception("API Error") + + with patch.dict("os.environ", {"DJANGO_ELEVENLABS_API_KEY": DEFAULT_API_KEY}): + instance = ElevenLabs() + instance.set_text("Test text") + + with pytest.raises(Exception, match="API Error"): + instance.generate() + + @patch("apps.common.eleven_labs.VoiceSettings") + @patch("apps.common.eleven_labs.ElevenLabsClient") + def test_generate_success(self, mock_client, mock_voice_settings): + mock_client.return_value.text_to_speech.convert.return_value = [b"audio", b"data"] + + with patch.dict("os.environ", {"DJANGO_ELEVENLABS_API_KEY": DEFAULT_API_KEY}): + instance = ElevenLabs() + instance.set_text("Test text") + response = instance.generate() + + assert response == b"audiodata" + mock_client.return_value.text_to_speech.convert.assert_called_once() + + @patch("apps.common.eleven_labs.ElevenLabsClient") + def test_save_creates_file(self, mock_client, tmp_path): + """Test save writes contents to file.""" + with patch.dict("os.environ", {"DJANGO_ELEVENLABS_API_KEY": DEFAULT_API_KEY}): + instance = ElevenLabs() + file_path = tmp_path / "output.mp3" + contents = b"audio_data" + + instance.save(contents, file_path) + + assert file_path.exists() + assert file_path.read_bytes() == b"audio_data" + + @patch("apps.common.eleven_labs.ElevenLabsClient") + def test_save_creates_parent_directories(self, mock_client, tmp_path): + """Test save creates parent directories if they don't exist.""" + with patch.dict("os.environ", {"DJANGO_ELEVENLABS_API_KEY": DEFAULT_API_KEY}): + instance = ElevenLabs() + file_path = tmp_path / "nested" / "dir" / "output.mp3" + contents = b"audio_data" + + instance.save(contents, file_path) + + assert file_path.exists() + assert file_path.read_bytes() == b"audio_data" diff --git a/backend/tests/apps/github/models/organization_test.py b/backend/tests/apps/github/models/organization_test.py index a7f6dded08..35983a60a9 100644 --- a/backend/tests/apps/github/models/organization_test.py +++ b/backend/tests/apps/github/models/organization_test.py @@ -12,7 +12,7 @@ def test_bulk_save(self): mock_org = [Mock(id=None), Mock(id=1)] with patch("apps.common.models.BulkSaveModel.bulk_save") as mock_bulk_save: Organization.bulk_save(mock_org) - mock_bulk_save.assert_called_once_with(Organization, mock_org) + mock_bulk_save.assert_called_once_with(Organization, mock_org, fields=None) @patch("apps.github.models.organization.Organization.objects.get") def test_update_data(self, mock_get): diff --git a/backend/tests/apps/nest/management/commands/base_badge_command_test.py b/backend/tests/apps/nest/management/commands/base_badge_command_test.py new file mode 100644 index 0000000000..702b1be905 --- /dev/null +++ b/backend/tests/apps/nest/management/commands/base_badge_command_test.py @@ -0,0 +1,51 @@ +"""Tests for base badge command.""" + +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest +from django.test import SimpleTestCase + +from apps.nest.management.commands.base_badge_command import BaseBadgeCommand + + +class MockCommand(BaseBadgeCommand): + badge_css_class = "fa-test" + badge_description = "Test" + badge_name = "Test Badge" + badge_weight = 50 + + def get_eligible_users(self): + return MagicMock() + + +class TestBaseBadgeCommand(SimpleTestCase): + def test_requires_badge_name(self): + class NoName(BaseBadgeCommand): + def get_eligible_users(self): + return MagicMock() + + with pytest.raises(ValueError, match="Badge name"): + NoName().handle() + + @patch("apps.nest.management.commands.base_badge_command.UserBadge") + @patch("apps.nest.management.commands.base_badge_command.Badge") + def test_syncs_badge(self, mock_badge, mock_user_badge): + badge = MagicMock() + badge.name = "Test Badge" + mock_badge.objects.get_or_create.return_value = (badge, False) + + qs = MagicMock() + qs.exclude.return_value = [] + MockCommand.get_eligible_users = MagicMock(return_value=qs) + + mock_user_badge.objects.filter.return_value.exclude.return_value.count.return_value = 0 + + out = StringIO() + cmd = MockCommand() + cmd.stdout = out + cmd.handle() + + output = out.getvalue() + assert "Test Badge" in output + assert "synced successfully" in output diff --git a/backend/tests/apps/nest/management/commands/nest_update_badges_test.py b/backend/tests/apps/nest/management/commands/nest_update_badges_test.py deleted file mode 100644 index 56b708e54c..0000000000 --- a/backend/tests/apps/nest/management/commands/nest_update_badges_test.py +++ /dev/null @@ -1,339 +0,0 @@ -"""Tests for the nest_update_badges management command.""" - -from io import StringIO -from unittest.mock import MagicMock, patch - -from django.core.management import call_command - -from apps.nest.management.commands.nest_update_badges import ( - OWASP_PROJECT_LEADER_BADGE_NAME, - OWASP_STAFF_BADGE_NAME, -) - - -def make_mock_employees(mock_employee): - mock_employees = MagicMock() - mock_employees_without_badge = MagicMock() - # This makes iterating over employees_without_badge yield mock_employee (not a list) - mock_employees_without_badge.__iter__.return_value = iter([mock_employee]) - mock_employees_without_badge.count.return_value = 1 - mock_employees_without_badge.values_list.return_value = [mock_employee.id] - mock_employees_without_badge.distinct.return_value = mock_employees_without_badge - mock_employees.exclude.return_value = mock_employees_without_badge - return mock_employees, mock_employees_without_badge - - -def make_mock_former_employees(mock_former_employee): - mock_former_employees = MagicMock() - mock_former_employees.__iter__.return_value = iter([mock_former_employee]) - mock_former_employees.count.return_value = 1 - mock_former_employees.values_list.return_value = [mock_former_employee.id] - mock_former_employees.distinct.return_value = mock_former_employees - return mock_former_employees - - -def make_mock_project_leaders(mock_leader): - """Create mock objects for project leaders query chain.""" - mock_filtered_leaders = MagicMock() - mock_distinct_leaders = MagicMock() - mock_leaders_without_badge = MagicMock() - mock_leaders_without_badge.__iter__.return_value = iter([mock_leader]) - mock_leaders_without_badge.count.return_value = 1 - mock_filtered_leaders.distinct.return_value = mock_distinct_leaders - mock_distinct_leaders.exclude.return_value = mock_leaders_without_badge - return mock_filtered_leaders - - -def extract_is_owasp_staff(arg): - """Extract is_owasp_staff value from Q object, dict, or tuple.""" - if hasattr(arg, "children"): - for key, value in arg.children: - if key == "is_owasp_staff": - return value - if isinstance(arg, dict) and "is_owasp_staff" in arg: - return arg["is_owasp_staff"] - if isinstance(arg, tuple) and len(arg) == 2 and arg[0] == "is_owasp_staff": - return arg[1] - return None - - -def user_filter_side_effect_factory(mock_employees, mock_former_employees): - """Create a side effect function for User.objects.filter.""" - - def get_mock_for_staff_value(value): - if value is True: - return mock_employees - if value is False: - return mock_former_employees - return None - - def user_filter_side_effect(*args, **kwargs): - staff_value = kwargs.get("is_owasp_staff") - if staff_value is not None: - return get_mock_for_staff_value(staff_value) - for arg in args: - staff_value = extract_is_owasp_staff(arg) - result = get_mock_for_staff_value(staff_value) - if result: - return result - return MagicMock() - - return user_filter_side_effect - - -class TestSyncUserBadgesCommand: - """Tests for the nest_update_badges management command.""" - - @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.User.objects.filter") - def test_sync_owasp_staff_badge( - self, - mock_user_filter, - mock_badge_get_or_create, - mock_user_badge_filter, - mock_user_badge_get_or_create, - mock_content_type_get, - mock_entity_member_filter, - ): - # Set up badge mock - mock_badge = MagicMock() - mock_badge.name = OWASP_STAFF_BADGE_NAME - mock_badge.id = 1 - mock_badge_get_or_create.return_value = (mock_badge, False) - - # Set up employee mocks - mock_employee = MagicMock() - mock_former_employee = MagicMock() - mock_employee.id = 456 - mock_former_employee.id = 123 - mock_employees, _ = make_mock_employees(mock_employee) - mock_former_employees = make_mock_former_employees(mock_former_employee) - - mock_user_filter.side_effect = user_filter_side_effect_factory( - mock_employees, mock_former_employees - ) - - mock_user_badge_get_or_create.return_value = (MagicMock(), True) - - out = StringIO() - call_command("nest_update_badges", stdout=out) - - # Assert correct badge assignment and removal calls - mock_user_badge_get_or_create.assert_any_call(user=mock_employee, badge=mock_badge) - mock_user_badge_filter.assert_any_call( - user_id__in=mock_former_employees.values_list.return_value, badge=mock_badge - ) - - # Check command output - output = out.getvalue() - assert "User badges sync completed" in output - assert any(s in output for s in ("Added badge to 1 staff", "Added badge to 1 employees")) - assert any( - s in output - for s in ("Removed badge from 1 non-staff", "Removed badge from 1 non-employees") - ) - - @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.User.objects.filter") - def test_sync_owasp_project_leader_badge( - self, - mock_user_filter, - mock_badge_get_or_create, - mock_user_badge_filter, - mock_user_badge_get_or_create, - mock_content_type_get_for_model, - mock_entity_member_filter, - ): - mock_badge = MagicMock() - mock_badge.name = OWASP_PROJECT_LEADER_BADGE_NAME - mock_badge_get_or_create.return_value = (mock_badge, False) - mock_leader = MagicMock() - mock_leader.id = 999 - mock_project_leaders = make_mock_project_leaders(mock_leader) - - def user_filter_side_effect(*_args, **kwargs): - if "id__in" in kwargs: - return mock_project_leaders - return MagicMock() - - mock_user_filter.side_effect = user_filter_side_effect - mock_user_badge_get_or_create.return_value = (MagicMock(), True) - mock_entity_qs = MagicMock() - mock_values_qs = MagicMock() - mock_distinct_qs = MagicMock() - mock_entity_member_filter.return_value = mock_entity_qs - mock_entity_qs.values_list.return_value = mock_values_qs - mock_values_qs.distinct.return_value = mock_distinct_qs - mock_distinct_qs.__iter__.return_value = iter([mock_leader.id]) - out = StringIO() - call_command("nest_update_badges", stdout=out) - - mock_badge_get_or_create.assert_any_call( - name=OWASP_PROJECT_LEADER_BADGE_NAME, - defaults={ - "description": "Official OWASP Project Leader", - "css_class": "fa-user-shield", - "weight": 90, - }, - ) - - mock_user_badge_get_or_create.assert_any_call(user=mock_leader, badge=mock_badge) - - output = out.getvalue() - assert "Added badge to 1 project leaders" in output - - @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.User.objects.filter") - def test_badge_creation( - self, - mock_user_filter, - mock_badge_get_or_create, - mock_user_badge_filter, - mock_user_badge_get_or_create, - mock_content_type_get, - mock_entity_member_filter, - ): - mock_badge = MagicMock() - mock_badge.name = OWASP_STAFF_BADGE_NAME - mock_badge_get_or_create.return_value = (mock_badge, True) - mock_employees = MagicMock() - mock_employees.__iter__.return_value = iter([]) - mock_employees.count.return_value = 0 - mock_employees.exclude.return_value = mock_employees - mock_employees.values_list.return_value = [] - mock_employees.exclude.return_value.values_list.return_value = [] - mock_employees.exclude.return_value.distinct.return_value = mock_employees - mock_employees.exclude.return_value.distinct.return_value.values_list.return_value = [] - - mock_former_employees = MagicMock() - mock_former_employees.__iter__.return_value = iter([]) - mock_former_employees.count.return_value = 0 - mock_former_employees.values_list.return_value = [] - mock_former_employees.distinct.return_value = mock_former_employees - - mock_user_filter.side_effect = [ - mock_employees, - mock_former_employees, - mock_employees, - ] - mock_entity_qs = MagicMock() - mock_values_qs = MagicMock() - mock_distinct_qs = MagicMock() - mock_entity_member_filter.return_value = mock_entity_qs - mock_entity_qs.values_list.return_value = mock_values_qs - mock_values_qs.distinct.return_value = mock_distinct_qs - mock_distinct_qs.__iter__.return_value = iter([]) - - out = StringIO() - call_command("nest_update_badges", stdout=out) - - mock_badge_get_or_create.assert_any_call( - name=OWASP_STAFF_BADGE_NAME, - defaults={ - "description": "Official OWASP Staff", - "css_class": "fa-user-shield", - "weight": 100, - }, - ) - output = out.getvalue() - assert f"Created badge: {mock_badge.name}" in output - - @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.User.objects.filter") - def test_command_idempotency( - self, - mock_user_filter, - mock_badge_get_or_create, - mock_user_badge_filter, - mock_user_badge_get_or_create, - mock_content_type_get, - mock_entity_member_filter, - ): - """Test that running the command multiple times has the same effect as running it once.""" - # Set up badge mock - mock_badge = MagicMock() - mock_badge.name = OWASP_STAFF_BADGE_NAME - mock_badge.id = 1 - mock_badge_get_or_create.return_value = (mock_badge, False) - - # Set up employee mock that already has the badge - mock_employee_with_badge = MagicMock() - mock_employees = MagicMock() - mock_employees.__iter__.return_value = iter([mock_employee_with_badge]) - mock_employees.exclude.return_value = MagicMock() - mock_employees.exclude.return_value.count.return_value = 0 - mock_employees.exclude.return_value.values_list.return_value = [] - mock_employees.exclude.return_value.distinct.return_value = ( - mock_employees.exclude.return_value - ) - - # No former employees have the badge - mock_non_employees_filter = MagicMock() - mock_non_employees_filter.count.return_value = 0 - mock_non_employees_filter.__iter__.return_value = iter([]) - mock_non_employees_filter.values_list.return_value = [] - mock_non_employees_filter.distinct.return_value = mock_non_employees_filter - - mock_leaders = MagicMock() - mock_leaders.distinct.return_value = mock_leaders - mock_leaders.exclude.return_value = mock_leaders - mock_leaders.count.return_value = 0 - - # Configure filter side effects for two command runs - mock_user_filter.side_effect = [ - mock_employees, - mock_non_employees_filter, - mock_leaders, - mock_employees, - mock_non_employees_filter, - mock_leaders, - ] - mock_entity_qs = MagicMock() - mock_values_qs = MagicMock() - mock_distinct_qs = MagicMock() - mock_entity_member_filter.return_value = mock_entity_qs - mock_entity_qs.values_list.return_value = mock_values_qs - mock_values_qs.distinct.return_value = mock_distinct_qs - mock_distinct_qs.__iter__.return_value = iter([]) - mock_user_badge_filter.return_value.count.return_value = 0 - mock_user_badge_filter.return_value.exclude.return_value.count.return_value = 0 - - # First run - out1 = StringIO() - call_command("nest_update_badges", stdout=out1) - - # Second run - out2 = StringIO() - call_command("nest_update_badges", stdout=out2) - - # Check both outputs contain zero-count messages - assert any( - s in out1.getvalue() for s in ("Added badge to 0 employees", "Added badge to 0 staff") - ) - assert any( - s in out1.getvalue() - for s in ("Removed badge from 0 non-employees", "Removed badge from 0 non-staff") - ) - assert any( - s in out2.getvalue() for s in ("Added badge to 0 employees", "Added badge to 0 staff") - ) - assert any( - s in out2.getvalue() - for s in ("Removed badge from 0 non-employees", "Removed badge from 0 non-staff") - ) diff --git a/backend/tests/apps/nest/management/commands/nest_update_project_leader_badges_test.py b/backend/tests/apps/nest/management/commands/nest_update_project_leader_badges_test.py new file mode 100644 index 0000000000..512974ce4b --- /dev/null +++ b/backend/tests/apps/nest/management/commands/nest_update_project_leader_badges_test.py @@ -0,0 +1,59 @@ +"""Tests for project leader badge command.""" + +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest +from django.core.management import call_command +from django.test import SimpleTestCase + +from apps.nest.management.commands.nest_update_project_leader_badges import Command + + +class TestProjectLeaderBadgeCommand(SimpleTestCase): + def test_has_correct_metadata(self): + assert Command.badge_name == "OWASP Project Leader" + assert Command.badge_weight == 90 + + @patch("apps.nest.management.commands.nest_update_project_leader_badges.User") + @patch("apps.nest.management.commands.nest_update_project_leader_badges.EntityMember") + @patch("apps.nest.management.commands.nest_update_project_leader_badges.Project") + @patch("apps.nest.management.commands.nest_update_project_leader_badges.ContentType") + @patch("apps.nest.management.commands.base_badge_command.UserBadge") + @patch("apps.nest.management.commands.base_badge_command.Badge") + def test_command_runs( + self, mock_badge, mock_user_badge, mock_ct, mock_project, mock_em, mock_user + ): + badge = MagicMock() + badge.name = "OWASP Project Leader" + mock_badge.objects.get_or_create.return_value = (badge, False) + + mock_ct.objects.get_for_model.return_value = MagicMock() + leaders_qs = MagicMock() + mock_em.objects.filter.return_value = leaders_qs + leaders_qs.values_list.return_value = [] + + qs = MagicMock() + qs.exclude.return_value = [] + qs.distinct.return_value = qs + mock_user.objects.filter.return_value = qs + mock_user_badge.objects.filter.return_value.exclude.return_value.count.return_value = 0 + + out = StringIO() + call_command("nest_update_project_leader_badges", stdout=out) + assert "Project Leader" in out.getvalue() + + @patch("apps.nest.management.commands.nest_update_project_leader_badges.ContentType") + @patch("apps.nest.management.commands.base_badge_command.Badge") + @patch( + "apps.nest.management.commands.nest_update_project_leader_badges.EntityMember.objects.filter", + side_effect=Exception("error"), + ) + def test_handles_errors(self, mock_filter, mock_badge, mock_content_type): + badge = MagicMock() + badge.name = "OWASP Project Leader" + mock_badge.objects.get_or_create.return_value = (badge, False) + mock_content_type.objects.get_for_model.return_value = MagicMock() + + with pytest.raises(Exception, match="error"): + call_command("nest_update_project_leader_badges", stdout=StringIO()) diff --git a/backend/tests/apps/nest/management/commands/nest_update_staff_badges_test.py b/backend/tests/apps/nest/management/commands/nest_update_staff_badges_test.py new file mode 100644 index 0000000000..bc04cbe71a --- /dev/null +++ b/backend/tests/apps/nest/management/commands/nest_update_staff_badges_test.py @@ -0,0 +1,46 @@ +"""Tests for staff badge command.""" + +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest +from django.core.management import call_command +from django.test import SimpleTestCase + +from apps.nest.management.commands.nest_update_staff_badges import Command + + +class TestStaffBadgeCommand(SimpleTestCase): + def test_has_correct_metadata(self): + assert Command.badge_name == "OWASP Staff" + assert Command.badge_weight == 100 + + @patch("apps.nest.management.commands.nest_update_staff_badges.User") + @patch("apps.nest.management.commands.base_badge_command.UserBadge") + @patch("apps.nest.management.commands.base_badge_command.Badge") + def test_command_runs(self, mock_badge, mock_user_badge, mock_user): + badge = MagicMock() + badge.name = "OWASP Staff" + mock_badge.objects.get_or_create.return_value = (badge, False) + + qs = MagicMock() + qs.exclude.return_value = [] + mock_user.objects.filter.return_value = qs + mock_user_badge.objects.filter.return_value.exclude.return_value.count.return_value = 0 + + out = StringIO() + call_command("nest_update_staff_badges", stdout=out) + assert "OWASP Staff" in out.getvalue() + + @patch("apps.nest.management.commands.base_badge_command.Badge") + @patch( + "apps.nest.management.commands.nest_update_staff_badges.User.objects.filter", + side_effect=Exception("error"), + ) + def test_handles_errors(self, mock_filter, mock_badge): + badge = MagicMock() + badge.name = "OWASP Staff" + mock_badge.objects.get_or_create.return_value = (badge, False) + + with pytest.raises(Exception, match="error"): + call_command("nest_update_staff_badges", stdout=StringIO()) diff --git a/backend/tests/apps/owasp/management/commands/owasp_generate_community_snapshot_video_test.py b/backend/tests/apps/owasp/management/commands/owasp_generate_community_snapshot_video_test.py new file mode 100644 index 0000000000..d2617bed7a --- /dev/null +++ b/backend/tests/apps/owasp/management/commands/owasp_generate_community_snapshot_video_test.py @@ -0,0 +1,183 @@ +"""Tests for the owasp_generate_community_snapshot_video Django management command.""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch + +import pytest +from django.core.management.base import BaseCommand + +sys.modules["weasyprint"] = MagicMock() + +from apps.owasp.management.commands.owasp_generate_community_snapshot_video import ( # noqa: E402 + Command, +) + + +@pytest.fixture +def command(): + """Return a command instance.""" + cmd = Command() + cmd.stdout = MagicMock() + return cmd + + +class TestOwaspGenerateCommunitySnapshotVideoCommand: + """Test suite for the owasp_generate_community_snapshot_video command.""" + + def test_command_help_text(self, command): + """Test that the command has the correct help text.""" + assert command.help == "Generate OWASP community snapshot video." + + def test_command_inheritance(self, command): + """Test that the command inherits from BaseCommand.""" + assert isinstance(command, BaseCommand) + + def test_add_arguments(self, command): + """Test that add_arguments configures the parser correctly.""" + parser = MagicMock() + command.add_arguments(parser) + + assert parser.add_argument.call_count == 2 + + +@patch("pathlib.Path.mkdir") +@patch("apps.owasp.management.commands.owasp_generate_community_snapshot_video.VideoGenerator") +@patch("apps.owasp.management.commands.owasp_generate_community_snapshot_video.SlideBuilder") +@patch("apps.owasp.management.commands.owasp_generate_community_snapshot_video.Snapshot") +class TestHandleMethod: + """Test suite for the handle method of the command.""" + + @pytest.fixture + def command(self): + """Return a command instance with mocked stdout.""" + cmd = Command() + cmd.stdout = MagicMock() + return cmd + + def test_handle_no_snapshots_found( + self, + mock_snapshot, + mock_slide_builder, + mock_generator, + mock_mkdir, + command, + ): + """Test handle when no completed snapshots are found.""" + mock_snapshot.objects.filter.return_value.order_by.return_value = [] + + with patch( + "apps.owasp.management.commands.owasp_generate_community_snapshot_video.logger" + ) as mock_logger: + command.handle(snapshot_key="2025-01", output_dir="/output") + + mock_logger.error.assert_called_once() + mock_slide_builder.assert_not_called() + mock_generator.assert_not_called() + + def test_handle_with_valid_snapshots( + self, + mock_snapshot, + mock_slide_builder, + mock_generator, + mock_mkdir, + command, + ): + """Test handle with valid snapshots.""" + mock_snapshot_obj = Mock() + mock_snapshot.objects.filter.return_value.order_by.return_value = [mock_snapshot_obj] + mock_snapshot.Status.COMPLETED = "completed" + + mock_builder_instance = MagicMock() + mock_slide_builder.return_value = mock_builder_instance + + mock_intro_slide = Mock() + mock_sponsors_slide = Mock() + mock_projects_slide = None + mock_chapters_slide = Mock() + mock_releases_slide = None + mock_thank_you_slide = Mock() + + mock_builder_instance.add_intro_slide.return_value = mock_intro_slide + mock_builder_instance.add_sponsors_slide.return_value = mock_sponsors_slide + mock_builder_instance.add_projects_slide.return_value = mock_projects_slide + mock_builder_instance.add_chapters_slide.return_value = mock_chapters_slide + mock_builder_instance.add_releases_slide.return_value = mock_releases_slide + mock_builder_instance.add_thank_you_slide.return_value = mock_thank_you_slide + + mock_generator_instance = MagicMock() + mock_generator.return_value = mock_generator_instance + + command.handle(snapshot_key="2025-01", output_dir="/output") + + mock_slide_builder.assert_called_once() + mock_generator.assert_called_once() + assert mock_generator_instance.append_slide.call_count == 4 + from pathlib import Path + + mock_generator_instance.generate_video.assert_called_once() + args = mock_generator_instance.generate_video.call_args + assert isinstance(args[0][0], Path) + assert args[0][1] == "2025-01_snapshot" + mock_generator_instance.cleanup.assert_called_once() + + def test_handle_filters_none_slides( + self, + mock_snapshot, + mock_slide_builder, + mock_generator, + mock_mkdir, + command, + ): + """Test that handle filters out None slides.""" + mock_snapshot_obj = Mock() + mock_snapshot.objects.filter.return_value.order_by.return_value = [mock_snapshot_obj] + mock_snapshot.Status.COMPLETED = "completed" + + mock_builder_instance = MagicMock() + mock_slide_builder.return_value = mock_builder_instance + + mock_builder_instance.add_intro_slide.return_value = Mock() + mock_builder_instance.add_sponsors_slide.return_value = None + mock_builder_instance.add_projects_slide.return_value = None + mock_builder_instance.add_chapters_slide.return_value = None + mock_builder_instance.add_releases_slide.return_value = None + mock_builder_instance.add_thank_you_slide.return_value = Mock() + + mock_generator_instance = MagicMock() + mock_generator.return_value = mock_generator_instance + + command.handle(snapshot_key="2025-01", output_dir="/output") + + assert mock_generator_instance.append_slide.call_count == 2 + + def test_handle_prints_success_message( + self, + mock_snapshot, + mock_slide_builder, + mock_generator, + mock_mkdir, + command, + ): + """Test that handle prints success message with video path.""" + mock_snapshot_obj = Mock() + mock_snapshot.objects.filter.return_value.order_by.return_value = [mock_snapshot_obj] + mock_snapshot.Status.COMPLETED = "completed" + + mock_builder_instance = MagicMock() + mock_slide_builder.return_value = mock_builder_instance + mock_builder_instance.add_intro_slide.return_value = Mock() + mock_builder_instance.add_sponsors_slide.return_value = Mock() + mock_builder_instance.add_projects_slide.return_value = Mock() + mock_builder_instance.add_chapters_slide.return_value = Mock() + mock_builder_instance.add_releases_slide.return_value = Mock() + mock_builder_instance.add_thank_you_slide.return_value = Mock() + + mock_generator_instance = MagicMock() + mock_generator_instance.generate_video.return_value = Path("/path/to/video.mkv") + mock_generator.return_value = mock_generator_instance + + command.handle(snapshot_key="2025", output_dir="/output") + + stdout_calls = [str(call) for call in command.stdout.write.call_args_list] + assert any("community_snapshot_video_2025" in call for call in stdout_calls) diff --git a/backend/tests/apps/owasp/models/event_test.py b/backend/tests/apps/owasp/models/event_test.py index d0257f21c1..8828c74aa6 100644 --- a/backend/tests/apps/owasp/models/event_test.py +++ b/backend/tests/apps/owasp/models/event_test.py @@ -31,72 +31,48 @@ def test_bulk_save(self): @pytest.mark.parametrize( ("dates", "start_date", "expected_result"), [ - # Test case: None or empty dates (None, date(2025, 5, 26), None), ("", date(2025, 5, 26), None), - # Test case: Single-day events - ("June 5, 2025", date(2025, 6, 5), date(2025, 6, 5)), - ("June 19, 2025", date(2025, 6, 19), date(2025, 6, 19)), - # Test case: Date ranges with proper format ("May 26-30, 2025", date(2025, 5, 26), date(2025, 5, 30)), - ("November 3-7, 2025", date(2025, 11, 3), date(2025, 11, 7)), - ("November 2-6, 2026", date(2026, 11, 2), date(2026, 11, 6)), - ("September 2-5, 2025", date(2025, 9, 2), date(2025, 9, 5)), - ("September 12-13, 2025", date(2025, 9, 12), date(2025, 9, 13)), - ("October 21-24, 2025", date(2025, 10, 21), date(2025, 10, 24)), - ("November 19-20, 2025", date(2025, 11, 19), date(2025, 11, 20)), - ("December 2-3, 2025", date(2025, 12, 2), date(2025, 12, 3)), - # Test case: Special format with spaces in date ranges - ( - "May 25 - 28, 2025", - date(2025, 5, 25), - date(2025, 5, 28), - ), - # Test edge cases - ("Invalid date", None, None), # Invalid date format - ("May 26-invalid, 2025", date(2025, 5, 26), None), # Invalid day in range - ("May, 2025", None, None), # Missing day + ("Invalid date range", date(2025, 5, 26), None), ], ) - def test_parse_dates(self, dates, start_date, expected_result): + def test_parse_dates_unit(self, dates, start_date, expected_result): + """Unit test for parse_dates structure using mocks.""" + if not dates: + assert Event.parse_dates(dates, start_date) is None + return + with patch("apps.owasp.models.event.parser.parse") as mock_parse: - if expected_result is not None: + if expected_result: mock_date = Mock() mock_date.date.return_value = expected_result mock_parse.return_value = mock_date - elif dates and ("Invalid" in dates or dates in {"May, 2025"}): - mock_parse.side_effect = ValueError("Invalid date") + result = Event.parse_dates(dates, start_date) + assert result == expected_result else: - mock_date = Mock() - mock_date.date.return_value = date(2025, 1, 1) - mock_parse.return_value = mock_date - - result = Event.parse_dates(dates, start_date) - assert result == expected_result + mock_parse.side_effect = ValueError("Invalid") + result = Event.parse_dates(dates, start_date) + assert result is None @pytest.mark.parametrize( ("dates", "start_date", "expected_result"), [ + ("2025-05-26", date(2025, 5, 26), date(2025, 5, 26)), + ("Dec 30, 2025 - Jan 2, 2026", date(2025, 12, 30), date(2026, 1, 2)), ("June 5, 2025", date(2025, 6, 5), date(2025, 6, 5)), ("May 26-30, 2025", date(2025, 5, 26), date(2025, 5, 30)), - ("November 3-7, 2025", date(2025, 11, 3), date(2025, 11, 7)), - ("September 2-5, 2025", date(2025, 9, 2), date(2025, 9, 5)), - ("October 21-24, 2025", date(2025, 10, 21), date(2025, 10, 24)), + ("May 26-30", date(2025, 5, 26), date(2025, 5, 30)), + ("May 26–30, 2025", date(2025, 5, 26), date(2025, 5, 30)), # noqa: RUF001 + ("May 26—30, 2025", date(2025, 5, 26), date(2025, 5, 30)), + ("May 30 - June 2, 2025", date(2025, 5, 30), date(2025, 6, 2)), ], ) def test_parse_dates_integration(self, dates, start_date, expected_result): - """Test parse_dates with real parser (no mocking).""" + """Test parse_dates with real parser to verify crossover and dash logic.""" result = Event.parse_dates(dates, start_date) assert result == expected_result - def test_parse_dates_with_space_in_range(self): - """Test the edge case with spaces in date range that's currently handled.""" - dates = "May 25 - 28, 2025" - start_date = date(2025, 5, 25) - - result = Event.parse_dates(dates, start_date) - assert result == date(2025, 5, 28) - def test_update_data_existing_event(self): """Test update_data when the event already exists.""" category = "Global" @@ -115,22 +91,14 @@ def test_update_data_existing_event(self): patch("apps.owasp.models.event.parser.parse") as mock_parser, ): mock_slugify.return_value = "test-event" - mock_parse_dates.return_value = date(2025, 5, 30) - mock_date = Mock() mock_date.date.return_value = date(2025, 5, 26) mock_parser.return_value = mock_date - mock_event = Mock() mock_get.return_value = mock_event result = Event.update_data(category, data) - - mock_slugify.assert_called_once_with(data["name"]) - mock_get.assert_called_once_with(key="test-event") - mock_event.from_dict.assert_called_once_with(category, data) - mock_event.save.assert_called_once() assert result == mock_event def test_from_dict(self): @@ -157,13 +125,8 @@ def test_from_dict(self): mock_normalize_url.return_value = "https://example.com/event/global-event-2025" event.from_dict(category, data) - assert event.category == Event.Category.GLOBAL - assert event.name == "Example Global Event 2025" - assert event.start_date == date(2025, 5, 26) assert event.end_date == date(2025, 5, 30) - assert event.description == "This is a test description" - assert event.url == "https://example.com/event/global-event-2025" @pytest.mark.parametrize( ("category_str", "expected_category"), @@ -178,11 +141,10 @@ def test_category_mapping(self, category_str, expected_category): """Test that category strings are correctly mapped to enum values.""" event = Event(key="test-event") data = { + "dates": "", "name": "Example Event", "start-date": date(2025, 1, 1), - "dates": "", } - with ( patch("apps.owasp.models.event.Event.parse_dates") as mock_parse_dates, patch("apps.owasp.models.event.normalize_url") as mock_normalize_url, diff --git a/backend/tests/apps/owasp/video_test.py b/backend/tests/apps/owasp/video_test.py new file mode 100644 index 0000000000..c1c4b35caf --- /dev/null +++ b/backend/tests/apps/owasp/video_test.py @@ -0,0 +1,394 @@ +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from apps.owasp.video import ( + AUDIO_EXTENSION, + IMAGE_EXTENSION, + VIDEO_EXTENSION, + Slide, + SlideBuilder, + VideoGenerator, +) + + +class TestSlide: + @pytest.fixture + def slide(self, tmp_path): + """Fixture to provide a sample slide.""" + return Slide( + context={"title": "Test Slide"}, + name="test_slide", + output_dir=tmp_path, + template_name="slides/intro.jinja", + transcript="Test transcript", + ) + + def test_audio_path(self, slide, tmp_path): + """Test audio_path property returns correct path.""" + expected = tmp_path / f"test_slide.{AUDIO_EXTENSION}" + assert slide.audio_path == expected + + def test_image_path(self, slide, tmp_path): + """Test image_path property returns correct path.""" + expected = tmp_path / f"test_slide.{IMAGE_EXTENSION}" + assert slide.image_path == expected + + def test_video_path(self, slide, tmp_path): + """Test video_path property returns correct path.""" + expected = tmp_path / f"test_slide.{VIDEO_EXTENSION}" + assert slide.video_path == expected + + @patch("apps.owasp.video.pdfium.PdfDocument") + @patch("apps.owasp.video.HTML") + @patch("apps.owasp.video.video_env") + def test_render_and_save_image_success( + self, mock_video_env, mock_html, mock_pdfium, slide, tmp_path + ): + """Test render_and_save_image creates image file.""" + mock_template = Mock() + mock_template.render.return_value = "" + mock_video_env.get_template.return_value = mock_template + mock_html.return_value.write_pdf.return_value = b"pdf_content" + + mock_bitmap = Mock() + mock_pil_image = Mock() + mock_bitmap.to_pil.return_value = mock_pil_image + + mock_page = Mock() + mock_page.render.return_value = mock_bitmap + + mock_pdf = Mock() + mock_pdf.__getitem__ = Mock(return_value=mock_page) + mock_pdfium.return_value = mock_pdf + + slide.render_and_save_image() + + mock_video_env.get_template.assert_called_once_with(slide.template_name) + mock_template.render.assert_called_once_with(slide.context) + mock_pil_image.save.assert_called_once() + mock_page.close.assert_called_once() + mock_pdf.close.assert_called_once() + + def test_generate_and_save_audio_generation_fails(self, slide): + """Test generate_and_save_audio raises error when generation fails.""" + eleven_labs = Mock() + eleven_labs.generate.side_effect = Exception("API Error") + + with pytest.raises(Exception, match="API Error"): + slide.generate_and_save_audio(eleven_labs) + + def test_generate_and_save_audio_success(self, slide): + """Test generate_and_save_audio succeeds.""" + eleven_labs = Mock() + eleven_labs.generate.return_value = b"audio_data" + + slide.generate_and_save_audio(eleven_labs) + + eleven_labs.set_text.assert_called_once_with(slide.transcript) + eleven_labs.generate.assert_called_once() + eleven_labs.save.assert_called_once_with(b"audio_data", slide.audio_path) + + @patch("apps.owasp.video.ffmpeg") + def test_generate_and_save_video_success(self, mock_ffmpeg, slide): + """Test generate_and_save_video succeeds.""" + mock_input = Mock() + mock_ffmpeg.input.return_value = mock_input + mock_output = Mock() + mock_ffmpeg.output.return_value = mock_output + + slide.generate_and_save_video() + + assert mock_ffmpeg.input.call_count == 2 + mock_ffmpeg.output.assert_called_once() + + @patch("apps.owasp.video.logger") + @patch("apps.owasp.video.ffmpeg") + def test_generate_and_save_video_exception(self, mock_ffmpeg, mock_logger, slide): + """Test generate_and_save_video logs exception on error.""" + import ffmpeg as real_ffmpeg + + mock_ffmpeg.input.return_value = Mock() + mock_ffmpeg.output.return_value.run.side_effect = real_ffmpeg.Error( + "ffmpeg", "stdout", "stderr" + ) + mock_ffmpeg.Error = real_ffmpeg.Error + + with pytest.raises(real_ffmpeg.Error): + slide.generate_and_save_video() + + mock_logger.exception.assert_called_once() + + +class TestSlideBuilder: + @pytest.fixture + def mock_snapshots(self): + """Fixture to provide mock snapshots queryset.""" + mock_qs = MagicMock() + mock_snapshot = Mock() + mock_snapshot.start_at.strftime.return_value = "January" + mock_snapshot.end_at.strftime.return_value = "Jan 31, 2024" + mock_qs.first.return_value = mock_snapshot + mock_qs.last.return_value = mock_snapshot + return mock_qs + + @pytest.fixture + def slide_builder(self, mock_snapshots, tmp_path): + """Fixture to provide a SlideBuilder instance.""" + return SlideBuilder(mock_snapshots, output_dir=tmp_path) + + def test_init(self, slide_builder, mock_snapshots): + """Test SlideBuilder initialization.""" + assert slide_builder.snapshots == mock_snapshots + + @pytest.mark.parametrize( + ("names", "expected"), + [ + (["OWASP Project A"], "Project A"), + (["OWASP A", "OWASP B"], "A, and B"), + (["OWASP A", "OWASP B", "OWASP C"], "A, B, and C"), + (["OWASP A", "OWASP B", "OWASP C", "OWASP D"], "A, B, and C"), + ([], ""), + ], + ) + def test_format_names_for_transcript(self, names, expected): + """Test format_names_for_transcript formats names correctly.""" + result = SlideBuilder.format_names_for_transcript(names) + assert result == expected + + def test_add_intro_slide(self, slide_builder): + """Test add_intro_slide returns a Slide.""" + slide = slide_builder.add_intro_slide() + + assert isinstance(slide, Slide) + assert slide.name == "intro" + assert slide.template_name == "slides/intro.jinja" + assert slide.transcript is not None + + def test_add_intro_slide_multi_snapshot(self, tmp_path): + """Test add_intro_slide with different first and last snapshots.""" + mock_qs = MagicMock() + first_snapshot = Mock() + first_snapshot.start_at.strftime.side_effect = ( + lambda fmt: "2024" if fmt == "%Y" else "Jan 01" + ) + last_snapshot = Mock() + last_snapshot.end_at.strftime.return_value = "Dec 31, 2024" + mock_qs.first.return_value = first_snapshot + mock_qs.last.return_value = last_snapshot + + slide_builder = SlideBuilder(mock_qs, output_dir=tmp_path) + slide = slide_builder.add_intro_slide() + + assert isinstance(slide, Slide) + assert slide.transcript + assert "2024" in slide.transcript + + @patch("apps.owasp.video.Sponsor.objects") + def test_add_sponsors_slide(self, mock_sponsor_objects, slide_builder): + """Test add_sponsors_slide returns a Slide.""" + mock_sponsor_objects.all.return_value = [] + + slide = slide_builder.add_sponsors_slide() + + assert isinstance(slide, Slide) + assert slide.name == "sponsors" + + @patch("apps.owasp.video.Project.objects") + def test_add_projects_slide_no_projects(self, mock_project_objects, slide_builder): + """Test add_projects_slide returns None when no projects.""" + mock_qs = MagicMock() + mock_qs.exists.return_value = False + mock_project_objects.filter.return_value.distinct.return_value.order_by.return_value = ( + mock_qs + ) + + result = slide_builder.add_projects_slide() + + assert result is None + + @patch("apps.owasp.video.Project.objects") + def test_add_projects_slide_with_projects(self, mock_project_objects, slide_builder): + """Test add_projects_slide returns Slide when projects exist.""" + mock_project = Mock() + mock_project.name = "Test Project" + mock_project.stars_count = 100 + mock_project.forks_count = 50 + mock_project.contributors_count = 10 + mock_project.leaders_raw = ["Leader 1"] + + mock_qs = MagicMock() + mock_qs.exists.return_value = True + mock_qs.count.return_value = 1 + mock_qs.__iter__ = Mock(return_value=iter([mock_project])) + mock_project_objects.filter.return_value.distinct.return_value.order_by.return_value = ( + mock_qs + ) + + result = slide_builder.add_projects_slide() + + assert isinstance(result, Slide) + assert result.name == "projects" + + @patch("apps.owasp.video.Chapter.objects") + def test_add_chapters_slide_no_chapters(self, mock_chapter_objects, slide_builder): + """Test add_chapters_slide returns None when no chapters.""" + mock_qs = MagicMock() + mock_qs.exists.return_value = False + mock_chapter_objects.filter.return_value.distinct.return_value.order_by.return_value = ( + mock_qs + ) + + result = slide_builder.add_chapters_slide() + + assert result is None + + @patch("apps.owasp.video.Chapter.objects") + def test_add_chapters_slide_with_chapters(self, mock_chapter_objects, slide_builder): + """Test add_chapters_slide returns Slide when chapters exist.""" + mock_chapter = Mock() + mock_chapter.name = "Test Chapter" + mock_chapter.suggested_location = "California, USA" + + mock_qs = MagicMock() + mock_qs.exists.return_value = True + mock_qs.count.return_value = 1 + mock_qs.__iter__ = Mock(return_value=iter([mock_chapter])) + mock_chapter_objects.filter.return_value.distinct.return_value.order_by.return_value = ( + mock_qs + ) + + result = slide_builder.add_chapters_slide() + + assert isinstance(result, Slide) + assert result.name == "chapters" + + @patch("apps.owasp.video.Project.objects") + @patch("apps.owasp.video.Release.objects") + def test_add_releases_slide_no_releases( + self, mock_release_objects, mock_project_objects, slide_builder + ): + """Test add_releases_slide returns None when no releases.""" + mock_qs = MagicMock() + mock_qs.exists.return_value = False + mock_release_objects.filter.return_value.distinct.return_value = mock_qs + + result = slide_builder.add_releases_slide() + + assert result is None + + @patch("apps.owasp.video.Project.objects") + @patch("apps.owasp.video.Release.objects") + def test_add_releases_slide_with_releases( + self, mock_release_objects, mock_project_objects, slide_builder + ): + """Test add_releases_slide returns Slide when releases exist.""" + mock_release = Mock() + mock_repository = Mock() + mock_release.repository = mock_repository + + mock_project = Mock() + mock_project.name = "Test Project" + + mock_release_qs = MagicMock() + mock_release_qs.exists.return_value = True + mock_release_qs.count.return_value = 5 + mock_release_qs.__iter__ = Mock(return_value=iter([mock_release])) + mock_release_objects.filter.return_value.distinct.return_value = mock_release_qs + + mock_project_qs = MagicMock() + mock_project_qs.first.return_value = mock_project + mock_project_objects.filter.return_value = mock_project_qs + + result = slide_builder.add_releases_slide() + + assert isinstance(result, Slide) + assert result.name == "releases" + + def test_add_thank_you_slide(self, slide_builder): + """Test add_thank_you_slide returns a Slide.""" + slide = slide_builder.add_thank_you_slide() + + assert isinstance(slide, Slide) + assert slide.name == "thank_you" + assert slide.template_name == "slides/thank_you.jinja" + + +class TestVideoGenerator: + @pytest.fixture + def generator(self): + """Fixture to provide a VideoGenerator instance.""" + with patch("apps.owasp.video.ElevenLabs"): + return VideoGenerator() + + def test_init(self, generator): + """Test VideoGenerator initialization.""" + assert generator.slides == [] + + def test_append_slide(self, generator): + """Test append_slide adds slide to list.""" + slide = Mock() + generator.append_slide(slide) + + assert len(generator.slides) == 1 + assert generator.slides[0] == slide + + def test_generate_video(self, generator, tmp_path): + """Test generate_video calls methods on all slides.""" + slide1 = Mock() + slide2 = Mock() + generator.slides = [slide1, slide2] + + with patch.object(generator, "merge_videos"): + generator.generate_video(tmp_path, "output") + + slide1.render_and_save_image.assert_called_once() + slide1.generate_and_save_audio.assert_called_once() + slide1.generate_and_save_video.assert_called_once() + slide2.render_and_save_image.assert_called_once() + + @patch("apps.owasp.video.ffmpeg") + def test_merge_videos(self, mock_ffmpeg, generator, tmp_path): + """Test merge_videos combines slide videos.""" + slide1 = Mock() + slide1.video_path = tmp_path / "slide1.mp4" + slide2 = Mock() + slide2.video_path = tmp_path / "slide2.mp4" + generator.slides = [slide1, slide2] + + mock_input = Mock() + mock_input.video = Mock() + mock_input.audio = Mock() + mock_ffmpeg.input.return_value = mock_input + mock_ffmpeg.concat.return_value = Mock() + mock_ffmpeg.output.return_value.overwrite_output.return_value.run = Mock() + + generator.merge_videos(tmp_path / "output.mp4") + + assert mock_ffmpeg.input.call_count == 2 + assert mock_ffmpeg.concat.call_count == 2 + + @patch("apps.owasp.video.logger") + @patch("apps.owasp.video.ffmpeg") + def test_merge_videos_exception(self, mock_ffmpeg, mock_logger, generator, tmp_path): + """Test merge_videos logs exception on error.""" + import ffmpeg as real_ffmpeg + + slide = Mock() + slide.video_path = tmp_path / "slide.mp4" + generator.slides = [slide] + + mock_input = Mock() + mock_input.video = Mock() + mock_input.audio = Mock() + mock_ffmpeg.input.return_value = mock_input + mock_ffmpeg.concat.return_value = Mock() + mock_ffmpeg.output.return_value.overwrite_output.return_value.run.side_effect = ( + real_ffmpeg.Error("ffmpeg", "stdout", "stderr") + ) + mock_ffmpeg.Error = real_ffmpeg.Error + + with pytest.raises(real_ffmpeg.Error): + generator.merge_videos(tmp_path / "output.mp4") + + mock_logger.exception.assert_called_once() diff --git a/cspell/Makefile b/cspell/Makefile index fdb4576913..f1d65864bd 100644 --- a/cspell/Makefile +++ b/cspell/Makefile @@ -1,10 +1,11 @@ check-spelling: cspell-check cspell-install: - @DOCKER_BUILDKIT=1 docker build \ - --cache-from cspell \ - cspell \ - -t cspell + # Check if cspell:ci image exists (from CI build step), tag it to 'cspell' if found, + # otherwise build the image from scratch for local development. + @docker image inspect cspell:ci >/dev/null 2>&1 && \ + docker tag cspell:ci cspell || \ + docker build -t cspell cspell cspell-check: CMD="--no-progress -r /nest" cspell-check: cspell-install cspell-run diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 6d4aab152b..5c39d84ac4 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -9,6 +9,7 @@ CISSP Cañón DRF ELB +ELEVENLABS GBP GFKs GSOC @@ -37,6 +38,7 @@ Whistleblower Wörld Xmark a2eeef +acodec agentic ahmedxgouda algoliasearch @@ -63,6 +65,7 @@ dismissable dkr dockerhub dsn +elevenlabs env facebookexternalhit gamesec @@ -86,6 +89,11 @@ kasya langgraph lhci libexpat +libgobject +libharfbuzz +libpango +libpangoft +libx lighthouseci linkify lte @@ -101,6 +109,7 @@ menteemodule_set mentees mern millify +mkv mpim navlink nestbot @@ -112,9 +121,11 @@ openstreetmap owasppcitoolkit owtf pdfgen +pdfium pentest pentesting pgvector +pil pnpmrc psycopg pygithub @@ -144,6 +155,7 @@ tsc unassigning unhover usefixtures +vcodec webgoat winsrdf wsgi @@ -152,6 +164,7 @@ xdg xdist xoxb xsser +zapconfig zaproxy zsc éàëîôû diff --git a/cspell/pnpm-lock.yaml b/cspell/pnpm-lock.yaml index ac4a6294aa..663a888554 100644 --- a/cspell/pnpm-lock.yaml +++ b/cspell/pnpm-lock.yaml @@ -196,8 +196,8 @@ packages: '@cspell/dict-node@5.0.8': resolution: {integrity: sha512-AirZcN2i84ynev3p2/1NCPEhnNsHKMz9zciTngGoqpdItUb2bDt1nJBjwlsrFI78GZRph/VaqTVFwYikmncpXg==} - '@cspell/dict-npm@5.2.28': - resolution: {integrity: sha512-tjnBjpIJsgYMTqNSrL5YlvFcXdtc7gkrL1ZI+MPSJSYOoJ78yeegS5UrIIbH3VrQtbNYSS8YhlEVF+xN0G4E8Q==} + '@cspell/dict-npm@5.2.29': + resolution: {integrity: sha512-ZAef8JpYmbuHFT1zekj/YyImLPvZevjECw663EmG5GPePyNo4AfH8Dd2fFhaOyQ3P5I5LrkAhGwypnOfUxcssw==} '@cspell/dict-people-names@1.1.16': resolution: {integrity: sha512-jiV+V32DVdaMqpznnqqNNMNaKFtyaHnZvak7HrVLWulGgobilQk+8NzFO9mtkyDs7Pde7CEGSExBAvc+xZxgeA==} @@ -220,8 +220,8 @@ packages: '@cspell/dict-ruby@5.1.0': resolution: {integrity: sha512-9PJQB3cfkBULrMLp5kSAcFPpzf8oz9vFN+QYZABhQwWkGbuzCIXSorHrmWSASlx4yejt3brjaWS57zZ/YL5ZQQ==} - '@cspell/dict-rust@4.1.0': - resolution: {integrity: sha512-ysFxxKc3QjPWtPacbwxzz8sDOACHNShlhQpnBsDXAHN3LogmuBsQtfyuU30APqFjCOg9KwGciKYC/hcGxJCbiA==} + '@cspell/dict-rust@4.1.1': + resolution: {integrity: sha512-fXiXnZH0wOaEVTKFRNaz6TsUGhuB8dAT0ubYkDNzRQCaV5JGSOebGb1v2x5ZrOSVp+moxWM/vdBfiNU6KOEaFQ==} '@cspell/dict-scala@5.0.9': resolution: {integrity: sha512-AjVcVAELgllybr1zk93CJ5wSUNu/Zb5kIubymR/GAYkMyBdYFCZ3Zbwn4Zz8GJlFFAbazABGOu0JPVbeY59vGg==} @@ -232,8 +232,8 @@ packages: '@cspell/dict-software-terms@4.2.5': resolution: {integrity: sha512-CaRzkWti3AgcXoxuRcMijaNG7YUk/MH1rHjB8VX34v3UdCxXXeqvRyElRKnxhFeVLB/robb2UdShqh/CpskxRg==} - '@cspell/dict-software-terms@5.1.19': - resolution: {integrity: sha512-3leJLYvibbOnPsIUV/60WcSPxzRmgrx6/0QkqRi8cSsEuRY5/cbUU8Jc0/hKYCIhWJlnIWh5yx34Ep2s8QSIBw==} + '@cspell/dict-software-terms@5.1.20': + resolution: {integrity: sha512-TEk1xHvetTI4pv7Vzje1D322m6QEjaH2P6ucOOf6q7EJCppQIdC0lZSXkgHJAFU5HGSvEXSzvnVeW2RHW86ziQ==} '@cspell/dict-sql@2.2.1': resolution: {integrity: sha512-qDHF8MpAYCf4pWU8NKbnVGzkoxMNrFqBHyG/dgrlic5EQiKANCLELYtGlX5auIMDLmTf1inA0eNtv74tyRJ/vg==} @@ -491,17 +491,17 @@ snapshots: '@cspell/dict-markdown': 2.0.14(@cspell/dict-css@4.0.19)(@cspell/dict-html-symbol-entities@4.0.5)(@cspell/dict-html@4.0.14)(@cspell/dict-typescript@3.2.3) '@cspell/dict-monkeyc': 1.0.12 '@cspell/dict-node': 5.0.8 - '@cspell/dict-npm': 5.2.28 + '@cspell/dict-npm': 5.2.29 '@cspell/dict-php': 4.1.1 '@cspell/dict-powershell': 5.0.15 '@cspell/dict-public-licenses': 2.0.15 '@cspell/dict-python': 4.2.25 '@cspell/dict-r': 2.1.1 '@cspell/dict-ruby': 5.1.0 - '@cspell/dict-rust': 4.1.0 + '@cspell/dict-rust': 4.1.1 '@cspell/dict-scala': 5.0.9 '@cspell/dict-shell': 1.1.2 - '@cspell/dict-software-terms': 5.1.19 + '@cspell/dict-software-terms': 5.1.20 '@cspell/dict-sql': 2.2.1 '@cspell/dict-svelte': 1.0.7 '@cspell/dict-swift': 2.0.6 @@ -612,7 +612,7 @@ snapshots: '@cspell/dict-node@5.0.8': {} - '@cspell/dict-npm@5.2.28': {} + '@cspell/dict-npm@5.2.29': {} '@cspell/dict-people-names@1.1.16': {} @@ -630,7 +630,7 @@ snapshots: '@cspell/dict-ruby@5.1.0': {} - '@cspell/dict-rust@4.1.0': {} + '@cspell/dict-rust@4.1.1': {} '@cspell/dict-scala@5.0.9': {} @@ -638,7 +638,7 @@ snapshots: '@cspell/dict-software-terms@4.2.5': {} - '@cspell/dict-software-terms@5.1.19': {} + '@cspell/dict-software-terms@5.1.20': {} '@cspell/dict-sql@2.2.1': {} diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index 95493539b6..4bde6c0821 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -33,7 +33,7 @@ USER owasp COPY --chmod=444 --chown=root:root poetry.lock pyproject.toml ./ RUN --mount=type=cache,target=${POETRY_CACHE_DIR},uid=${OWASP_UID},gid=${OWASP_GID} \ - poetry install --no-root --without dev --without test + poetry install --no-root --without dev --without test --without video COPY apps apps COPY Makefile entrypoint.sh manage.py wsgi.py ./ diff --git a/docker/backend/Dockerfile.local b/docker/backend/Dockerfile.local index f1074808bb..ec108faaab 100644 --- a/docker/backend/Dockerfile.local +++ b/docker/backend/Dockerfile.local @@ -28,7 +28,7 @@ WORKDIR /home/owasp COPY --chmod=444 --chown=root:root poetry.lock pyproject.toml ./ RUN --mount=type=cache,target=${POETRY_CACHE_DIR},uid=${OWASP_UID},gid=${OWASP_GID} \ - poetry install --no-root --without dev --without test + poetry install --no-root --without dev --without test --without video FROM python:3.13.7-alpine diff --git a/docker/backend/Dockerfile.video b/docker/backend/Dockerfile.video new file mode 100644 index 0000000000..489e288ba0 --- /dev/null +++ b/docker/backend/Dockerfile.video @@ -0,0 +1,35 @@ +FROM nest-backend:base + +USER root + +ENV APK_CACHE_DIR="/home/owasp/.cache/apk-backend-video" \ + APK_SYMLINK_DIR="/etc/apk/cache" \ + OWASP_GID=1000 \ + OWASP_UID=1000 \ + PIP_CACHE_DIR="/home/owasp/.cache/pip" \ + POETRY_CACHE_DIR="/home/owasp/.cache/pypoetry" \ + POETRY_VIRTUALENVS_IN_PROJECT=true \ + PYTHONUNBUFFERED=1 + +RUN mkdir -p ${APK_CACHE_DIR} ${POETRY_CACHE_DIR} && \ + ln -fns ${APK_CACHE_DIR} ${APK_SYMLINK_DIR} + +RUN --mount=type=cache,target=${PIP_CACHE_DIR} \ + python -m pip install poetry --cache-dir ${PIP_CACHE_DIR} + +COPY --chmod=444 --chown=root:root poetry.lock pyproject.toml ./ +RUN --mount=type=cache,target=${POETRY_CACHE_DIR},uid=${OWASP_UID},gid=${OWASP_GID} \ + poetry install --no-root --without dev --without test && \ + python -m pip uninstall -y poetry + +RUN --mount=type=cache,target=${APK_CACHE_DIR} \ + apk update && apk upgrade && \ + apk add ffmpeg \ + # WeasyPrint dependencies: https://doc.courtbouillon.org/weasyprint/stable/first_steps.html + so:libfontconfig.so.1 so:libgobject-2.0.so.0 so:libharfbuzz-subset.so.0 \ + so:libharfbuzz.so.0 so:libpango-1.0.so.0 so:libpangoft2-1.0.so.0 ttf-freefont && \ + chown -R owasp:owasp /home/owasp + +WORKDIR /home/owasp + +USER owasp diff --git a/docker/frontend/Dockerfile.a11y.test b/docker/frontend/Dockerfile.a11y.test new file mode 100644 index 0000000000..05c19451ce --- /dev/null +++ b/docker/frontend/Dockerfile.a11y.test @@ -0,0 +1,27 @@ +FROM node:24-alpine + +ENV FORCE_COLOR=1 \ + NPM_CACHE="/app/.npm" \ + PNPM_HOME="/pnpm" + +ENV NPM_CONFIG_RETRY=5 \ + NPM_CONFIG_TIMEOUT=30000 \ + PATH="$PNPM_HOME:$PATH" + +RUN --mount=type=cache,target=${NPM_CACHE} \ + npm install --ignore-scripts -g pnpm --cache ${NPM_CACHE} + +WORKDIR /app + +COPY --chmod=444 --chown=root:root package.json pnpm-lock.yaml ./ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --ignore-scripts && \ + chown node:node /app + +COPY __tests__/a11y __tests__/a11y +COPY __tests__/mockData __tests__/mockData +COPY .pnpmrc jest.config.ts jest.setup.ts tsconfig.json ./ +COPY public public +COPY src src + +USER node diff --git a/docker/frontend/Dockerfile.e2e.test b/docker/frontend/Dockerfile.e2e.test index 681ef18691..bd03a0130c 100644 --- a/docker/frontend/Dockerfile.e2e.test +++ b/docker/frontend/Dockerfile.e2e.test @@ -18,7 +18,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile --ignore-scripts COPY __tests__/e2e __tests__/e2e -COPY __tests__/unit/data __tests__/unit/data +COPY __tests__/mockData __tests__/mockData COPY .pnpmrc next.config.ts postcss.config.js playwright.config.ts tailwind.config.mjs tsconfig.json ./ COPY public public COPY src src diff --git a/docker/frontend/Dockerfile.unit.test b/docker/frontend/Dockerfile.unit.test index 7a0e19e2bd..b93d0f6aec 100644 --- a/docker/frontend/Dockerfile.unit.test +++ b/docker/frontend/Dockerfile.unit.test @@ -19,6 +19,7 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ chown node:node /app COPY __tests__/unit __tests__/unit +COPY __tests__/mockData __tests__/mockData COPY .pnpmrc jest.config.ts jest.setup.ts tsconfig.json ./ COPY public public COPY src src diff --git a/docs/poetry.lock b/docs/poetry.lock index 4d97b445fd..62ac200794 100644 --- a/docs/poetry.lock +++ b/docs/poetry.lock @@ -588,16 +588,22 @@ lint = ["black"] [[package]] name = "pathspec" -version = "0.12.1" +version = "1.0.3" description = "Utility library for gitignore style pattern matching of file paths." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, + {file = "pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c"}, + {file = "pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d"}, ] +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] +tests = ["pytest (>=9)", "typing-extensions (>=4.15)"] + [[package]] name = "platformdirs" version = "4.5.1" @@ -798,14 +804,14 @@ files = [ [[package]] name = "urllib3" -version = "2.6.2" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, - {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] diff --git a/frontend/Makefile b/frontend/Makefile index a43ae55be3..c61cd6aed7 100644 --- a/frontend/Makefile +++ b/frontend/Makefile @@ -1,5 +1,12 @@ SHELL := /bin/bash +build-frontend-local-image: + @DOCKER_BUILDKIT=1 NEXT_PUBLIC_ENVIRONMENT=local docker build \ + --no-cache \ + -f docker/frontend/Dockerfile \ + -t nest-frontend-local \ + frontend + check-frontend: \ format-frontend-code \ lint-frontend-code \ @@ -49,13 +56,29 @@ generate-graphql-types: || (printf "pnpm run graphql-codegen"; for i in $$(seq 1 49); do printf "."; done; printf "\033[37;41mFailed\033[0m\n" \ && pnpm run graphql-codegen)) +scan-frontend-image: build-frontend-local-image + @trivy image \ + --config trivy.yaml \ + --docker-host $$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null) \ + --exit-code 1 \ + --severity CRITICAL,HIGH \ + nest-frontend-local + shell-frontend: @CMD="/bin/sh" $(MAKE) exec-frontend-command-it test-frontend: \ test-frontend-unit \ + test-frontend-a11y \ test-frontend-e2e +test-frontend-a11y: + @DOCKER_BUILDKIT=1 NEXT_PUBLIC_ENVIRONMENT=local docker build \ + --cache-from nest-test-frontend-a11y \ + -f docker/frontend/Dockerfile.a11y.test frontend \ + -t nest-test-frontend-a11y + @docker run --env-file frontend/.env.example --rm nest-test-frontend-a11y pnpm run test:a11y + test-frontend-e2e: @DOCKER_BUILDKIT=1 NEXT_PUBLIC_ENVIRONMENT=local docker build \ --cache-from nest-test-frontend-e2e \ diff --git a/frontend/__tests__/a11y/components/ActionButton.a11y.test.tsx b/frontend/__tests__/a11y/components/ActionButton.a11y.test.tsx new file mode 100644 index 0000000000..802871689f --- /dev/null +++ b/frontend/__tests__/a11y/components/ActionButton.a11y.test.tsx @@ -0,0 +1,32 @@ +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import ActionButton from 'components/ActionButton' + +expect.extend(toHaveNoViolations) + +describe('ActionButton Accessibility', () => { + it('should not have any accessibility violations when no url is provided', async () => { + const { container } = render(Sample Text) + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when url is provided', async () => { + const { container } = render(Visit Site) + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when tooltipLabel is provided', async () => { + const { baseElement } = render( + Test Button + ) + const results = await axe(baseElement) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/AnchorTitle.a11y.test.tsx b/frontend/__tests__/a11y/components/AnchorTitle.a11y.test.tsx new file mode 100644 index 0000000000..fa1a1a201e --- /dev/null +++ b/frontend/__tests__/a11y/components/AnchorTitle.a11y.test.tsx @@ -0,0 +1,15 @@ +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import AnchorTitle from 'components/AnchorTitle' + +expect.extend(toHaveNoViolations) + +describe('AnchorTitle Accessibility', () => { + it('should not have any accessibility violations', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/AutoScrollToTop.a11y.test.tsx b/frontend/__tests__/a11y/components/AutoScrollToTop.a11y.test.tsx new file mode 100644 index 0000000000..cb7418b321 --- /dev/null +++ b/frontend/__tests__/a11y/components/AutoScrollToTop.a11y.test.tsx @@ -0,0 +1,27 @@ +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import AutoScrollToTop from 'components/AutoScrollToTop' + +expect.extend(toHaveNoViolations) + +jest.mock('next/navigation', () => ({ + usePathname: () => '/test-path', +})) + +beforeAll(() => { + window.scrollTo = jest.fn() +}) + +afterAll(() => { + jest.clearAllMocks() +}) + +describe('AutoScrollToTop Accessibility', () => { + it('should not have any accessibility violations', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/Badges.a11y.test.tsx b/frontend/__tests__/a11y/components/Badges.a11y.test.tsx new file mode 100644 index 0000000000..fb344b1770 --- /dev/null +++ b/frontend/__tests__/a11y/components/Badges.a11y.test.tsx @@ -0,0 +1,28 @@ +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import Badges from 'components/Badges' + +const defaultProps = { + name: 'Test Badge', + cssClass: 'medal', +} + +expect.extend(toHaveNoViolations) + +describe('Badges Accessibility', () => { + it('should not have any accessibility violations when tooltip is enabled', async () => { + const { baseElement } = render() + + const results = await axe(baseElement) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when tooltip is disabled', async () => { + const { baseElement } = render() + + const results = await axe(baseElement) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/BarChart.a11y.test.tsx b/frontend/__tests__/a11y/components/BarChart.a11y.test.tsx new file mode 100644 index 0000000000..5829397c99 --- /dev/null +++ b/frontend/__tests__/a11y/components/BarChart.a11y.test.tsx @@ -0,0 +1,83 @@ +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import { ReactNode } from 'react' +import BarChart from 'components/BarChart' + +const mockProps = { + title: 'Calories Burned', + labels: ['Mon', 'Tue', 'Wed'], + days: [200, 150, 100], + requirements: [180, 170, 90], +} + +jest.mock('react-apexcharts', () => { + return function MockChart(props: { + options: unknown + series: unknown + height: number + type: string + }) { + const mockOptions = props.options as Record + + return ( +
+ ) + } +}) + +jest.mock('next/dynamic', () => { + return function mockDynamic() { + return jest.requireMock('react-apexcharts') + } +}) + +jest.mock('next-themes', () => ({ + ThemeProvider: ({ children, ...props }: { children: ReactNode; [key: string]: unknown }) => ( +
{children}
+ ), + useTheme: () => ({ theme: 'light', setTheme: jest.fn() }), +})) + +jest.mock('components/AnchorTitle', () => { + return function MockAnchorTitle({ title }: { title: string }) { + return
{title}
+ } +}) + +jest.mock('components/SecondaryCard', () => { + return function MockSecondaryCard({ + title, + icon, + children, + }: { + title: ReactNode + icon?: unknown + children: ReactNode + }) { + return ( +
+
{title}
+ {icon &&
icon
} +
{children}
+
+ ) + } +}) + +expect.extend(toHaveNoViolations) + +describe('BarChart Accessibility', () => { + it('should not have any accessibility violations', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/BreadCrumbs.a11y.test.tsx b/frontend/__tests__/a11y/components/BreadCrumbs.a11y.test.tsx new file mode 100644 index 0000000000..775a35a797 --- /dev/null +++ b/frontend/__tests__/a11y/components/BreadCrumbs.a11y.test.tsx @@ -0,0 +1,15 @@ +import { Breadcrumbs } from '@heroui/react' +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' + +expect.extend(toHaveNoViolations) + +describe('Breadcrumbs a11y', () => { + it('should not have any accessibility violations', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/BreadCrumbsWrapper.a11y.test.tsx b/frontend/__tests__/a11y/components/BreadCrumbsWrapper.a11y.test.tsx new file mode 100644 index 0000000000..03588a6b4d --- /dev/null +++ b/frontend/__tests__/a11y/components/BreadCrumbsWrapper.a11y.test.tsx @@ -0,0 +1,34 @@ +import { render } from '@testing-library/react' +import { useBreadcrumbs } from 'hooks/useBreadcrumbs' +import { axe, toHaveNoViolations } from 'jest-axe' +import { usePathname } from 'next/navigation' +import BreadCrumbsWrapper from 'components/BreadCrumbsWrapper' + +expect.extend(toHaveNoViolations) + +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(), +})) + +jest.mock('hooks/useBreadcrumbs', () => ({ + useBreadcrumbs: jest.fn(), +})) + +describe('BreadcrumbsWrapper a11y', () => { + beforeAll(() => { + ;(usePathname as jest.Mock).mockReturnValue('/projects/test-project') + ;(useBreadcrumbs as jest.Mock).mockReturnValue([ + { title: 'Home', path: '/' }, + { title: 'Projects', path: '/projects' }, + { title: 'Test Project', path: '/projects/test-project' }, + ]) + }) + + it('should not have any accessibility violations', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/CalendarButton.a11y.test.tsx b/frontend/__tests__/a11y/components/CalendarButton.a11y.test.tsx new file mode 100644 index 0000000000..54a7fa84a1 --- /dev/null +++ b/frontend/__tests__/a11y/components/CalendarButton.a11y.test.tsx @@ -0,0 +1,38 @@ +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import { FaCalendarAlt } from 'react-icons/fa' +import CalendarButton from 'components/CalendarButton' + +expect.extend(toHaveNoViolations) + +const mockEvent = { + title: 'Test Event', + description: 'Test description', + location: 'Test Location', + startDate: '2025-12-01', + endDate: '2025-12-02', +} + +describe('CalendarButton Accessibility', () => { + it('should not have any accessibility violations as an icon-only button', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when showLabel is enabled', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when custom icon is provided', async () => { + const { container } = render(} />) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/Card.a11y.test.tsx b/frontend/__tests__/a11y/components/Card.a11y.test.tsx new file mode 100644 index 0000000000..70bfb52418 --- /dev/null +++ b/frontend/__tests__/a11y/components/Card.a11y.test.tsx @@ -0,0 +1,83 @@ +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import { ReactNode } from 'react' +import { FaCrown } from 'react-icons/fa6' +import Card from 'components/Card' + +interface MockLinkProps { + children: ReactNode + href: string + target?: string + rel?: string + className?: string +} + +jest.mock('components/MarkdownWrapper', () => ({ + __esModule: true, + default: ({ children }) =>
{children}
, +})) + +jest.mock('next/link', () => { + return function MockedLink({ children, href, ...props }: MockLinkProps) { + return ( + + {children} + + ) + } +}) + +const baseProps = { + title: 'Test Project', + url: 'https://github.com/test/project', + summary: 'This is a test project summary', + button: { + label: 'View Project', + url: 'https://github.com/test', + icon: github, + onclick: jest.fn(), + }, +} + +expect.extend(toHaveNoViolations) + +describe('Card Accessibility', () => { + it('should not have any accessibility violations with minimal props', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when level is provided', async () => { + const { container } = render( + + ) + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when project name is provided', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when socials is provided', async () => { + const { container } = render( + + ) + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/CardDetailsPage.a11y.test.tsx b/frontend/__tests__/a11y/components/CardDetailsPage.a11y.test.tsx new file mode 100644 index 0000000000..b1e63f91ae --- /dev/null +++ b/frontend/__tests__/a11y/components/CardDetailsPage.a11y.test.tsx @@ -0,0 +1,213 @@ +import { mockChapterData } from '@mockData/mockChapterData' +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import React from 'react' +import { FaCode, FaTags } from 'react-icons/fa6' +import { ExperienceLevelEnum } from 'types/__generated__/graphql' +import { DetailsCardProps } from 'types/card' +import DetailsCard from 'components/CardDetailsPage' + +jest.mock('next/link', () => { + return function MockLink({ + children, + href, + ...props + }: { + children: React.ReactNode + href: string + [key: string]: unknown + }) { + return ( + + {children} + + ) + } +}) + +jest.mock('components/ChapterMapWrapper', () => ({ + __esModule: true, + default: ({ + geoLocData: _geoLocData, + showLocal, + style, + showLocationSharing: _showLocationSharing, + ...otherProps + }: { + geoLocData?: unknown + showLocal: boolean + style: React.CSSProperties + showLocationSharing?: boolean + [key: string]: unknown + }) => { + return ( +
+ Chapter Map {showLocal ? '(Local)' : ''} +
+ ) + }, +})) + +jest.mock('react-apexcharts', () => ({ + __esModule: true, + default: () =>
, +})) + +jest.mock('next/dynamic', () => ({ + __esModule: true, + default: () => { + return function MockDynamicComponent({ + children, + ...props + }: React.ComponentPropsWithoutRef<'div'>) { + return
{children}
+ } + }, +})) + +jest.mock('next-auth/react', () => ({ + useSession: jest.fn(() => ({ data: null, status: 'unauthenticated' })), +})) + +const mockHealthMetricsData = [ + { + ageDays: 365, + ageDaysRequirement: 365, + id: 'test-id', + createdAt: '2023-01-01', + contributorsCount: 10, + forksCount: 5, + isFundingRequirementsCompliant: true, + isLeaderRequirementsCompliant: true, + lastCommitDays: 1, + lastCommitDaysRequirement: 30, + lastPullRequestDays: 2, + lastPullRequestDaysRequirement: 30, + lastReleaseDays: 10, + lastReleaseDaysRequirement: 90, + openIssuesCount: 5, + openPullRequestsCount: 3, + owaspPageLastUpdateDays: 30, + owaspPageLastUpdateDaysRequirement: 90, + projectName: 'Test Project', + projectKey: 'test-project', + recentReleasesCount: 2, + score: 85, + starsCount: 100, + totalIssuesCount: 20, + totalReleasesCount: 5, + unassignedIssuesCount: 2, + unansweredIssuesCount: 1, + }, +] + +const mockStats = [ + { + icon: FaCode, + pluralizedName: 'repositories', + unit: '', + value: 10, + }, + { + icon: FaTags, + pluralizedName: 'stars', + unit: '', + value: 100, + }, +] + +const mockDetails = [ + { label: 'Created', value: '2023-01-01' }, + { label: 'Leaders', value: 'John Doe, Jane Smith' }, + { label: 'Status', value: 'Active' }, +] + +const defaultProps: DetailsCardProps = { + title: 'Test Project', + description: 'A test project for demonstration', + type: 'project', + details: mockDetails, + stats: mockStats, + isActive: true, + showAvatar: true, + languages: ['JavaScript', 'TypeScript'], + topics: ['web', 'frontend'], + repositories: [], + recentIssues: [], + recentMilestones: [], + recentReleases: [], + pullRequests: [], + topContributors: [], + healthMetricsData: mockHealthMetricsData, + socialLinks: [], +} + +expect.extend(toHaveNoViolations) + +describe('CardDetailsPage a11y', () => { + it('should have no accessibility violations', async () => { + const { container } = render() + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + + it('should have no violations for chapter type', async () => { + const { container } = render( + + ) + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + + it('should have no violations for program type', async () => { + const { container } = render( + + ) + const results = await axe(container) + expect(results).toHaveNoViolations() + }) + + it('should have no violations in archived state', async () => { + let container: HTMLElement + + await React.act(async () => { + const renderResult = render( + + ) + container = renderResult.container + }) + + const results = await axe(container) + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/ChapterMap.a11y.test.tsx b/frontend/__tests__/a11y/components/ChapterMap.a11y.test.tsx new file mode 100644 index 0000000000..adce4d0b7b --- /dev/null +++ b/frontend/__tests__/a11y/components/ChapterMap.a11y.test.tsx @@ -0,0 +1,170 @@ +import { mockChapterData } from '@mockData/mockChapterData' +import { screen, fireEvent, render, waitFor } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import * as L from 'leaflet' +import React, { useEffect } from 'react' +import ChapterMap from 'components/ChapterMap' + +expect.extend(toHaveNoViolations) + +const mockMap = { + setView: jest.fn().mockReturnThis(), + fitBounds: jest.fn().mockReturnThis(), + scrollWheelZoom: { + enable: jest.fn(), + disable: jest.fn(), + }, +} + +const mockZoomControl = { + addTo: jest.fn().mockReturnThis(), + remove: jest.fn(), +} + +/* eslint-disable @typescript-eslint/naming-convention */ +jest.mock('leaflet', () => ({ + map: jest.fn(() => mockMap), + tileLayer: jest.fn(() => ({ + addTo: jest.fn().mockReturnThis(), + })), + marker: jest.fn(() => ({ + bindPopup: jest.fn().mockReturnThis(), + })), + popup: jest.fn(() => ({ + setContent: jest.fn().mockReturnThis(), + })), + latLngBounds: jest.fn(() => ({})), + Icon: jest.fn(() => ({})), + divIcon: jest.fn(() => ({})), + control: { + zoom: jest.fn(() => mockZoomControl), + }, +})) +/* eslint-enable @typescript-eslint/naming-convention */ + +// Mock CSS imports +jest.mock('leaflet/dist/leaflet.css', () => ({})) +jest.mock('leaflet.markercluster/dist/MarkerCluster.css', () => ({})) +jest.mock('leaflet.markercluster/dist/MarkerCluster.Default.css', () => ({})) +jest.mock('leaflet.markercluster', () => ({})) + +// Mock react-leaflet +jest.mock('react-leaflet', () => ({ + MapContainer: ({ + children, + center, + zoom, + scrollWheelZoom, + style, + zoomControl, + maxBounds, + maxBoundsViscosity, + className, + }: { + children: React.ReactNode + center: L.LatLngExpression + zoom: number + scrollWheelZoom: boolean + style: React.CSSProperties + zoomControl: boolean + maxBounds: L.LatLngBoundsExpression + maxBoundsViscosity: number + className: string + }) => { + useEffect(() => { + L.map('chapter-map', { + worldCopyJump: false, + maxBounds, + maxBoundsViscosity, + scrollWheelZoom, + zoomControl, + }).setView(center, zoom) + }, [center, zoom, scrollWheelZoom, zoomControl, maxBounds, maxBoundsViscosity]) + return ( +
+ {children} +
+ ) + }, + TileLayer: ({ + attribution, + url, + className, + }: { + attribution: string + url: string + className: string + }) => { + useEffect(() => { + L.tileLayer(url, { attribution, className }).addTo(mockMap as unknown as L.Map) + }, [url, attribution, className]) + return null + }, + Marker: ({ + children, + position, + icon, + }: { + children: React.ReactNode + position: L.LatLngExpression + icon: L.Icon + }) => { + useEffect(() => { + const marker = L.marker(position, { icon }) + marker.bindPopup(L.popup().setContent('mock content')) + }, [position, icon]) + return
{children}
+ }, + Popup: ({ children }: { children: React.ReactNode }) =>
{children}
, + useMap: () => mockMap as unknown as L.Map, +})) + +// Mock react-leaflet-cluster +jest.mock('react-leaflet-cluster', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +const defaultProps = { + geoLocData: mockChapterData.chapters, + showLocal: false, + style: { width: '100%', height: '400px' }, +} + +describe('ChapterMap a11y', () => { + it('should not have any accessibility violations in locked state', async () => { + const { baseElement } = render() + + const results = await axe(baseElement) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when map is unlocked', async () => { + const { baseElement } = render() + + const unlockButton = screen.getByLabelText('Unlock map') + fireEvent.click(unlockButton) + + await waitFor(() => expect(screen.getByLabelText(/Share location/i)).toBeInTheDocument()) + + const results = await axe(baseElement) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when user location is shared', async () => { + const { baseElement } = render( + + ) + + const results = await axe(baseElement) + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/ContributionHeatmap.a11y.test.tsx b/frontend/__tests__/a11y/components/ContributionHeatmap.a11y.test.tsx new file mode 100644 index 0000000000..f1e7cc1bf7 --- /dev/null +++ b/frontend/__tests__/a11y/components/ContributionHeatmap.a11y.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import { ReactNode } from 'react' +import ContributionHeatmap from 'components/ContributionHeatmap' + +expect.extend(toHaveNoViolations) + +jest.mock('react-apexcharts', () => { + return function MockChart(props: { + options: unknown + series: unknown + height: string | number + type: string + }) { + const mockSeries = props.series as Array<{ + name: string + data: Array<{ x: string; y: number; date: string }> + }> + const mockOptions = props.options as Record + + if (mockOptions.tooltip && typeof mockOptions.tooltip === 'object') { + const tooltip = mockOptions.tooltip as { custom?: (...args: unknown[]) => unknown } + if (tooltip.custom) { + if (mockSeries[0]?.data.length > 0) { + tooltip.custom({ + seriesIndex: 0, + dataPointIndex: 0, + w: { config: { series: mockSeries } }, + }) + } + tooltip.custom({ + seriesIndex: 0, + dataPointIndex: 999, + w: { config: { series: mockSeries } }, + }) + } + } + + return ( +
+ {mockSeries.map((series) => ( +
+ {series.name}: {series.data.length} data points +
+ ))} +
+ ) + } +}) + +jest.mock('next-themes', () => ({ + useTheme: () => ({ theme: 'light', setTheme: jest.fn() }), + ThemeProvider: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +const mockData: Record = { + '2024-01-01': 5, + '2024-01-02': 8, + '2024-01-03': 12, + '2024-01-04': 15, + '2024-01-05': 0, + '2024-01-08': 3, + '2024-01-15': 20, +} +const defaultProps = { + contributionData: mockData, + startDate: '2024-01-01', + endDate: '2024-01-31', +} + +describe('ContributionHeatmap Accessibility', () => { + it('should not have any accessibility violations', async () => { + const { container } = render() + + await screen.findByTestId('mock-heatmap-chart') + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when title is provided', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/ContributorAvatar.a11y.test.tsx b/frontend/__tests__/a11y/components/ContributorAvatar.a11y.test.tsx new file mode 100644 index 0000000000..b6fa9d9e8f --- /dev/null +++ b/frontend/__tests__/a11y/components/ContributorAvatar.a11y.test.tsx @@ -0,0 +1,57 @@ +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import { ReactNode } from 'react' +import { Contributor } from 'types/contributor' +import ContributorAvatar from 'components/ContributorAvatar' + +expect.extend(toHaveNoViolations) + +jest.mock('@heroui/tooltip', () => ({ + Tooltip: ({ children, content, id }: { children: ReactNode; content: string; id: string }) => ( +
+
{children}
+ +
+ ), +})) + +jest.mock('next/link', () => { + return ({ + children, + href, + target, + rel, + }: { + children: ReactNode + href: string + target?: string + rel?: string + }) => ( + + {children} + + ) +}) + +const mockGitHubContributor: Contributor = { + login: 'jane-doe', + name: 'Jane Doe', + avatarUrl: 'https://avatars.githubusercontent.com/u/12345', + contributionsCount: 15, + projectName: 'OWASP-Nest', + projectKey: 'test-key', +} + +describe('ContributorAvatar a11y', () => { + it('should not have any accessibility violations', async () => { + const { container } = render( + + ) + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/DashboardCard.a11y.test.tsx b/frontend/__tests__/a11y/components/DashboardCard.a11y.test.tsx new file mode 100644 index 0000000000..3f97aa505c --- /dev/null +++ b/frontend/__tests__/a11y/components/DashboardCard.a11y.test.tsx @@ -0,0 +1,23 @@ +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import { FaUser } from 'react-icons/fa' +import DashboardCard from 'components/DashboardCard' + +expect.extend(toHaveNoViolations) + +const baseProps = { + title: 'Test Card', + icon: FaUser, + className: undefined, + stats: undefined, +} + +describe('DashboardCard a11y', () => { + it('should not have any accessibility violations', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/DisplayIcon.a11y.test.tsx b/frontend/__tests__/a11y/components/DisplayIcon.a11y.test.tsx new file mode 100644 index 0000000000..0376309147 --- /dev/null +++ b/frontend/__tests__/a11y/components/DisplayIcon.a11y.test.tsx @@ -0,0 +1,25 @@ +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import { Icon } from 'types/icon' +import DisplayIcon from 'components/DisplayIcon' + +expect.extend(toHaveNoViolations) + +const mockIcons: Icon = { + starsCount: 1250, + forksCount: 350, + contributorsCount: 25, + contributionCount: 25, + issuesCount: 42, + license: 'MIT', +} + +describe('DisplayIcon a11y', () => { + it('should not have any accessibility violations', async () => { + const { container } = render() + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/DonutBarChart.a11y.test.tsx b/frontend/__tests__/a11y/components/DonutBarChart.a11y.test.tsx new file mode 100644 index 0000000000..913858fb4c --- /dev/null +++ b/frontend/__tests__/a11y/components/DonutBarChart.a11y.test.tsx @@ -0,0 +1,38 @@ +import { render } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import { FaChartPie } from 'react-icons/fa' +import DonutBarChart from 'components/DonutBarChart' + +expect.extend(toHaveNoViolations) + +jest.mock('next/dynamic', () => { + return jest.fn(() => { + // Mock Chart component that mimics react-apexcharts + const MockChart = ({ options, series, height, type, ...props }) => ( +
+ ApexCharts Mock +
+ ) + MockChart.displayName = 'Chart' + return MockChart + }) +}) + +describe('DonutBarChart a11y', () => { + it('should not have any accessibility violations', async () => { + const { container } = render( + + ) + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/EntityActions.a11y.test.tsx b/frontend/__tests__/a11y/components/EntityActions.a11y.test.tsx new file mode 100644 index 0000000000..78e422ac67 --- /dev/null +++ b/frontend/__tests__/a11y/components/EntityActions.a11y.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import EntityActions from 'components/EntityActions' + +expect.extend(toHaveNoViolations) + +describe('EntityActions a11y', () => { + it('should not have any accessibility violations', async () => { + const setStatus = jest.fn() + + const { container } = render( + + ) + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) + + it('should not have any accessibility violations when dropDown is open', async () => { + const setStatus = jest.fn() + + const { container } = render( + + ) + + const toggleButton = screen.getByRole('button', { name: /Program actions menu/ }) + fireEvent.click(toggleButton) + + const results = await axe(container) + + expect(results).toHaveNoViolations() + }) +}) diff --git a/frontend/__tests__/a11y/components/Footer.a11y.test.tsx b/frontend/__tests__/a11y/components/Footer.a11y.test.tsx new file mode 100644 index 0000000000..23d79de291 --- /dev/null +++ b/frontend/__tests__/a11y/components/Footer.a11y.test.tsx @@ -0,0 +1,26 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { axe, toHaveNoViolations } from 'jest-axe' +import Footer from 'components/Footer' + +expect.extend(toHaveNoViolations) + +describe('Footer a11y', () => { + it('should not have any accessibility violations', async () => { + const { container } = render(