diff --git a/Containerfile b/Containerfile index 83e951e1..f1a52b01 100644 --- a/Containerfile +++ b/Containerfile @@ -25,6 +25,10 @@ COPY . . ENV PATH="/app/.venv/bin:$PATH" +# Release id (e.g. git short SHA); selects this image's django-compressor offline manifest. +ARG METRON_RELEASE=dev +ENV METRON_RELEASE=$METRON_RELEASE + # Compile translation catalogs into the image. Uses django-admin (not # manage.py) so it doesn't require settings/env vars (DB, SECRET_KEY, etc.) # to be configured at build time. gettext (msgfmt) is only needed here, so diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index cb3da715..a707ede2 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -38,6 +38,9 @@ with Quadlet (systemd-managed containers). - [Manual backup](#manual-backup) - [Automated backups with a systemd timer](#automated-backups-with-a-systemd-timer) - [Restoring from a backup](#restoring-from-a-backup) +- [Upgrading PostgreSQL](#upgrading-postgresql) + - [Minor version updates](#minor-version-updates) + - [Major version upgrade (16 → 18)](#major-version-upgrade-16--18) - [Enable persistent journals](#enable-persistent-journals) - [Fail2ban](#fail2ban) - [Install](#install) @@ -322,7 +325,8 @@ systemctl --user list-timers certbot-renew.timer ```bash cd ~/metron -podman build -t localhost/metron:latest . +podman build --build-arg METRON_RELEASE=$(git rev-parse --short HEAD) \ + -t localhost/metron:latest . ``` --- @@ -411,11 +415,21 @@ systemctl --user start metron-nginx > ```bash > podman exec metron-web python manage.py migrate > podman exec metron-web python manage.py collectstatic --no-input +> podman exec metron-web python manage.py compress --force +> systemctl --user restart metron-web > ``` > > When restoring from an existing droplet the schema and static files are > already in place — `migrate` is a no-op and static files live on -> DigitalOcean Spaces, so `collectstatic` is not needed either. +> DigitalOcean Spaces, so `collectstatic` is not needed either. Run +> `compress` anyway: the image's release manifest +> (`static/CACHE/manifest-.json`) won't be in the bucket yet. +> +> `compress` builds the minified CSS/JS bundles for every `{% compress %}` +> block in the templates (django-compressor, offline mode) and uploads them +> plus `static/CACHE/manifest-.json` to Spaces. Each gunicorn worker caches +> the manifest in memory, so restart `metron-web` after compressing. A page +> whose bundle is missing from the manifest fails with `OfflineGenerationError`. The container services start automatically on boot without needing `systemctl enable` — the `WantedBy=default.target` line in each `.container` @@ -541,14 +555,33 @@ git pull cp ~/metron/.quadlet/* ~/.config/containers/systemd/ systemctl --user daemon-reload -podman build -t localhost/metron:latest . +podman build --build-arg METRON_RELEASE=$(git rev-parse --short HEAD) \ + -t localhost/metron:latest . + +# Re-run collectstatic only if static assets changed: +# podman run --rm --network host --env-file ~/.config/containers/metron.env \ +# localhost/metron:latest python manage.py collectstatic --no-input + +# Build the CSS/JS bundles and this release's manifest from the *new* image before +# restarting. Each release gets its own manifest-.json, so containers still running +# the previous release keep reading their own manifest. +podman run --rm --network host --env-file ~/.config/containers/metron.env \ + localhost/metron:latest python manage.py compress --force + systemctl --user restart metron-web metron-anubis podman exec metron-web python manage.py migrate -# Re-run collectstatic only if static assets changed: -# podman exec metron-web python manage.py collectstatic --no-input ``` +> **Why `compress` every deploy:** production uses django-compressor in offline +> mode. If a template's `{% compress %}` block or any bundled CSS/JS file changes +> and `compress` isn't re-run, those pages fail with `OfflineGenerationError`. +> It is cheap to run, so run it on every deploy. The manifest name includes the +> `METRON_RELEASE` build arg, so always pass `--build-arg METRON_RELEASE=...` when +> building; rolling back to an earlier image reuses that image's manifest, which +> is still in the bucket. (The "Invalid template +> django_filters/rest_framework/crispy_form.html" warning it prints is harmless.) + --- ## History cleanup @@ -795,6 +828,171 @@ systemctl --user start metron-web --- +## Upgrading PostgreSQL + +### Minor version updates + +Minor releases (e.g. 16.4 → 16.10) never change the on-disk format, so updating +is just a pull and restart: + +```bash +podman pull docker.io/library/postgres:16 +systemctl --user restart metron-postgres +podman exec metron-postgres postgres --version +``` + +### Major version upgrade (16 → 18) + +A new major version uses a different data-directory format, and `postgres:18` +will not start on a volume initialized by 16, so changing the `Image=` tag alone +does not work. The procedure below dumps the database, starts 18 on a **new** +volume, and restores into it. The old volume is left in place as the rollback. +Restoring from a dump also rebuilds every index, which avoids the collation +problems that can come with a newer glibc in the new image. + +Two things are different from a normal restore: + +1. **The 18 image moved its data directory.** `PGDATA` is now + `/var/lib/postgresql/18/docker`, and the volume must be mounted at + `/var/lib/postgresql`, not `/var/lib/postgresql/data`. +2. **The `unaccent` change must be redone before restoring.** Migration + `0050` runs `ALTER FUNCTION unaccent(text) IMMUTABLE`. `unaccent(text)` + belongs to the `unaccent` extension, and `pg_dump` does not save changes + made to extension members. A fresh database therefore gets the stock + STABLE `unaccent()`, and the five `*_name_unaccent_trgm_idx` indexes fail + with *"functions in index expression must be marked IMMUTABLE"*. `pg_restore` + logs the error and keeps going, so the site comes up with slow name + searches and nothing obviously broken. Step 6 below handles this. + +Expect downtime from step 2 until step 9. Rehearse the restore locally against +a production dump first: it shows how long the restore takes and catches problems +before you're working on production. + +**1. Record a baseline** to compare against after the restore: + +```bash +podman exec metron-postgres psql -U {db_username} -d metron -Atc \ + "SELECT count(*) FROM pg_indexes WHERE schemaname = 'public';" +podman exec metron-postgres psql -U {db_username} -d metron -Atc \ + "SELECT count(*) FROM comicsdb_issue;" +``` + +**2. Stop everything that writes to the database**, including the timers: + +```bash +systemctl --user stop metron-nginx metron-anubis metron-web +systemctl --user stop metron-backup.timer metron-history-cleanup.timer \ + metron-opencollective-sync.timer +``` + +**3. Take the final dump.** Run the backup script first so there is also an +off-site copy in Spaces: + +```bash +bash ~/metron/scripts/backup.sh +podman exec metron-postgres pg_dump -U {db_username} -Fc metron -f /tmp/final.dump +podman cp metron-postgres:/tmp/final.dump ~/backups/metron-pg16-final.dump +``` + +**4. Stop Postgres 16.** Do not remove its volume (`systemd-metron-postgres`): +it is the rollback. + +```bash +systemctl --user stop metron-postgres +``` + +**5. Point the quadlet at 18 and a new volume.** Create the volume unit: + +```bash +printf '[Volume]\n' > ~/.config/containers/systemd/metron-postgres18.volume +``` + +Then edit `~/.config/containers/systemd/metron-postgres.container` and change +these two lines: + +```ini +Image=docker.io/library/postgres:18 +Volume=metron-postgres18.volume:/var/lib/postgresql +``` + +Make the same change in the repo's `.quadlet/` files, but merge it only when you +do the upgrade. Otherwise the next routine deploy (`cp ~/metron/.quadlet/* ...`) +would switch Postgres over by accident. + +```bash +systemctl --user daemon-reload +systemctl --user start metron-postgres # initializes the empty volume +podman exec metron-postgres postgres --version +``` + +**6. Prepare the empty database before restoring.** The dump uses +`CREATE EXTENSION IF NOT EXISTS`, so creating the extensions first doesn't +conflict with the restore: + +```bash +podman exec metron-postgres psql -U {db_username} -d metron -c " + CREATE EXTENSION IF NOT EXISTS unaccent; + CREATE EXTENSION IF NOT EXISTS pg_trgm; + ALTER FUNCTION unaccent(text) IMMUTABLE;" +``` + +**7. Restore, then rebuild planner statistics.** A dump does not carry +statistics, and queries will be slow until `ANALYZE` has run: + +```bash +podman cp ~/backups/metron-pg16-final.dump metron-postgres:/tmp/final.dump +podman exec metron-postgres pg_restore \ + --username {db_username} \ + --dbname metron \ + --no-owner \ + --role {db_username} \ + /tmp/final.dump 2>&1 | tee ~/pg18-restore.log + +grep -i error ~/pg18-restore.log # should print nothing +podman exec metron-postgres vacuumdb -U {db_username} -d metron --analyze-in-stages +podman exec metron-postgres rm /tmp/final.dump +``` + +**8. Verify** that both counts match the baseline from step 1, and that the +`unaccent` indexes exist: + +```bash +podman exec metron-postgres psql -U {db_username} -d metron -Atc \ + "SELECT count(*) FROM pg_indexes WHERE schemaname = 'public';" +podman exec metron-postgres psql -U {db_username} -d metron -Atc \ + "SELECT count(*) FROM comicsdb_issue;" +podman exec metron-postgres psql -U {db_username} -d metron -Atc \ + "SELECT indexname FROM pg_indexes WHERE indexname LIKE '%unaccent_trgm_idx';" +``` + +**9. Bring the site back** and restart the timers: + +```bash +systemctl --user start metron-web metron-anubis metron-nginx +systemctl --user start metron-backup.timer metron-history-cleanup.timer \ + metron-opencollective-sync.timer +``` + +Check that the site loads, that a name search with an accented name works +(e.g. "Pokémon"), and that `journalctl --user -u metron-web -n 50` is free of +database errors. Run the backup service once (`systemctl --user start +metron-backup`) to confirm backups work against 18. + +**Rollback:** stop `metron-postgres`, put the old `Image=` and `Volume=` lines +back (`postgres:16`, `metron-postgres.volume:/var/lib/postgresql/data`), run +`systemctl --user daemon-reload`, and start it again. Anything written after the +cutover is lost, so decide early. + +**Cleanup:** after a week or two without problems, remove the old volume and +image: + +```bash +podman volume rm systemd-metron-postgres +podman rmi docker.io/library/postgres:16 +``` + +--- + ## Enable persistent journals On CentOS, journal logs are not persisted across reboots by default. Run this diff --git a/comicsdb/templates/comicsdb/home.html b/comicsdb/templates/comicsdb/home.html index 572d96e1..8c9b54a3 100644 --- a/comicsdb/templates/comicsdb/home.html +++ b/comicsdb/templates/comicsdb/home.html @@ -1,8 +1,10 @@ {% extends parent_template|default:"base.html" %} -{% load i18n static %} +{% load i18n static compress %} {% load cache %} {% block headcss %} - + {% compress css %} + + {% endcompress %} {% endblock %} {% block sitedesc %} {% trans "Metron is a community-based comic book database with a public REST API. Search and discover comic books with comprehensive metadata." as home_description %} diff --git a/comicsdb/templates/comicsdb/issue_form.html b/comicsdb/templates/comicsdb/issue_form.html index f076ce7d..3aa40b3f 100644 --- a/comicsdb/templates/comicsdb/issue_form.html +++ b/comicsdb/templates/comicsdb/issue_form.html @@ -1,15 +1,19 @@ {% extends parent_template|default:"base.html" %} -{% load i18n static %} +{% load i18n static compress %} {% load bulma_tags %} {% block title %} {% trans "Issue Form" %} {% endblock title %} {% block headcss %} - - {# Sticky bar, section nav, grouped fields #} - + {% compress css %} + + {% endcompress %} + {% compress css %} + {# Sticky bar, section nav, grouped fields #} + + {% endcompress %} {% endblock %} {% block content %}
@@ -143,7 +147,13 @@

{% trans "Attribution" %}

{% endif %} {% endblock %} {% block js %} - + {% compress js %} + + {% endcompress %} + {% compress js %} + {# Usability behaviour (section nav active-state); independent of the inline script below #} + + {% endcompress %} - {# Usability behaviour — section nav active-state #} - {% endblock %} diff --git a/comicsdb/templates/comicsdb/model_with_attribution_form.html b/comicsdb/templates/comicsdb/model_with_attribution_form.html index 6da2c30d..2fb72425 100644 --- a/comicsdb/templates/comicsdb/model_with_attribution_form.html +++ b/comicsdb/templates/comicsdb/model_with_attribution_form.html @@ -1,16 +1,20 @@ {% extends parent_template|default:"base.html" %} -{% load i18n static %} +{% load i18n static compress %} {% load bulma_tags %} {% block title %} {% trans "Form" as default_title %} {{ title|default:default_title }} {% endblock title %} {% block headcss %} - - {# usability styles — shared with issue_form.html #} - + {% compress css %} + + {% endcompress %} + {% compress css %} + {# usability styles — shared with issue_form.html #} + + {% endcompress %} {% endblock %} {% block content %} {% trans "Form" as default_title %} @@ -53,7 +57,13 @@

{% trans "Attribution" %}

{% endblock %} {% block js %} - + {% compress js %} + + {% endcompress %} + {% compress js %} + {# Usability behaviour (section nav active-state); independent of the inline script below #} + + {% endcompress %} - {# Tier 3 usability behaviour — shared with issue_form.html (section nav; role chips no-op here) #} - {% endblock %} diff --git a/comicsdb/templates/comicsdb/partials/issue_filter.html b/comicsdb/templates/comicsdb/partials/issue_filter.html index d9dc9ab6..d35d3b2f 100644 --- a/comicsdb/templates/comicsdb/partials/issue_filter.html +++ b/comicsdb/templates/comicsdb/partials/issue_filter.html @@ -1,4 +1,4 @@ -{% load i18n range_tags static %} +{% load i18n range_tags static compress %} {% comment %} USABILITY CHANGE: the ~14 advanced filter fields are now organised under labelled sub-sections (Series / Issue Numbers / Dates / Codes & IDs) @@ -446,8 +446,12 @@

{% trans "Description" %}

- - +{% compress css %} + +{% endcompress %} +{% compress js %} + +{% endcompress %} - + {% compress js %} + + {% endcompress %} + {% compress js %} + + {% endcompress %} {% endblock %} {% block title %} {% trans "Statistics - Metron" %} diff --git a/metron/settings.py b/metron/settings.py index 12619734..f6b004f8 100644 --- a/metron/settings.py +++ b/metron/settings.py @@ -50,6 +50,7 @@ "django.contrib.messages", "django.contrib.postgres", "django.contrib.staticfiles", + "compressor", "django.contrib.sites", "django.contrib.humanize", "django.forms", @@ -358,8 +359,24 @@ "django.contrib.staticfiles.finders.AppDirectoriesFinder", # Now add our custom SimpleBulma one. "django_simple_bulma.finders.SimpleBulmaFinder", + "compressor.finders.CompressorFinder", ] +# django-compressor: bundles and minifies the local CSS/JS inside {% compress %} blocks. +# COMPRESS_ENABLED defaults to `not DEBUG`, so development serves the original files. +# Production compresses offline: run `manage.py compress` after `collectstatic` on deploy. +COMPRESS_OFFLINE = not DEBUG +COMPRESS_FILTERS = { + "css": [ + "compressor.filters.css_default.CssAbsoluteFilter", + "compressor.filters.cssmin.rCSSMinFilter", + ], + "js": ["compressor.filters.jsmin.rJSMinFilter"], +} +# Pin the alias name: django-compressor 4.6 defaults to the misspelled "compressor-offine". +# This bug was fixed upstream in #1325, remove once a release includes it +COMPRESS_OFFLINE_MANIFEST_STORAGE_ALIAS = "compressor-offline" + IZAYA_CLASSES = { "input": "input", "chip": "tag is-link is-medium", @@ -433,11 +450,25 @@ STORAGES = { "default": {"BACKEND": "metron.storage_backends.MediaStorage"}, "staticfiles": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, + # Compressed bundles and the offline manifest live alongside the other static files. + "compressor": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, + "compressor-offline": {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}, } STATICFILES_DIRS = (BASE_DIR / "static",) STATIC_URL = f"{AWS_S3_CUSTOM_DOMAIN}/{AWS_LOCATION}/" + # S3Boto3Storage.url() emits absolute https:// URLs, and compressor only bundles files + # whose URL starts with COMPRESS_URL, so this must include the scheme (STATIC_URL doesn't). + COMPRESS_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/{AWS_LOCATION}/" + # There is no STATIC_ROOT in production; compressor only reads source files from here + # (falling back to the staticfiles finders) and writes its output to S3. + COMPRESS_ROOT = BASE_DIR / "static" + # One manifest per release (METRON_RELEASE is baked into the image at build time), so + # running `compress` for a new release never changes the manifest the old containers read, + # and rolling back to an earlier image finds that image's manifest still in place. + COMPRESS_OFFLINE_MANIFEST = f"CACHE/manifest-{config('METRON_RELEASE', default='dev')}.json" + else: STATICFILES_DIRS = (BASE_DIR / "static",) STATIC_URL = "/static/" diff --git a/pyproject.toml b/pyproject.toml index 33c857f5..28033473 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "chartkick", "disposable-email-domains>=0.0.281", "django", + "django-compressor>=4.6.0", "django-countries", "django-filter", "django-htmx", diff --git a/templates/base.html b/templates/base.html index 8e1311c9..dbe21176 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,4 +1,4 @@ -{% load static django_htmx i18n %} +{% load static django_htmx i18n compress %} @@ -34,14 +34,18 @@ {% load django_simple_bulma %} {% bulma %} {% font_awesome %} - - - + {% compress css %} + + + + {% endcompress %} {% block headcss %}{% endblock %} {# Head Scripts #} {% htmx_script %} {# Lookup widgets rely on this; it only adds document listeners, so every page can load it #} - + {% compress js %} + + {% endcompress %} {% block headscripts %}{% endblock %} ', content) + # Bulma itself is intentionally left outside the compress blocks. + assert '' in content + + +def test_assets_are_not_bundled_when_disabled(db, client, settings): + settings.COMPRESS_ENABLED = False + resp = client.get(reverse("home")) + content = resp.content.decode() + assert "site/css/base.css" in content + assert "/CACHE/css/" not in content diff --git a/timeline/templates/timeline.html b/timeline/templates/timeline.html index 80646320..ad3f522e 100644 --- a/timeline/templates/timeline.html +++ b/timeline/templates/timeline.html @@ -1,9 +1,11 @@ {% extends parent_template|default:"base.html" %} -{% load static i18n %} +{% load static i18n compress %} {% block title %}{% trans "Timeline" %}{% endblock %} {% block headcss %} - + {% compress css %} + + {% endcompress %} {% endblock %} {% block content %} diff --git a/user_collection/templates/user_collection/collection_detail.html b/user_collection/templates/user_collection/collection_detail.html index dd165ad2..a9b4fdf7 100644 --- a/user_collection/templates/user_collection/collection_detail.html +++ b/user_collection/templates/user_collection/collection_detail.html @@ -1,10 +1,12 @@ {% extends parent_template|default:"base.html" %} -{% load static i18n %} +{% load static i18n compress %} {% load thumbnail %} {% block title %}{% blocktrans with issue=item.issue %}{{ issue }} - My Collection - Metron{% endblocktrans %}{% endblock %} {% block headcss %} - + {% compress css %} + + {% endcompress %} {% endblock %} {% block content %}
@@ -196,7 +198,9 @@

{% trans "Collection Details" %}

{% endblock %} {% block js %} - + {% compress js %} + + {% endcompress %} + {% compress js %} + + {% endcompress %} + {% compress js %} + + {% endcompress %} - + {% compress js %} + + {% endcompress %} {% endblock %} {% block title %}{% trans "Collection Statistics - Metron" %}{% endblock %} {% block content %} diff --git a/uv.lock b/uv.lock index 6843b550..08f03b30 100644 --- a/uv.lock +++ b/uv.lock @@ -241,6 +241,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f2/adbe0352c6673f7b608724be5f69962c80ba1d06f8ed60fa4bfbec258b08/django-6.1.3-py3-none-any.whl", hash = "sha256:a0530e3fdac55d34ccc5893c36f6c57d7a7083064f3d414537e51473648b3290", size = 8424610, upload-time = "2026-11-03T15:21:19.64Z" }, ] +[[package]] +name = "django-appconf" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/a2/e58bec8d7941b914af52a67c35b5709eceed2caa2848f28437f1666ed668/django_appconf-1.2.0.tar.gz", hash = "sha256:15a88d60dd942d6059f467412fe4581db632ef03018a3c183fb415d6fc9e5cec", size = 16127, upload-time = "2025-11-08T15:46:27.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/e6/4c34d94dfb74bbcbc489606e61f1924933de30d22c593dd1f429f35fbd7f/django_appconf-1.2.0-py3-none-any.whl", hash = "sha256:b81bce5ef0ceb9d84df48dfb623a32235d941c78cc5e45dbb6947f154ea277f4", size = 6500, upload-time = "2025-11-08T15:46:25.957Z" }, +] + [[package]] name = "django-classy-tags" version = "4.1.0" @@ -253,6 +265,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/10/13afc12b7d9657ed67f8d49c1cf0585a4331b197ed98ea654e9ed8034e79/django_classy_tags-4.1.0-py3-none-any.whl", hash = "sha256:1c784cf1bac49c20a77b8f7d1541867c64076642a160a847ff449588d4e01e55", size = 14095, upload-time = "2023-07-29T09:06:16.951Z" }, ] +[[package]] +name = "django-compressor" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-appconf" }, + { name = "rcssmin" }, + { name = "rjsmin" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/e4/c6d87b1341d744ceafa85eeceb2adabb1c62b795b8207cbc580fb70df8f4/django_compressor-4.6.0.tar.gz", hash = "sha256:c7478feab98f3368780591f9ee28a433350f5277dd28811f7f710f5bc6dff3c0", size = 99735, upload-time = "2025-11-10T13:12:11.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/9d/9a0ba39f33574994e5b33aea55a68e8fad72b8dd923a82300e4e91774f59/django_compressor-4.6.0-py3-none-any.whl", hash = "sha256:6e7b21020a0d86272c5e37000c33accc4ebeb77394a3dd86d775a09aae7aade4", size = 96828, upload-time = "2025-11-10T13:12:10.001Z" }, +] + [[package]] name = "django-countries" version = "9.1.0" @@ -703,6 +730,7 @@ dependencies = [ { name = "chartkick" }, { name = "disposable-email-domains" }, { name = "django" }, + { name = "django-compressor" }, { name = "django-countries" }, { name = "django-filter" }, { name = "django-htmx" }, @@ -750,6 +778,7 @@ requires-dist = [ { name = "chartkick" }, { name = "disposable-email-domains", specifier = ">=0.0.281" }, { name = "django" }, + { name = "django-compressor", specifier = ">=4.6.0" }, { name = "django-countries" }, { name = "django-filter" }, { name = "django-htmx" }, @@ -1221,6 +1250,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rcssmin" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/71/a3f1836b88f557185ccfd38d156e149db24c276ac1280336ba967e656434/rcssmin-1.3.0.tar.gz", hash = "sha256:ff15a3890eb350f1aa9ec34998f914c4e2fb13f949496f7c25e807578281adcf", size = 588994, upload-time = "2026-10-10T16:31:39.247Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/e0/c8e2370fc04773bb1931132cb6311b54cf896c15b25f5e45f374ac8ea805/rcssmin-1.3.0-cp314-cp314-manylinux1_i686.whl", hash = "sha256:c753ba4216894ebe14d3e6a6f3b5d48a8d878d3094b5d718cae4ecaaa64972e4", size = 49000, upload-time = "2026-10-10T16:32:56.345Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2c/142a6d11ee58d93e108e5c7e1947ceb13a1d5b8824fddfd7cb3013580dea/rcssmin-1.3.0-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:4c38da10a9717db10595ba0c94803bccd78ed72948b2222b815c76053d5e2f96", size = 49491, upload-time = "2026-10-10T16:32:58.399Z" }, + { url = "https://files.pythonhosted.org/packages/be/25/cccf8ee7d7157eec5f06b52247adce26459ec39c06baaf025815c4d41931/rcssmin-1.3.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:d2298258fdb42db6d0227d921b6b0d5daa2287f943b2a1ecd3eae69eba13010e", size = 50368, upload-time = "2026-10-10T16:33:00.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/45/49beae5d75470b31769dc439eb4cef8fbe83e8c2dddcb2545a8fe0429a2d/rcssmin-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d8173243493ac101f48edcfd1315225d22f3a0f4248bdcd51093e6c67a7e6944", size = 51001, upload-time = "2026-10-10T16:33:02.023Z" }, + { url = "https://files.pythonhosted.org/packages/fd/92/65ccd21bdbdecf48be43b1a007ac6139b8562f0f73ad9fcdce6f2fa08931/rcssmin-1.3.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6de48f314f075d528561bceb12929cc0a23fc4dc9796588a35834cb05c21fa59", size = 52493, upload-time = "2026-10-10T16:33:03.417Z" }, + { url = "https://files.pythonhosted.org/packages/42/5f/bf037b4077637328776cd996cc5f67bed7513495c1badbd9de53c191bf32/rcssmin-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:564960a8efbd2841b3915f94eaab16503d41704998bd069660f96aed6b6eedc8", size = 50574, upload-time = "2026-10-10T16:33:05.018Z" }, + { url = "https://files.pythonhosted.org/packages/c9/08/20a21df9ce56a0ea073e9f3ed84134269522bb8353b08d2b47dca13580cd/rcssmin-1.3.0-cp314-cp314t-manylinux1_i686.whl", hash = "sha256:867ea50fa3b43c145f660addc3266df52a6998a48fcbb8b088dd4576c0770215", size = 51224, upload-time = "2026-10-10T16:33:06.261Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/331939cfb686f8d94405805cf08317270d55390f1612a541fecc0d035745/rcssmin-1.3.0-cp314-cp314t-manylinux1_x86_64.whl", hash = "sha256:952637cbd2e982bf0777950d3a2545856aa9d861633e2d3bb3ca400a1930b1e5", size = 51578, upload-time = "2026-10-10T16:33:07.622Z" }, + { url = "https://files.pythonhosted.org/packages/05/fa/c5a26de2512a906edfbe034b2c302bac4b00155d504a610b2db552c5bcd8/rcssmin-1.3.0-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:4d47ccfc075cd276ebc9b98471e6db80c9bb248a6e31cf5932c260b23c5e5676", size = 53336, upload-time = "2026-10-10T16:33:08.974Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/d553a5fd908af1d0be71f30e702884c7c10e061f289b90cdf865fc7b8c69/rcssmin-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:13cfa028fc795749a58461ecda3c87fd92b0f3dafec2163918c6d7dd4a8a1f3c", size = 53091, upload-time = "2026-10-10T16:33:10.441Z" }, + { url = "https://files.pythonhosted.org/packages/9a/31/2dcac8a788acd8ffd6224f1041615e9924b88939d70918aff979c1b53b31/rcssmin-1.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:43e8134f207b9355566ccbd0d0efac07bd5de62717b9441936e793b796b9e9be", size = 54264, upload-time = "2026-10-10T16:33:11.733Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9d/a3c5c85b7542fdc0af89475ca320aece91d31eb895285301b0c440fd2bbc/rcssmin-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f7f16a4bfc863853c3058bdf95b5a1dcbbb02fdcbba8528a2e93d5eff8b9f153", size = 52422, upload-time = "2026-10-10T16:33:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/51/b4/bec3a45790bfcfeb73861d988459bf3b9d08a7e0b1b35e518aeb0478a81e/rcssmin-1.3.0-cp315-cp315-manylinux1_i686.whl", hash = "sha256:955fe49c56fa76249d93c810ade487b640a11d6cfd3f648c4b3824056ed6d79a", size = 50388, upload-time = "2026-10-10T16:33:14.44Z" }, + { url = "https://files.pythonhosted.org/packages/23/f7/b3fdd27476d3747bd2974a62be8e64db00aabe0d7f7c8cc2e72ff9fff13e/rcssmin-1.3.0-cp315-cp315-manylinux1_x86_64.whl", hash = "sha256:f2dcccf95def8453d75116ed219638ba8e54a10de9f6691fed70212886aec9f9", size = 50000, upload-time = "2026-10-10T16:33:15.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/2a/01344b88dd52c3a9cd44ac53da74e406b7d9ecb919842a946feb660d2bb9/rcssmin-1.3.0-cp315-cp315-manylinux2014_aarch64.whl", hash = "sha256:b715c445a02d2ddb2131de7b72171c61f750d48d9279289c6f91857b6ee27728", size = 51100, upload-time = "2026-10-10T16:33:17.17Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f8/1431f85f13bc95dc1d6017dcaec15d0d93209830500de850a6967ed62f5b/rcssmin-1.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9c85b3aebec2107a709e6b56c4d28bc670f2367ccb341cc70ca7914dc00a7cca", size = 51285, upload-time = "2026-10-10T16:33:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6b/c7d1c8cd637fdeebe67cbf73f1895b6c628366fe2e1f8a5b8fc316c7ae52/rcssmin-1.3.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:97b4c9fcf98db91f987fdf885ee530fbc94b01d296214f766c20594f8d088f99", size = 52488, upload-time = "2026-10-10T16:33:19.946Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0e/d79534b429638c04229b954b14d70690b5a88abd4e9b1cbabe65b3c43d53/rcssmin-1.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:aae81d6b8be707c7564aa5e82656b77be04af138826ad76b0b83c9a5fc3286cb", size = 50850, upload-time = "2026-10-10T16:33:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e02bf1c285137c2dd0fe04b929b7d1ce78d822f30dec5a622dd464d0aae1/rcssmin-1.3.0-cp315-cp315t-manylinux1_i686.whl", hash = "sha256:29c63e2a1e4d5e5b361b4b63895f7fac01fc8842e25243ad4296f7e4e24bf540", size = 52231, upload-time = "2026-10-10T16:33:22.682Z" }, + { url = "https://files.pythonhosted.org/packages/51/4a/fafb8493d31d7963b265931d64d712a92039a2c04fdbc5ebac7ea3ecf432/rcssmin-1.3.0-cp315-cp315t-manylinux1_x86_64.whl", hash = "sha256:387a4b1c71c61eb052e8cb154811ad791ec2d95e9f5e55017e250e321cf17840", size = 51772, upload-time = "2026-10-10T16:33:24.003Z" }, + { url = "https://files.pythonhosted.org/packages/68/85/a3e0b5023eb8f488095a533a7605f0130d2427920c4596ef004f8d141776/rcssmin-1.3.0-cp315-cp315t-manylinux2014_aarch64.whl", hash = "sha256:95d565b931321f3d9fddad5c68bda212f0f691b513243a67dc3ef6874f4636f9", size = 53391, upload-time = "2026-10-10T16:33:25.792Z" }, + { url = "https://files.pythonhosted.org/packages/4b/28/5e4c858d32903285df702fb9794699f1683ae629300c4a7438cf1d9a2fbb/rcssmin-1.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a344fa602072a57fae1066a8417d862f79ad1f6d6ad29ecfd091cb754d1ef71c", size = 53209, upload-time = "2026-10-10T16:33:27.385Z" }, + { url = "https://files.pythonhosted.org/packages/7b/97/8fc790fc714ba4a7b77d323a8f045a38c3da333cbe553cca0f540a70cfd2/rcssmin-1.3.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:b63c3bb729c8bc7a9b69985453441cf629a4fe3beeda496425976cd2e1204360", size = 54011, upload-time = "2026-10-10T16:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/18916aa35f6350159e974ed8cb4a2ca87e6f2ca34ff1a826c24414179553/rcssmin-1.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:76af331d361770dd0d91309f7bb91272e024e70f63112cec9a180d2be9003c38", size = 52490, upload-time = "2026-10-10T16:33:30.279Z" }, +] + [[package]] name = "redis" version = "8.1.0" @@ -1315,6 +1376,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/bc/97b2245c8c7b2dd01f2db74f2bea003cd33c15009b4996a2447f46b5325c/regex-2026.9.29-cp315-cp315t-win_arm64.whl", hash = "sha256:e8c65ef3862a8ad6e86492b6ed9327805dd66904c012bd3649dc67d822ed6c34", size = 285955, upload-time = "2026-09-29T00:49:55.655Z" }, ] +[[package]] +name = "rjsmin" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/7e/1a5e8fa9cf68e9147b4bc041e247783117a9d100cdec91d0efaea785d035/rjsmin-1.3.0.tar.gz", hash = "sha256:7c2ef57d55e2d76db0c0d0f7399c6c5efde995c677b190ba30fb94019f94a07e", size = 427569, upload-time = "2026-10-10T16:32:12.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/37/1f7dcaf0834a0a8d6f7dbcd5fe15447cc4cbd475b152a0acfc7fcf2adda9/rjsmin-1.3.0-cp314-cp314-manylinux1_i686.whl", hash = "sha256:bab857bc74fd2c0f70b16d44a3ffdc9814230afcea495a40b3c217e931b42220", size = 31988, upload-time = "2026-10-10T16:33:08.247Z" }, + { url = "https://files.pythonhosted.org/packages/c8/5e/a4b061e5c797b08832fc1a0e03ff79cbca8c5f1ab34f46313f5686420ef1/rjsmin-1.3.0-cp314-cp314-manylinux1_x86_64.whl", hash = "sha256:cd4a2ee73a7e012cbf3a5c11708c1e2f57f555457d0cae099adcee8101ebebf1", size = 31997, upload-time = "2026-10-10T16:33:09.638Z" }, + { url = "https://files.pythonhosted.org/packages/58/28/33b57831776d2081b6025bd0824cb7ba167c9cb604ffeb2cc8e152450d56/rjsmin-1.3.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:ea98b441cca662185e18de95cbd5ea7b522f6ced60dde201335d1473c06dd7fa", size = 32426, upload-time = "2026-10-10T16:33:11.046Z" }, + { url = "https://files.pythonhosted.org/packages/b3/26/b7bfbe285f6c379b14621929f22b0b31732ef9e7dc892b13fba58f01d910/rjsmin-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c7bab8e15dc8f555dc0b306f37fe28579a46ce43ac7efcf0702450467914c5f0", size = 32919, upload-time = "2026-10-10T16:33:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/e9655ecbd79a6c6c0078a14da5376228ce647148660107cd5696b4702394/rjsmin-1.3.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:40454fd01b8acd039233f2e11e85204b0d3e591dfe7cf1e777b71119e458ae78", size = 32955, upload-time = "2026-10-10T16:33:13.727Z" }, + { url = "https://files.pythonhosted.org/packages/2a/65/19894478636ea166a54251e4cf00b23a23a8f2484a145e1d2e72863ced67/rjsmin-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc79f06230db0061d5245094e81bed7be55bdc9b5a383b35d6068e45917215ea", size = 32463, upload-time = "2026-10-10T16:33:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/74/83/4f1054e5a6de03894381fbf6545c2cd1d50a4f0ddeed05560edbbd61bf48/rjsmin-1.3.0-cp314-cp314t-manylinux1_i686.whl", hash = "sha256:c0a7e58b3f65865f4e9925449d81db8242233066c276fc17a34764cc2cdb9cd7", size = 34119, upload-time = "2026-10-10T16:33:16.506Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/95adcdd99d3d006e373f6c6a246a469d9953ded9aa5a08f77f81c6f7f790/rjsmin-1.3.0-cp314-cp314t-manylinux1_x86_64.whl", hash = "sha256:4cc7ac80adb33e53c598c9f1afe4b390d3b6631fc9a2b05dabdce9f5400fda1f", size = 33960, upload-time = "2026-10-10T16:33:17.934Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8c/238c9e15495726419f44ca48747d3acdaebc53f8693140f3e03e6be73d2b/rjsmin-1.3.0-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:a8a41fa57ef5b3c930bdd42cd62f18807a7b088064280bab376e9a5ca328d4e1", size = 34595, upload-time = "2026-10-10T16:33:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/69/23/0181994478008cbbb67a1c46e4481330d53821c8e8b72578b74782e4a634/rjsmin-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:67690b4bbe8c39cf21362fe3ae389169133a9787b9192244e4459e13835f1711", size = 34842, upload-time = "2026-10-10T16:33:20.587Z" }, + { url = "https://files.pythonhosted.org/packages/12/0f/b3bcb118b86fa8dd6a592b673886fbd2dd948ecf39f629697586989ee234/rjsmin-1.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d473f9e2d855d5578f8579bf8dc58b16170c7e14b833e1f3e392c621b3dc588e", size = 34690, upload-time = "2026-10-10T16:33:21.931Z" }, + { url = "https://files.pythonhosted.org/packages/e8/df/a0a5a79707c867973f358fac3df6c155a03f22a40ad81e4c4194ce67ab59/rjsmin-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:303f021ea53064b86f090303b6a28217aa08ed89e25da62c45bdb3d0ac121bf6", size = 34159, upload-time = "2026-10-10T16:33:23.317Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5a/acad8dbac532c113eafc9bde01cf3b556b18762a5dd3fcf62c7c04956da2/rjsmin-1.3.0-cp315-cp315-manylinux1_i686.whl", hash = "sha256:719b949efea978e435ff22447f9dd8004f680862ee1d9d559151c966d67ca50f", size = 32520, upload-time = "2026-10-10T16:33:25.063Z" }, + { url = "https://files.pythonhosted.org/packages/00/00/48631d59fabbffde8a21a9494422a9d1617e1dac17ad31058a96609c611b/rjsmin-1.3.0-cp315-cp315-manylinux1_x86_64.whl", hash = "sha256:bb223344438e77d74c5e41d5a07fb754c42e9b04bab0c004d08ca6022c885d72", size = 32167, upload-time = "2026-10-10T16:33:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/fd/81/1977433e16146575269bc81ab118bcc4012a3814ae1787450dd12d03927e/rjsmin-1.3.0-cp315-cp315-manylinux2014_aarch64.whl", hash = "sha256:da4961eb74c563094e931f7d09bf2fbd12d1690ec567a6fbea3964e5a142b80e", size = 32690, upload-time = "2026-10-10T16:33:27.983Z" }, + { url = "https://files.pythonhosted.org/packages/77/7b/d45832af516bc9fae2bbdd929be97a3edfdf7ba30e3c351bb60c092a4237/rjsmin-1.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:30625ba457151b52f7a262169187f0bf1def5e25418381282a0891a560afc0e0", size = 33235, upload-time = "2026-10-10T16:33:29.59Z" }, + { url = "https://files.pythonhosted.org/packages/30/81/c1373e2bc61c21957474c13f42776c71c2dbebf06400f9a218c566b52d09/rjsmin-1.3.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:9d08552e90f5f6b7e79838a23190bc89ba6ccbcad74b9cca923bfb4596d5415d", size = 33454, upload-time = "2026-10-10T16:33:30.94Z" }, + { url = "https://files.pythonhosted.org/packages/f6/35/c5f46e4cedaf95b414f6701c8cced668aa1328b4f588e27590ad3535ab70/rjsmin-1.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:adccd1027c095ad49408802a77ad030ad567a337d938031c42bbbccce22d93c8", size = 32819, upload-time = "2026-10-10T16:33:32.294Z" }, + { url = "https://files.pythonhosted.org/packages/e1/20/7af2475fa7a6ce3fde9ccdd40ff31b489d633f6b76a87664691a66d14dac/rjsmin-1.3.0-cp315-cp315t-manylinux1_i686.whl", hash = "sha256:a49363b26e4fa35f4a56f1a0102bcb81e0502ad98d0802cc0eabee54c38a5a3a", size = 34172, upload-time = "2026-10-10T16:33:33.634Z" }, + { url = "https://files.pythonhosted.org/packages/c6/79/bbaacb8e52691c2c4eac47cf1e03cd124b28d77328f99d366c282da97396/rjsmin-1.3.0-cp315-cp315t-manylinux1_x86_64.whl", hash = "sha256:9fb12bc2939e2037c4c1fa36dffd46229f0a6c9ca7e5a18e7ff4841bc7f3f47b", size = 33823, upload-time = "2026-10-10T16:33:35.255Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6c/7e3bf4a66bea608b805a6cb80ab497356d38f4929bf28e33b28a0246e910/rjsmin-1.3.0-cp315-cp315t-manylinux2014_aarch64.whl", hash = "sha256:4eaed13693f43b52ced8266923d56c9e03c11fc788a834312ea3b498cc80871c", size = 34647, upload-time = "2026-10-10T16:33:36.652Z" }, + { url = "https://files.pythonhosted.org/packages/37/25/f924b49524e3e2dbd9f577c3eb2a3533862803a15c14bd4fef196f1c3b5a/rjsmin-1.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9dbda7b1423b7e50590dc60aee22bdf14c51b52edc2f23823ced8e7e054a1cd7", size = 34815, upload-time = "2026-10-10T16:33:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/68/43/e06b06b5ada1c62a0527896d43cd7c5b896a5d419f49fb1b4079526c07c5/rjsmin-1.3.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:5e957e788256bd23141786e6646bc2062b7fa78de6f4eb8b155f47a54524c990", size = 34966, upload-time = "2026-10-10T16:33:39.336Z" }, + { url = "https://files.pythonhosted.org/packages/a9/9c/1ecf761d5a9cdf1610d90a9c42710680773788eb5b178196ddaf81fec85b/rjsmin-1.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:bc0d1f930dfb64195394d121a746431674a310a26a3205423b8236a6144192a4", size = 34267, upload-time = "2026-10-10T16:33:40.65Z" }, +] + [[package]] name = "rpds-py" version = "2026.9.1"