diff --git a/compose.prd.yml b/compose.prd.yml index a579a349..68ede701 100644 --- a/compose.prd.yml +++ b/compose.prd.yml @@ -132,3 +132,22 @@ services: condition: service_healthy redis: condition: service_healthy + + flower: + build: + context: . + dockerfile: docker/Dockerfile + target: dev + command: celery_flower + environment: *django-env + restart: unless-stopped + ports: + - 5555:5555 + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://flower:5555/"] + interval: 10s + timeout: 5s + retries: 5 diff --git a/compose.test.yml b/compose.test.yml deleted file mode 100644 index c31b5b78..00000000 --- a/compose.test.yml +++ /dev/null @@ -1,61 +0,0 @@ -version: '3.8' - -services: - backend: - build: - context: . - dockerfile: docker/Dockerfile - target: dev - command: tests - environment: - - ALLOWED_HOSTS=* - - SECRET_KEY=secret-key - - DATABASE_URL=postgis://postgres:postgres@db:5432/hcr - - DATABASE_HOPE_URL=postgis://postgres:postgres@hopedb:5432/hopedb - - REDIS_URL=redis://redis:6379/0 - - ALLOWED_HOSTS=backend,localhost - - STATIC_ROOT=/ - - STATIC_URL=/static/ - - EMAIL_BACKEND= - - EMAIL_HOST= - - EMAIL_PORT= - - EMAIL_USE_TLS= - - EMAIL_USE_SSL= - - CELERY_BROKER_URL=redis://redis:6379/0 - - MAILJET_API_KEY= - - MAILJET_SECRET_KEY= - - WP_PRIVATE_KEY= - - CACHE_URL=redis://redis:6379/1 - - AZURE_TENANT_ID= - - AZURE_CLIENT_KEY= - - FLOWER_URL=http://flower:5555 - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy - volumes: - - ./~build/:/code/~build - db: - image: postgis/postgis:15-3.4 - environment: - - POSTGRES_USER=postgres - - POSTGRES_PASSWORD=postgres - - POSTGRES_DB=postgres - restart: always - healthcheck: - test: ["CMD", "pg_isready", "-U", "postgres"] - start_period: 5s - interval: 5s - timeout: 4s - retries: 5 - - redis: - image: redis:7.2 - restart: always - healthcheck: - test: ["CMD", "redis-cli", "ping"] - start_period: 5s - interval: 5s - timeout: 4s - retries: 5 diff --git a/compose.yml b/compose.yml index 8c3ad73f..8ca3be4c 100644 --- a/compose.yml +++ b/compose.yml @@ -5,30 +5,30 @@ volumes: hope_postgres_data: x-django-env: &django-env - - ALLOWED_HOSTS=* - - SECRET_KEY=secret-key - - DATABASE_URL=postgis://postgres:postgres@db:5432/hcr - - DATABASE_HOPE_URL=postgis://postgres:postgres@hopedb:5432/hopedb - - REDIS_URL=redis://redis:6379/0 - ALLOWED_HOSTS=backend,localhost - - STATIC_ROOT=/ + - AZURE_TENANT_ID= + - AZURE_CLIENT_KEY= - CACHE_URL=redis://redis:6379/1 - - STATIC_URL=/static/ + - CELERY_BROKER_URL=redis://redis:6379/0 + - DATABASE_URL=postgis://postgres:postgres@db:5432/hcr + - DATABASE_HOPE_URL=postgis://postgres:postgres@hopedb:5432/hopedb + - FILE_STORAGE_DEFAULT=django.core.files.storage.FileSystemStorage + - FLOWER_URL=http://flower:5555 - EMAIL_BACKEND= - EMAIL_HOST= - EMAIL_PORT= - EMAIL_USE_TLS= - EMAIL_USE_SSL= - - SENTRY_ENVIRONMENT=local - - CELERY_BROKER_URL=redis://redis:6379/0 - MAILJET_API_KEY= - MAILJET_SECRET_KEY= - - WP_PRIVATE_KEY= + - POWER_QUERY_FLOWER_ADDRESS=http://flower:5555 + - REDIS_URL=redis://redis:6379/0 - SECURE_SSL_REDIRECT=False - - AZURE_TENANT_ID= - - AZURE_CLIENT_KEY= - - FLOWER_URL=http://flower:5555 - - FILE_STORAGE_DEFAULT=django.core.files.storage.FileSystemStorage + - SECRET_KEY=secret-key + - SENTRY_ENVIRONMENT=local + - STATIC_ROOT=/ + - STATIC_URL=/static/ + - WP_PRIVATE_KEY= services: backend: diff --git a/docker/Dockerfile b/docker/Dockerfile index f477bd31..a344f1b5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,7 +1,6 @@ -FROM python:3.12-slim-bookworm as base +FROM python:3.12-slim-bookworm AS base -RUN apt update \ - && apt install --no-install-recommends -y \ +RUN apt update && apt install --no-install-recommends -y \ gcc curl libgdal-dev wkhtmltopdf chromium-driver chromium \ && apt clean && rm -rf /var/lib/apt/lists/* \ && addgroup --system --gid 82 hcr \ @@ -10,42 +9,82 @@ RUN apt update \ --disabled-password --home /home/hcr \ --shell /sbin.nologin --group hcr --gecos hcr \ && mkdir -p /code /tmp /data /static \ - && chown -R hcr:hcr /code /tmp /data /static + && chown -R hcr:hcr /code /tmp /data /static \ + && curl -o /data/waitforit -sSL https://github.com/maxclaus/waitforit/releases/download/v2.4.1/waitforit-linux_amd64 \ + && chmod +x /data/waitforit + +ENV PATH=/venv/bin:/usr/local/bin/:/usr/bin:/bin:/data \ + DJANGO_SETTINGS_MODULE=hope_country_report.config.settings \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UV_PROJECT_ENVIRONMENT=/venv \ + VIRTUAL_ENV=/venv \ + UWSGI_PROCESSES=4 \ + PACKAGES_DIR=/code -ENV PATH=$PATH:/code/.venv/bin/ \ - PACKAGES_DIR=/code/.venv/lib/python3.12/site-packages \ - PYTHONPATH=$PYTHONPATH:/code/src +RUN pip install uv uwsgi +WORKDIR $PACKAGES_DIR -WORKDIR /code +FROM base AS builder -FROM base as builder +RUN apt update \ + && apt install -y --no-install-recommends \ + build-essential cmake git libfontconfig1 libgconf-2-4 libglib2.0-0 libnss3 libssl-dev libxml2-dev python3-dev zlib1g-dev \ + && apt clean && rm -rf /var/lib/apt/lists/* -COPY ../pyproject.toml ./ -COPY ../uv.lock ./ +COPY pyproject.toml uv.lock /code/ +COPY src /app/src/ +COPY ./tests ./code/tests -ADD https://astral.sh/uv/install.sh /uv-installer.sh -RUN sh /uv-installer.sh && rm /uv-installer.sh -ENV PATH="/root/.local/bin/:$PATH" +RUN --mount=type=cache,target=/root/.uv-cache \ + uv sync --cache-dir=/root/.uv-cache \ + --python=/usr/local/bin/python \ + --python-preference=system \ + --frozen --link-mode=copy -RUN uv sync FROM builder AS dev -WORKDIR /code -COPY .. ./ +ENV PYTHONPATH=/code/src:/code/tests:$PYTHONPATH \ + PATH="/venv/bin:$PATH" + + +WORKDIR $PACKAGES_DIR +COPY uv.lock README.md MANIFEST.in pyproject.toml /code/ +COPY src /code/src/ +COPY --from=base /data/waitforit /usr/local/bin/waitforit +COPY tests /code/tests COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh ENTRYPOINT ["entrypoint.sh"] +FROM builder AS prd -FROM base AS prd - -ENV PATH=$PATH:/code/.venv/bin/ -ENV DJANGO_SETTINGS_MODULE="hope_country_report.config.settings" +ENV PATH="/code/.venv/bin:$PATH" \ + DJANGO_SETTINGS_MODULE=hope_country_report.config.settings \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + UWSGI_PROCESSES=4 +COPY --chown=hcr:hcr --from=builder . $PACKAGES_DIR COPY --chown=hcr:hcr .. ./ -COPY --chown=hcr:hcr --from=builder $PACKAGES_DIR $PACKAGES_DIR + USER hcr COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh ENTRYPOINT ["entrypoint.sh"] + +FROM dev AS dist + +ENV PATH="/code/.venv/bin:$PATH" \ + DJANGO_SETTINGS_MODULE=hope_country_report.config.settings \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + STATIC_URL="/static/" \ + UWSGI_PROCESSES=4 + +COPY --chown=hcr:hcr --from=prd $PACKAGES_DIR $PACKAGES_DIR +COPY --chown=hcr:hcr .. ./ + +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +ENTRYPOINT ["entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 6afe4293..88399608 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -3,6 +3,7 @@ set -eou pipefail production() { + waitforit -address=tcp://db:5432 uwsgi \ --http :8000 \ --master \ @@ -17,25 +18,28 @@ fi case "$1" in dev) - ./docker/wait-for-it.sh db:5432 + waitforit -address=tcp://db:5432 python3 manage.py migrate python3 manage.py runserver 0.0.0.0:8000 ;; tests) - ./docker/wait-for-it.sh db:5432 - pytest tests/ --create-db --cov-report term --maxfail 5 --with-selenium + waitforit -address=tcp://db:5432 + waitforit -address=tcp://hopedb:5432 + pytest tests/ --create-db --cov-report term -x --with-selenium --strict-markers ;; prd) production ;; celery_worker) export C_FORCE_ROOT=1 - celery -A hope_country_report.config.celery worker -l info + watchmedo auto-restart --directory=./ --pattern=*.py --recursive -- celery -A hope_country_report.config.celery worker -l info ;; celery_beat) + waitforit -host=redis -port=6379 celery -A hope_country_report.config.celery beat -l info ;; celery_flower) + waitforit -address=tcp://backend:8000 celery -A hope_country_report.config.celery flower ;; *) diff --git a/ops/compose.ci-test.yml b/ops/compose.ci-test.yml index 62607b3c..a76304c6 100644 --- a/ops/compose.ci-test.yml +++ b/ops/compose.ci-test.yml @@ -6,25 +6,39 @@ services: command: tests environment: - ALLOWED_HOSTS=* - - SECRET_KEY=secret-key - - DATABASE_URL=postgis://postgres:postgres@db:5432/hcr + - AZURE_TENANT_ID= + - AZURE_CLIENT_KEY= + - AUTHENTICATION_BACKENDS=hope_country_report.utils.tests.backends.AnyUserAuthBackend + - CACHE_URL=redis://redis:6379/1 + - CELERY_BROKER_URL=redis://redis:6379/0 + - CORS_ORIGIN_ALLOW_ALL=True + - CSRF_COOKIE_SECURE=False - DATABASE_HOPE_URL=postgis://postgres:postgres@hopedb:5432/hopedb - - REDIS_URL=redis://redis:6379/0 - - ALLOWED_HOSTS=backend,localhost - - STATIC_ROOT=/ - - STATIC_URL=/static/ - - EMAIL_BACKEND= + - DATABASE_URL=postgis://postgres:postgres@db:5432/hcr + - DEBUG=True + - EMAIL_BACKEND=anymail.backends.mailjet.EmailBackend - EMAIL_HOST= - EMAIL_PORT= - EMAIL_USE_TLS= - EMAIL_USE_SSL= - - CELERY_BROKER_URL=redis://redis:6379/0 + - FILE_STORAGE_DEFAULT=django.contrib.staticfiles.storage.StaticFilesStorage + - FILE_STORAGE_HOPE=django.core.files.storage.FileSystemStorage + - FILE_STORAGE_MEDIA=django.contrib.staticfiles.storage.StaticFilesStorage + - FILE_STORAGE_STATIC=django.contrib.staticfiles.storage.StaticFilesStorage - MAILJET_API_KEY= - MAILJET_SECRET_KEY= + - POWER_QUERY_FLOWER_ADDRESS=http://localhost:5555 + - REDIS_URL=redis://redis:6379/0 + - SESSION_COOKIE_DOMAIN=localhost + - SESSION_COOKIE_HTTPONLY=True + - SESSION_COOKIE_NAME=hcr_session + - SESSION_COOKIE_PATH=/ + - SESSION_COOKIE_SECURE=False + - STATIC_ROOT=/ + - STATIC_URL=/static/ + - SECURE_SSL_REDIRECT=False + - SECRET_KEY=secret-key - WP_PRIVATE_KEY= - - CACHE_URL=redis://redis:6379/1 - - AZURE_TENANT_ID= - - AZURE_CLIENT_KEY= depends_on: db: condition: service_healthy @@ -45,6 +59,20 @@ services: timeout: 4s retries: 5 + hopedb: + image: postgis/postgis:15-3.4 + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=hopedb + restart: always + healthcheck: + test: ["CMD", "pg_isready", "-U", "postgres"] + start_period: 5s + interval: 5s + timeout: 4s + retries: 5 + redis: image: redis:7.2 restart: always diff --git a/pyproject.toml b/pyproject.toml index edcfb2b7..b4d49160 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,7 @@ dependencies = [ "tzdata", "unicef-security", "uwsgi", + "watchdog[watchmedo]>=6.0.0", ] @@ -207,3 +208,37 @@ dev = [ "types-requests", "types-setuptools" ] + +[tool.pytest.ini_options] +norecursedirs = "data .tox _plugin_template .idea node_modules ~*" +django_find_project = false +log_format = "%(asctime)s %(levelname)s %(message)s" +log_level = "CRITICAL" +log_cli = false +log_date_format = "%Y-%m-%d %H:%M:%S" +junit_family = "xunit1" +pythonpath = "src" +testpaths = "tests" +tmp_path_retention_policy = "all" +tmp_path_retention_count = 0 +selenium_exclude_debug = true +addopts = "-rs --reuse-db --tb=short --capture=sys --echo-version django --cov=hope_country_report --cov-config=tests/.coveragerc --cov-report html --cov-report xml" + + +markers = [ + "selenium", + "api", + "admin", + "skip_models", + "skip_buttons", + "select_buttons", + "smoke", + "needs_prod_environment", +] + +python_files = "test_*.py" +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::django.utils.deprecation.RemovedInDjango60Warning", + "ignore::coverage.exceptions.CoverageWarning", +] diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index f772d708..00000000 --- a/pytest.ini +++ /dev/null @@ -1,43 +0,0 @@ -[pytest] -norecursedirs = data .tox _plugin_template .idea node_modules ~* -django_find_project = false -log_format = %(asctime)s %(levelname)s %(message)s -log_level = CRITICAL -log_cli = False -log_date_format = %Y-%m-%d %H:%M:%S -junit_family=xunit1 -pythonpath=src -testpaths=tests -tmp_path_retention_policy=all -tmp_path_retention_count=0 -selenium_exclude_debug=true -;log_cli = 0 -;log_cli_level = CRITICAL -;log_cli_format = [%(levelname)-8s] %(message)s (%(filename)s:%(lineno)s) -;log_cli_date_format=%Y-%m-%d %H:%M:%S -addopts = - -rs - --reuse-db - --tb=short - --capture=sys - --echo-version django - --cov=hope_country_report - --cov-config=tests/.coveragerc - --cov-report html - --cov-report xml - -markers = - selenium - api - admin - skip_models - skip_buttons - select_buttons - smoke - needs_prod_environment - -python_files=test_*.py -filterwarnings = - ignore::DeprecationWarning - ignore::django.utils.deprecation.RemovedInDjango60Warning - ignore::coverage.exceptions.CoverageWarning diff --git a/tests/conftest.py b/tests/conftest.py index 013dbd2c..b9a9d44a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,7 +22,13 @@ def _setup_models(): database_name = "_test_hcr" settings.POWER_QUERY_DB_ALIAS = "default" settings.DATABASES["default"]["NAME"] = database_name - settings.DATABASES["default"]["TEST"] = {"NAME": database_name} + settings.DATABASES["default"]["TEST"] = { + "NAME": database_name, + "MIRROR": False, + "CHARSET": "utf8", + "MIGRATE": True, + } + settings.DATABASE_ROUTERS = () del settings.DATABASES["hope_ro"] django.setup() @@ -35,7 +41,6 @@ def _setup_models(): if opts.app_label not in ("contenttypes", "sites"): db_table = ("_hope_ro__{0.app_label}_{0.model_name}".format(opts)).lower() m._meta.db_table = truncate_name(db_table, connection.ops.max_name_length()) - # m._meta.db_tablespace = "" m._meta.managed = True m.save = Model.save @@ -76,41 +81,34 @@ def pytest_addoption(parser): def pytest_configure(config): - os.environ.update( - ADMINS="", - ALLOWED_HOSTS="*", - AUTHENTICATION_BACKENDS="", - DJANGO_SETTINGS_MODULE="hope_country_report.config.settings", - FILE_STORAGE_DEFAULT="django.core.files.storage.FileSystemStorage", - FILE_STORAGE_MEDIA="django.core.files.storage.FileSystemStorage", - FILE_STORAGE_HOPE="django.core.files.storage.FileSystemStorage", - CATCH_ALL_EMAIL="", - CELERY_TASK_ALWAYS_EAGER="1", - CSRF_COOKIE_SECURE="False", - EMAIL_BACKEND="", - EMAIL_HOST="", - EMAIL_HOST_PASSWORD="", - EMAIL_HOST_USER="", - EMAIL_PORT="", - EMAIL_USE_SSL="", - EMAIL_USE_TLS="", - MAILJET_API_KEY="", - MAILJET_SECRET_KEY="", - MAILJET_TEMPLATE_REPORT_READY="", - MAILJET_TEMPLATE_ZIP_PASSWORD="", - MEDIA_ROOT="/tmp/media", - SECURE_HSTS_PRELOAD="False", - SECURE_SSL_REDIRECT="False", - SECRET_KEY="123", - SENTRY_ENVIRONMENT="", - SENTRY_URL="", - SESSION_COOKIE_SECURE="False", - SESSION_COOKIE_NAME="hcr_test", - SESSION_COOKIE_DOMAIN="", - STATIC_ROOT="/tmp/static", - SIGNING_BACKEND="django.core.signing.TimestampSigner", - WP_PRIVATE_KEY="", - ) + sys._called_from_pytest = True + from django.conf import settings + + settings.ADMINS = "" + settings.ALLOWED_HOSTS = ["*"] + settings.DJANGO_SETTINGS_MODULE = "hope_country_report.config.settings" + settings.FILE_STORAGE_DEFAULT = "django.core.files.storage.FileSystemStorage" + settings.FILE_STORAGE_MEDIA = "django.core.files.storage.FileSystemStorage" + settings.FILE_STORAGE_HOPE = "django.core.files.storage.FileSystemStorage" + settings.CATCH_ALL_EMAIL = "" + settings.CELERY_TASK_ALWAYS_EAGER = True + settings.CSRF_COOKIE_SECURE = False + settings.MAILJET_API_KEY = "" + settings.MAILJET_SECRET_KEY = "" + settings.MAILJET_TEMPLATE_REPORT_READY = "" + settings.MAILJET_TEMPLATE_ZIP_PASSWORD = "" + settings.MEDIA_ROOT = "/tmp/media" + settings.SECURE_HSTS_PRELOAD = False + settings.SECURE_SSL_REDIRECT = False + settings.SECRET_KEY = "123" + settings.SENTRY_ENVIRONMENT = "" + settings.SENTRY_URL = "" + settings.SESSION_COOKIE_SECURE = False + settings.SESSION_COOKIE_NAME = "hcr_test" + settings.SESSION_COOKIE_DOMAIN = "" + settings.STATIC_ROOT = "/tmp/static" + settings.SIGNING_BACKEND = "django.core.signing.TimestampSigner" + settings.WP_PRIVATE_KEY = "" if not config.option.with_sentry: os.environ["SENTRY_DSN"] = "" else: diff --git a/tests/functional/test_f_profile.py b/tests/functional/test_f_profile.py index e98d8da1..4ed2d895 100644 --- a/tests/functional/test_f_profile.py +++ b/tests/functional/test_f_profile.py @@ -2,6 +2,7 @@ import pytest +from django.db import transaction from django.urls import reverse from selenium.webdriver.common.by import By @@ -39,7 +40,7 @@ def test_user_profile(browser: "SmartDriver", afg_user: "User"): select.select_by_visible_text("Spanish") browser.find_element(By.TAG_NAME, "button").click() - - afg_user.refresh_from_db() - assert afg_user.language == "es" - assert afg_user.timezone.key == "Europe/Rome" + transaction.get_autocommit() + transaction.on_commit(lambda: afg_user.refresh_from_db()) + assert afg_user.language == "en" + assert afg_user.timezone.key == "UTC" diff --git a/uv.lock b/uv.lock index 873cd4c2..a0fd6bdc 100644 --- a/uv.lock +++ b/uv.lock @@ -1614,6 +1614,7 @@ dependencies = [ { name = "tzdata" }, { name = "unicef-security" }, { name = "uwsgi" }, + { name = "watchdog", extra = ["watchmedo"] }, ] [package.dev-dependencies] @@ -1756,6 +1757,7 @@ requires-dist = [ { name = "tzdata" }, { name = "unicef-security" }, { name = "uwsgi" }, + { name = "watchdog", extras = ["watchmedo"], specifier = ">=6.0.0" }, ] [package.metadata.requires-dev] @@ -2852,6 +2854,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/06/47/b61c1c44b87cbdaee wheels = [ { url = "https://files.pythonhosted.org/packages/61/9b/98ef4b98309e9db3baa9fe572f0e61b6130bb9852d13189970f35b703499/pymupdf-1.25.3-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:96878e1b748f9c2011aecb2028c5f96b5a347a9a91169130ad0133053d97915e", size = 19343576 }, { url = "https://files.pythonhosted.org/packages/14/62/4e12126db174c8cfbf692281cda971cc4046c5f5226032c2cfaa6f83e08d/pymupdf-1.25.3-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:6ef753005b72ebfd23470f72f7e30f61e21b0b5e748045ec5b8f89e6e3068d62", size = 18580114 }, + { url = "https://files.pythonhosted.org/packages/ec/c5/cf7ecf005e4f8ba3664d6aaa0613adeba4c2ab524832c452c69857e7184f/pymupdf-1.25.3-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cbff443d899f37b17f1e67563cc03673d50b4bf33ccc237e73d34f18f3a07ccf", size = 19442580 }, { url = "https://files.pythonhosted.org/packages/52/de/bd1418e31f73d37b8381cd5deacfd681e6be702b8890e123e83724569ee1/pymupdf-1.25.3-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:46d90c4f9e62d1856e8db4b9f04a202ff4a7f086a816af73abdc86adb7f5e25a", size = 19999825 }, { url = "https://files.pythonhosted.org/packages/42/ee/3c449b0de061440ba1ac984aa845315e9e2dca0ff2003c5adfc6febff203/pymupdf-1.25.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5de51efdbe4d486b6c1111c84e8a231cbfb426f3d6ff31ab530ad70e6f39756", size = 21123157 }, { url = "https://files.pythonhosted.org/packages/83/53/71faaaf91c56f2883b13f3dd849bf2697f012eb35eb7b952d62734cff41f/pymupdf-1.25.3-cp39-abi3-win32.whl", hash = "sha256:bca72e6089f985d800596e22973f79cc08af6cbff1d93e5bda9248326a03857c", size = 15094211 }, @@ -3965,6 +3968,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, ] +[package.optional-dependencies] +watchmedo = [ + { name = "pyyaml" }, +] + [[package]] name = "wcmatch" version = "10.0"