django-vite-rolling 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. django_vite_rolling-0.1.0/.github/workflows/release.yml +37 -0
  2. django_vite_rolling-0.1.0/.github/workflows/test.yml +29 -0
  3. django_vite_rolling-0.1.0/.gitignore +32 -0
  4. django_vite_rolling-0.1.0/LICENSE +21 -0
  5. django_vite_rolling-0.1.0/PKG-INFO +132 -0
  6. django_vite_rolling-0.1.0/README.md +97 -0
  7. django_vite_rolling-0.1.0/pyproject.toml +62 -0
  8. django_vite_rolling-0.1.0/setup.cfg +4 -0
  9. django_vite_rolling-0.1.0/src/django_vite_rolling/__init__.py +1 -0
  10. django_vite_rolling-0.1.0/src/django_vite_rolling/apps.py +6 -0
  11. django_vite_rolling-0.1.0/src/django_vite_rolling/conf.py +30 -0
  12. django_vite_rolling-0.1.0/src/django_vite_rolling/management/__init__.py +0 -0
  13. django_vite_rolling-0.1.0/src/django_vite_rolling/management/commands/__init__.py +0 -0
  14. django_vite_rolling-0.1.0/src/django_vite_rolling/management/commands/refresh_vite_manifest.py +57 -0
  15. django_vite_rolling-0.1.0/src/django_vite_rolling/manifest.py +32 -0
  16. django_vite_rolling-0.1.0/src/django_vite_rolling/templatetags/__init__.py +0 -0
  17. django_vite_rolling-0.1.0/src/django_vite_rolling/templatetags/vite.py +75 -0
  18. django_vite_rolling-0.1.0/src/django_vite_rolling.egg-info/PKG-INFO +132 -0
  19. django_vite_rolling-0.1.0/src/django_vite_rolling.egg-info/SOURCES.txt +27 -0
  20. django_vite_rolling-0.1.0/src/django_vite_rolling.egg-info/dependency_links.txt +1 -0
  21. django_vite_rolling-0.1.0/src/django_vite_rolling.egg-info/requires.txt +7 -0
  22. django_vite_rolling-0.1.0/src/django_vite_rolling.egg-info/top_level.txt +1 -0
  23. django_vite_rolling-0.1.0/tests/__init__.py +0 -0
  24. django_vite_rolling-0.1.0/tests/conftest.py +31 -0
  25. django_vite_rolling-0.1.0/tests/settings.py +43 -0
  26. django_vite_rolling-0.1.0/tests/test_conf.py +37 -0
  27. django_vite_rolling-0.1.0/tests/test_manifest.py +35 -0
  28. django_vite_rolling-0.1.0/tests/test_refresh_command.py +67 -0
  29. django_vite_rolling-0.1.0/tests/test_templatetags.py +55 -0
@@ -0,0 +1,37 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ build:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ with:
13
+ fetch-depth: 0 # setuptools-scm needs full history for version
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.12"
17
+ - name: Build
18
+ run: |
19
+ python -m pip install -U pip build
20
+ python -m build
21
+ - uses: actions/upload-artifact@v4
22
+ with:
23
+ name: dist
24
+ path: dist/
25
+
26
+ publish:
27
+ needs: build
28
+ runs-on: ubuntu-latest
29
+ environment: pypi
30
+ permissions:
31
+ id-token: write # required for PyPI trusted publishing
32
+ steps:
33
+ - uses: actions/download-artifact@v4
34
+ with:
35
+ name: dist
36
+ path: dist/
37
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,29 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python: ["3.11", "3.12", "3.13"]
15
+ django: ["4.2", "5.0", "5.1"]
16
+ exclude:
17
+ - python: "3.13"
18
+ django: "4.2"
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python }}
24
+ - name: Install
25
+ run: |
26
+ python -m pip install -U pip
27
+ python -m pip install "Django~=${{ matrix.django }}.0" -e ".[test]"
28
+ - name: Run tests
29
+ run: pytest -v
@@ -0,0 +1,32 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ *.egg
7
+ .eggs/
8
+ build/
9
+ dist/
10
+ .tox/
11
+ .nox/
12
+ .coverage
13
+ .coverage.*
14
+ htmlcov/
15
+ coverage.xml
16
+ .pytest_cache/
17
+ .mypy_cache/
18
+ .ruff_cache/
19
+
20
+ # Virtualenvs
21
+ .venv/
22
+ venv/
23
+ env/
24
+
25
+ # Editors
26
+ .idea/
27
+ .vscode/
28
+ *.swp
29
+ *.swo
30
+
31
+ # OS
32
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wedgworth's, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-vite-rolling
3
+ Version: 0.1.0
4
+ Summary: Lean Django + Vite integration with rolling-deploy-safe manifest caching.
5
+ Author-email: Patrick Altman <patrick@wedgworth.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/wedgworth/django-vite-rolling
8
+ Project-URL: Issues, https://github.com/wedgworth/django-vite-rolling/issues
9
+ Project-URL: Source, https://github.com/wedgworth/django-vite-rolling
10
+ Keywords: django,vite,manifest,rolling-deploy
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: Django :: 4.2
15
+ Classifier: Framework :: Django :: 5.0
16
+ Classifier: Framework :: Django :: 5.1
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Internet :: WWW/HTTP
25
+ Requires-Python: >=3.11
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: django>=4.2
29
+ Requires-Dist: django-redis>=5.0
30
+ Provides-Extra: test
31
+ Requires-Dist: pytest>=7; extra == "test"
32
+ Requires-Dist: pytest-django>=4; extra == "test"
33
+ Requires-Dist: fakeredis>=2; extra == "test"
34
+ Dynamic: license-file
35
+
36
+ # django-vite-rolling
37
+
38
+ A lean Django + Vite integration with rolling-deploy-safe manifest caching.
39
+
40
+ Roughly 100 lines of code. No React Refresh, no polyfills, no legacy bundle handling — just `{% vite_scripts %}`, `{% vite_styles %}`, and a management command that keeps Vite manifest caches consistent across rolling deploys.
41
+
42
+ ## Why this exists
43
+
44
+ Most Django + Vite integrations cache one manifest globally. On a rolling deploy, both the old and new app versions can serve requests simultaneously, each needing their own manifest. This package caches manifests under versioned Redis keys (`vite_manifest:<RELEASE_VERSION>`) and provides a management command to prune stale versions during deploys.
45
+
46
+ If you don't need versioned/rolling-deploy support, you probably want [`django-vite`](https://github.com/MrBin99/django-vite) instead.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install django-vite-rolling
52
+ ```
53
+
54
+ Add to `INSTALLED_APPS`:
55
+
56
+ ```python
57
+ INSTALLED_APPS = [
58
+ # ...
59
+ "django_vite_rolling",
60
+ ]
61
+ ```
62
+
63
+ Requires `django-redis` configured as your default cache backend.
64
+
65
+ ## Configure
66
+
67
+ ```python
68
+ VITE = {
69
+ "manifest_path": BASE_DIR / "static" / ".vite" / "manifest.json",
70
+ "cache": not DEBUG,
71
+ }
72
+ ```
73
+
74
+ ### All settings
75
+
76
+ | Key | Default | Description |
77
+ |---|---|---|
78
+ | `manifest_path` | *(required)* | Path to Vite's `manifest.json` |
79
+ | `cache` | `True` | Whether to cache the loaded manifest in Redis (set `False` in dev) |
80
+ | `cache_key_prefix` | `"vite_manifest"` | Redis key prefix; full key is `<prefix>:<RELEASE_VERSION>` |
81
+ | `dev_server_host` | `None` | Dev server host. `None` → derive from request `Host` header (fallback `localhost`) |
82
+ | `dev_server_port` | `3001` | Dev server port |
83
+ | `dev_server_static_path` | `"/static"` | Path prefix Vite serves from |
84
+ | `versions_to_keep` | `5` | How many recent release versions to retain manifests for |
85
+ | `version_setting` | `"RELEASE_VERSION"` | Name of the Django setting holding the current release identifier |
86
+ | `versions_redis_key` | `"recent-manifest-versions"` | Redis list key tracking recent versions |
87
+ | `redis_alias` | `"default"` | django-redis alias to use |
88
+
89
+ ## Usage
90
+
91
+ In your base template:
92
+
93
+ ```html
94
+ {% load vite %}
95
+ <!DOCTYPE html>
96
+ <html>
97
+ <head>
98
+ {% vite_styles "src/main.ts" %}
99
+ </head>
100
+ <body>
101
+ {% vite_scripts "src/main.ts" %}
102
+ </body>
103
+ </html>
104
+ ```
105
+
106
+ In `DEBUG` mode the tags inject Vite's HMR client and module URLs pointing at the dev server. In production they resolve the named entries through the manifest, including recursively-imported chunks and their CSS.
107
+
108
+ ## Post-deploy
109
+
110
+ Run after each deploy (e.g., in a release-phase / post-deploy hook):
111
+
112
+ ```bash
113
+ python manage.py refresh_vite_manifest
114
+ ```
115
+
116
+ This:
117
+ 1. Records `RELEASE_VERSION` in a Redis list of recent versions (truncated to `versions_to_keep`).
118
+ 2. Scans for `vite_manifest:*` keys whose suffix is not in the recent list and deletes them.
119
+ 3. Loads the current manifest and caches it under `vite_manifest:<RELEASE_VERSION>`.
120
+
121
+ If `RELEASE_VERSION` is empty, the command caches the manifest under the bare prefix and skips cleanup.
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ pip install -e ".[test]"
127
+ pytest
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,97 @@
1
+ # django-vite-rolling
2
+
3
+ A lean Django + Vite integration with rolling-deploy-safe manifest caching.
4
+
5
+ Roughly 100 lines of code. No React Refresh, no polyfills, no legacy bundle handling — just `{% vite_scripts %}`, `{% vite_styles %}`, and a management command that keeps Vite manifest caches consistent across rolling deploys.
6
+
7
+ ## Why this exists
8
+
9
+ Most Django + Vite integrations cache one manifest globally. On a rolling deploy, both the old and new app versions can serve requests simultaneously, each needing their own manifest. This package caches manifests under versioned Redis keys (`vite_manifest:<RELEASE_VERSION>`) and provides a management command to prune stale versions during deploys.
10
+
11
+ If you don't need versioned/rolling-deploy support, you probably want [`django-vite`](https://github.com/MrBin99/django-vite) instead.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install django-vite-rolling
17
+ ```
18
+
19
+ Add to `INSTALLED_APPS`:
20
+
21
+ ```python
22
+ INSTALLED_APPS = [
23
+ # ...
24
+ "django_vite_rolling",
25
+ ]
26
+ ```
27
+
28
+ Requires `django-redis` configured as your default cache backend.
29
+
30
+ ## Configure
31
+
32
+ ```python
33
+ VITE = {
34
+ "manifest_path": BASE_DIR / "static" / ".vite" / "manifest.json",
35
+ "cache": not DEBUG,
36
+ }
37
+ ```
38
+
39
+ ### All settings
40
+
41
+ | Key | Default | Description |
42
+ |---|---|---|
43
+ | `manifest_path` | *(required)* | Path to Vite's `manifest.json` |
44
+ | `cache` | `True` | Whether to cache the loaded manifest in Redis (set `False` in dev) |
45
+ | `cache_key_prefix` | `"vite_manifest"` | Redis key prefix; full key is `<prefix>:<RELEASE_VERSION>` |
46
+ | `dev_server_host` | `None` | Dev server host. `None` → derive from request `Host` header (fallback `localhost`) |
47
+ | `dev_server_port` | `3001` | Dev server port |
48
+ | `dev_server_static_path` | `"/static"` | Path prefix Vite serves from |
49
+ | `versions_to_keep` | `5` | How many recent release versions to retain manifests for |
50
+ | `version_setting` | `"RELEASE_VERSION"` | Name of the Django setting holding the current release identifier |
51
+ | `versions_redis_key` | `"recent-manifest-versions"` | Redis list key tracking recent versions |
52
+ | `redis_alias` | `"default"` | django-redis alias to use |
53
+
54
+ ## Usage
55
+
56
+ In your base template:
57
+
58
+ ```html
59
+ {% load vite %}
60
+ <!DOCTYPE html>
61
+ <html>
62
+ <head>
63
+ {% vite_styles "src/main.ts" %}
64
+ </head>
65
+ <body>
66
+ {% vite_scripts "src/main.ts" %}
67
+ </body>
68
+ </html>
69
+ ```
70
+
71
+ In `DEBUG` mode the tags inject Vite's HMR client and module URLs pointing at the dev server. In production they resolve the named entries through the manifest, including recursively-imported chunks and their CSS.
72
+
73
+ ## Post-deploy
74
+
75
+ Run after each deploy (e.g., in a release-phase / post-deploy hook):
76
+
77
+ ```bash
78
+ python manage.py refresh_vite_manifest
79
+ ```
80
+
81
+ This:
82
+ 1. Records `RELEASE_VERSION` in a Redis list of recent versions (truncated to `versions_to_keep`).
83
+ 2. Scans for `vite_manifest:*` keys whose suffix is not in the recent list and deletes them.
84
+ 3. Loads the current manifest and caches it under `vite_manifest:<RELEASE_VERSION>`.
85
+
86
+ If `RELEASE_VERSION` is empty, the command caches the manifest under the bare prefix and skips cleanup.
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ pip install -e ".[test]"
92
+ pytest
93
+ ```
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "setuptools-scm>=8"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "django-vite-rolling"
7
+ description = "Lean Django + Vite integration with rolling-deploy-safe manifest caching."
8
+ readme = "README.md"
9
+ authors = [{ name = "Patrick Altman", email = "patrick@wedgworth.com" }]
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.11"
12
+ keywords = ["django", "vite", "manifest", "rolling-deploy"]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Environment :: Web Environment",
16
+ "Framework :: Django",
17
+ "Framework :: Django :: 4.2",
18
+ "Framework :: Django :: 5.0",
19
+ "Framework :: Django :: 5.1",
20
+ "Intended Audience :: Developers",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Topic :: Internet :: WWW/HTTP",
28
+ ]
29
+ dependencies = [
30
+ "django>=4.2",
31
+ "django-redis>=5.0",
32
+ ]
33
+ dynamic = ["version"]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/wedgworth/django-vite-rolling"
37
+ Issues = "https://github.com/wedgworth/django-vite-rolling/issues"
38
+ Source = "https://github.com/wedgworth/django-vite-rolling"
39
+
40
+ [project.optional-dependencies]
41
+ test = [
42
+ "pytest>=7",
43
+ "pytest-django>=4",
44
+ "fakeredis>=2",
45
+ ]
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
49
+
50
+ [tool.setuptools_scm]
51
+
52
+ [tool.pytest.ini_options]
53
+ DJANGO_SETTINGS_MODULE = "tests.settings"
54
+ python_files = ["test_*.py"]
55
+ pythonpath = ["."]
56
+
57
+ [tool.ruff]
58
+ line-length = 120
59
+ target-version = "py311"
60
+
61
+ [tool.ruff.lint]
62
+ select = ["E", "F", "I", "W", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ default_app_config = "django_vite_rolling.apps.DjangoViteRollingConfig"
@@ -0,0 +1,6 @@
1
+ from django.apps import AppConfig
2
+
3
+
4
+ class DjangoViteRollingConfig(AppConfig):
5
+ name = "django_vite_rolling"
6
+ verbose_name = "Django Vite Rolling"
@@ -0,0 +1,30 @@
1
+ from django.conf import settings
2
+
3
+
4
+ DEFAULTS = {
5
+ "manifest_path": None,
6
+ "cache": True,
7
+ "cache_key_prefix": "vite_manifest",
8
+ "dev_server_host": None,
9
+ "dev_server_port": 3001,
10
+ "dev_server_static_path": "/static",
11
+ "versions_to_keep": 5,
12
+ "version_setting": "RELEASE_VERSION",
13
+ "versions_redis_key": "recent-manifest-versions",
14
+ "redis_alias": "default",
15
+ }
16
+
17
+
18
+ def get_setting(key: str):
19
+ user_settings = getattr(settings, "VITE", {})
20
+ return user_settings.get(key, DEFAULTS[key])
21
+
22
+
23
+ def get_release_version() -> str:
24
+ return getattr(settings, get_setting("version_setting"), "") or ""
25
+
26
+
27
+ def get_cache_key() -> str:
28
+ prefix = get_setting("cache_key_prefix")
29
+ version = get_release_version()
30
+ return f"{prefix}:{version}" if version else prefix
@@ -0,0 +1,57 @@
1
+ from django.core.management import BaseCommand
2
+ from django_redis import get_redis_connection
3
+ from redis.exceptions import RedisError
4
+
5
+ from django_vite_rolling.conf import get_release_version, get_setting
6
+ from django_vite_rolling.manifest import set_manifest
7
+
8
+
9
+ class Command(BaseCommand):
10
+ help = "Refresh the Vite manifest cache and prune stale versions for rolling deploys."
11
+
12
+ def handle(self, *args, **options):
13
+ version = get_release_version()
14
+
15
+ if not version:
16
+ self.stdout.write(self.style.WARNING(
17
+ f"{get_setting('version_setting')} is empty; caching manifest without versioned cleanup."
18
+ ))
19
+ set_manifest()
20
+ self.stdout.write(self.style.SUCCESS("Vite manifest cached."))
21
+ return
22
+
23
+ versions_to_keep = get_setting("versions_to_keep")
24
+ prefix_match = f"{get_setting('cache_key_prefix')}:*"
25
+ versions_key = get_setting("versions_redis_key")
26
+ redis_alias = get_setting("redis_alias")
27
+
28
+ try:
29
+ client = get_redis_connection(redis_alias)
30
+ client.lpush(versions_key, version.encode("utf-8"))
31
+ if versions_to_keep:
32
+ client.ltrim(versions_key, 0, versions_to_keep - 1)
33
+
34
+ recent = {v.decode("utf-8") for v in client.lrange(versions_key, 0, -1)}
35
+ self.stdout.write(f"Recent versions: {sorted(recent)}")
36
+
37
+ deleted = 0
38
+ cursor = 0
39
+ while True:
40
+ cursor, keys = client.scan(cursor=cursor, match=prefix_match, count=100)
41
+ for key in keys:
42
+ key_str = key.decode("utf-8")
43
+ if not any(key_str.endswith(f":{v}") for v in recent):
44
+ client.delete(key)
45
+ deleted += 1
46
+ self.stdout.write(f"Deleted stale manifest key: {key_str}")
47
+ if cursor == 0:
48
+ break
49
+
50
+ self.stdout.write(self.style.SUCCESS(
51
+ f"Added '{version}' to recent versions; deleted {deleted} stale key(s)."
52
+ ))
53
+
54
+ set_manifest()
55
+ self.stdout.write(self.style.SUCCESS("Vite manifest cached."))
56
+ except RedisError as e:
57
+ self.stdout.write(self.style.ERROR(f"Redis error: {e}"))
@@ -0,0 +1,32 @@
1
+ import json
2
+ import typing
3
+
4
+ from django.core.cache import cache
5
+
6
+ from django_vite_rolling.conf import get_cache_key, get_setting
7
+
8
+
9
+ if typing.TYPE_CHECKING: # pragma: no cover
10
+ ChunkType = typing.TypedDict(
11
+ "ChunkType",
12
+ {"file": str, "css": list[str], "imports": list[str]},
13
+ total=False,
14
+ )
15
+ ManifestType = typing.Mapping[str, ChunkType]
16
+
17
+
18
+ def set_manifest() -> "ManifestType":
19
+ manifest_path = get_setting("manifest_path")
20
+ if not manifest_path:
21
+ raise RuntimeError("VITE['manifest_path'] is not configured")
22
+ with open(manifest_path) as fp:
23
+ manifest: ManifestType = json.load(fp)
24
+ cache.set(get_cache_key(), manifest, None)
25
+ return manifest
26
+
27
+
28
+ def get_manifest() -> "ManifestType":
29
+ if cached := cache.get(get_cache_key()):
30
+ if get_setting("cache"):
31
+ return cached
32
+ return set_manifest()
@@ -0,0 +1,75 @@
1
+ import re
2
+ import typing
3
+
4
+ from django import template
5
+ from django.conf import settings
6
+ from django.templatetags.static import static
7
+ from django.utils.safestring import mark_safe
8
+
9
+ from django_vite_rolling.conf import get_setting
10
+ from django_vite_rolling.manifest import get_manifest
11
+
12
+
13
+ if typing.TYPE_CHECKING: # pragma: no cover
14
+ from django.utils.safestring import SafeString
15
+
16
+
17
+ register = template.Library()
18
+
19
+
20
+ def _is_absolute_url(url: str) -> bool:
21
+ return re.match("^https?://", url) is not None
22
+
23
+
24
+ def _dev_server_root(request=None) -> str:
25
+ host = get_setting("dev_server_host")
26
+ if host is None:
27
+ host = "localhost"
28
+ if request is not None:
29
+ host = request.get_host().split(":")[0]
30
+ port = get_setting("dev_server_port")
31
+ static_path = get_setting("dev_server_static_path")
32
+ return f"http://{host}:{port}{static_path}"
33
+
34
+
35
+ def vite_manifest(entries_names: typing.Sequence[str], request=None) -> tuple[list[str], list[str]]:
36
+ if settings.DEBUG:
37
+ dev_root = _dev_server_root(request)
38
+ scripts = [f"{dev_root}/@vite/client"] + [f"{dev_root}/{name}" for name in entries_names]
39
+ return scripts, []
40
+
41
+ manifest = get_manifest()
42
+ seen: set[str] = set()
43
+
44
+ def _process(names: typing.Sequence[str]) -> tuple[list[str], list[str]]:
45
+ scripts: list[str] = []
46
+ styles: list[str] = []
47
+ for name in names:
48
+ if name in seen:
49
+ continue
50
+ chunk = manifest[name]
51
+ import_scripts, import_styles = _process(chunk.get("imports", []))
52
+ scripts.extend(import_scripts)
53
+ styles.extend(import_styles)
54
+ scripts.append(chunk["file"])
55
+ styles.extend(chunk.get("css", []))
56
+ seen.add(name)
57
+ return scripts, styles
58
+
59
+ return _process(entries_names)
60
+
61
+
62
+ @register.simple_tag(name="vite_styles", takes_context=True)
63
+ def vite_styles(context, *entries_names: str) -> "SafeString":
64
+ request = context.get("request")
65
+ _, styles = vite_manifest(entries_names, request=request)
66
+ hrefs = (href if _is_absolute_url(href) else static(href) for href in styles)
67
+ return mark_safe("\n".join(f'<link rel="stylesheet" href="{href}" />' for href in hrefs)) # nosec
68
+
69
+
70
+ @register.simple_tag(name="vite_scripts", takes_context=True)
71
+ def vite_scripts(context, *entries_names: str) -> "SafeString":
72
+ request = context.get("request")
73
+ scripts, _ = vite_manifest(entries_names, request=request)
74
+ srcs = (src if _is_absolute_url(src) else static(src) for src in scripts)
75
+ return mark_safe("\n".join(f'<script type="module" src="{src}"></script>' for src in srcs)) # nosec
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-vite-rolling
3
+ Version: 0.1.0
4
+ Summary: Lean Django + Vite integration with rolling-deploy-safe manifest caching.
5
+ Author-email: Patrick Altman <patrick@wedgworth.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/wedgworth/django-vite-rolling
8
+ Project-URL: Issues, https://github.com/wedgworth/django-vite-rolling/issues
9
+ Project-URL: Source, https://github.com/wedgworth/django-vite-rolling
10
+ Keywords: django,vite,manifest,rolling-deploy
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: Django :: 4.2
15
+ Classifier: Framework :: Django :: 5.0
16
+ Classifier: Framework :: Django :: 5.1
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: License :: OSI Approved :: MIT License
19
+ Classifier: Operating System :: OS Independent
20
+ Classifier: Programming Language :: Python :: 3
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Internet :: WWW/HTTP
25
+ Requires-Python: >=3.11
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: django>=4.2
29
+ Requires-Dist: django-redis>=5.0
30
+ Provides-Extra: test
31
+ Requires-Dist: pytest>=7; extra == "test"
32
+ Requires-Dist: pytest-django>=4; extra == "test"
33
+ Requires-Dist: fakeredis>=2; extra == "test"
34
+ Dynamic: license-file
35
+
36
+ # django-vite-rolling
37
+
38
+ A lean Django + Vite integration with rolling-deploy-safe manifest caching.
39
+
40
+ Roughly 100 lines of code. No React Refresh, no polyfills, no legacy bundle handling — just `{% vite_scripts %}`, `{% vite_styles %}`, and a management command that keeps Vite manifest caches consistent across rolling deploys.
41
+
42
+ ## Why this exists
43
+
44
+ Most Django + Vite integrations cache one manifest globally. On a rolling deploy, both the old and new app versions can serve requests simultaneously, each needing their own manifest. This package caches manifests under versioned Redis keys (`vite_manifest:<RELEASE_VERSION>`) and provides a management command to prune stale versions during deploys.
45
+
46
+ If you don't need versioned/rolling-deploy support, you probably want [`django-vite`](https://github.com/MrBin99/django-vite) instead.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install django-vite-rolling
52
+ ```
53
+
54
+ Add to `INSTALLED_APPS`:
55
+
56
+ ```python
57
+ INSTALLED_APPS = [
58
+ # ...
59
+ "django_vite_rolling",
60
+ ]
61
+ ```
62
+
63
+ Requires `django-redis` configured as your default cache backend.
64
+
65
+ ## Configure
66
+
67
+ ```python
68
+ VITE = {
69
+ "manifest_path": BASE_DIR / "static" / ".vite" / "manifest.json",
70
+ "cache": not DEBUG,
71
+ }
72
+ ```
73
+
74
+ ### All settings
75
+
76
+ | Key | Default | Description |
77
+ |---|---|---|
78
+ | `manifest_path` | *(required)* | Path to Vite's `manifest.json` |
79
+ | `cache` | `True` | Whether to cache the loaded manifest in Redis (set `False` in dev) |
80
+ | `cache_key_prefix` | `"vite_manifest"` | Redis key prefix; full key is `<prefix>:<RELEASE_VERSION>` |
81
+ | `dev_server_host` | `None` | Dev server host. `None` → derive from request `Host` header (fallback `localhost`) |
82
+ | `dev_server_port` | `3001` | Dev server port |
83
+ | `dev_server_static_path` | `"/static"` | Path prefix Vite serves from |
84
+ | `versions_to_keep` | `5` | How many recent release versions to retain manifests for |
85
+ | `version_setting` | `"RELEASE_VERSION"` | Name of the Django setting holding the current release identifier |
86
+ | `versions_redis_key` | `"recent-manifest-versions"` | Redis list key tracking recent versions |
87
+ | `redis_alias` | `"default"` | django-redis alias to use |
88
+
89
+ ## Usage
90
+
91
+ In your base template:
92
+
93
+ ```html
94
+ {% load vite %}
95
+ <!DOCTYPE html>
96
+ <html>
97
+ <head>
98
+ {% vite_styles "src/main.ts" %}
99
+ </head>
100
+ <body>
101
+ {% vite_scripts "src/main.ts" %}
102
+ </body>
103
+ </html>
104
+ ```
105
+
106
+ In `DEBUG` mode the tags inject Vite's HMR client and module URLs pointing at the dev server. In production they resolve the named entries through the manifest, including recursively-imported chunks and their CSS.
107
+
108
+ ## Post-deploy
109
+
110
+ Run after each deploy (e.g., in a release-phase / post-deploy hook):
111
+
112
+ ```bash
113
+ python manage.py refresh_vite_manifest
114
+ ```
115
+
116
+ This:
117
+ 1. Records `RELEASE_VERSION` in a Redis list of recent versions (truncated to `versions_to_keep`).
118
+ 2. Scans for `vite_manifest:*` keys whose suffix is not in the recent list and deletes them.
119
+ 3. Loads the current manifest and caches it under `vite_manifest:<RELEASE_VERSION>`.
120
+
121
+ If `RELEASE_VERSION` is empty, the command caches the manifest under the bare prefix and skips cleanup.
122
+
123
+ ## Development
124
+
125
+ ```bash
126
+ pip install -e ".[test]"
127
+ pytest
128
+ ```
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,27 @@
1
+ .gitignore
2
+ LICENSE
3
+ README.md
4
+ pyproject.toml
5
+ .github/workflows/release.yml
6
+ .github/workflows/test.yml
7
+ src/django_vite_rolling/__init__.py
8
+ src/django_vite_rolling/apps.py
9
+ src/django_vite_rolling/conf.py
10
+ src/django_vite_rolling/manifest.py
11
+ src/django_vite_rolling.egg-info/PKG-INFO
12
+ src/django_vite_rolling.egg-info/SOURCES.txt
13
+ src/django_vite_rolling.egg-info/dependency_links.txt
14
+ src/django_vite_rolling.egg-info/requires.txt
15
+ src/django_vite_rolling.egg-info/top_level.txt
16
+ src/django_vite_rolling/management/__init__.py
17
+ src/django_vite_rolling/management/commands/__init__.py
18
+ src/django_vite_rolling/management/commands/refresh_vite_manifest.py
19
+ src/django_vite_rolling/templatetags/__init__.py
20
+ src/django_vite_rolling/templatetags/vite.py
21
+ tests/__init__.py
22
+ tests/conftest.py
23
+ tests/settings.py
24
+ tests/test_conf.py
25
+ tests/test_manifest.py
26
+ tests/test_refresh_command.py
27
+ tests/test_templatetags.py
@@ -0,0 +1,7 @@
1
+ django>=4.2
2
+ django-redis>=5.0
3
+
4
+ [test]
5
+ pytest>=7
6
+ pytest-django>=4
7
+ fakeredis>=2
@@ -0,0 +1 @@
1
+ django_vite_rolling
File without changes
@@ -0,0 +1,31 @@
1
+ import json
2
+
3
+ import pytest
4
+ from django.core.cache import cache
5
+
6
+
7
+ SAMPLE_MANIFEST = {
8
+ "src/main.ts": {
9
+ "file": "assets/main-abc123.js",
10
+ "css": ["assets/main-abc123.css"],
11
+ "imports": ["_shared.js"],
12
+ },
13
+ "_shared.js": {
14
+ "file": "assets/shared-def456.js",
15
+ "css": ["assets/shared-def456.css"],
16
+ },
17
+ }
18
+
19
+
20
+ @pytest.fixture
21
+ def manifest_file(tmp_path):
22
+ path = tmp_path / "manifest.json"
23
+ path.write_text(json.dumps(SAMPLE_MANIFEST))
24
+ return path
25
+
26
+
27
+ @pytest.fixture(autouse=True)
28
+ def clear_cache():
29
+ cache.clear()
30
+ yield
31
+ cache.clear()
@@ -0,0 +1,43 @@
1
+ SECRET_KEY = "test"
2
+ DEBUG = False
3
+ ALLOWED_HOSTS = ["*"]
4
+
5
+ INSTALLED_APPS = [
6
+ "django.contrib.contenttypes",
7
+ "django.contrib.auth",
8
+ "django.contrib.staticfiles",
9
+ "django_vite_rolling",
10
+ ]
11
+
12
+ DATABASES = {
13
+ "default": {
14
+ "ENGINE": "django.db.backends.sqlite3",
15
+ "NAME": ":memory:",
16
+ }
17
+ }
18
+
19
+ CACHES = {
20
+ "default": {
21
+ "BACKEND": "django.core.cache.backends.locmem.LocMemCache",
22
+ },
23
+ }
24
+
25
+ TEMPLATES = [
26
+ {
27
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
28
+ "APP_DIRS": True,
29
+ "OPTIONS": {
30
+ "context_processors": [],
31
+ },
32
+ },
33
+ ]
34
+
35
+ STATIC_URL = "/static/"
36
+ USE_TZ = True
37
+
38
+ RELEASE_VERSION = "test-version"
39
+
40
+ VITE = {
41
+ "manifest_path": None,
42
+ "cache": True,
43
+ }
@@ -0,0 +1,37 @@
1
+ from django.test import override_settings
2
+
3
+ from django_vite_rolling.conf import get_cache_key, get_release_version, get_setting
4
+
5
+
6
+ def test_get_setting_returns_default_when_not_configured():
7
+ assert get_setting("cache_key_prefix") == "vite_manifest"
8
+
9
+
10
+ @override_settings(VITE={"cache_key_prefix": "custom_prefix"})
11
+ def test_get_setting_returns_user_override():
12
+ assert get_setting("cache_key_prefix") == "custom_prefix"
13
+
14
+
15
+ @override_settings(RELEASE_VERSION="abc123")
16
+ def test_get_release_version_reads_default_setting():
17
+ assert get_release_version() == "abc123"
18
+
19
+
20
+ @override_settings(RELEASE_VERSION="")
21
+ def test_get_release_version_blank_is_empty():
22
+ assert get_release_version() == ""
23
+
24
+
25
+ @override_settings(RELEASE_VERSION="v1.2.3")
26
+ def test_get_cache_key_with_version():
27
+ assert get_cache_key() == "vite_manifest:v1.2.3"
28
+
29
+
30
+ @override_settings(RELEASE_VERSION="")
31
+ def test_get_cache_key_without_version_falls_back_to_prefix():
32
+ assert get_cache_key() == "vite_manifest"
33
+
34
+
35
+ @override_settings(MY_VERSION="x", VITE={"version_setting": "MY_VERSION"})
36
+ def test_version_setting_is_configurable():
37
+ assert get_release_version() == "x"
@@ -0,0 +1,35 @@
1
+ import pytest
2
+ from django.core.cache import cache
3
+ from django.test import override_settings
4
+
5
+ from django_vite_rolling.manifest import get_manifest, set_manifest
6
+
7
+
8
+ def test_set_manifest_loads_from_file_and_caches(manifest_file):
9
+ with override_settings(VITE={"manifest_path": str(manifest_file)}, RELEASE_VERSION="v1"):
10
+ result = set_manifest()
11
+ assert "src/main.ts" in result
12
+ assert cache.get("vite_manifest:v1") == result
13
+
14
+
15
+ def test_set_manifest_raises_when_path_unconfigured():
16
+ with override_settings(VITE={"manifest_path": None}):
17
+ with pytest.raises(RuntimeError, match="manifest_path"):
18
+ set_manifest()
19
+
20
+
21
+ def test_get_manifest_returns_cached_when_cache_enabled(manifest_file):
22
+ with override_settings(VITE={"manifest_path": str(manifest_file), "cache": True}, RELEASE_VERSION="v1"):
23
+ first = set_manifest()
24
+ cache.set("vite_manifest:v1", {"sentinel": {"file": "x"}}, None)
25
+ second = get_manifest()
26
+ assert second == {"sentinel": {"file": "x"}}
27
+ assert second != first
28
+
29
+
30
+ def test_get_manifest_re_reads_when_cache_disabled(manifest_file):
31
+ with override_settings(VITE={"manifest_path": str(manifest_file), "cache": False}, RELEASE_VERSION="v1"):
32
+ set_manifest()
33
+ cache.set("vite_manifest:v1", {"sentinel": {"file": "x"}}, None)
34
+ result = get_manifest()
35
+ assert "src/main.ts" in result
@@ -0,0 +1,67 @@
1
+ from io import StringIO
2
+ from unittest.mock import patch
3
+
4
+ import fakeredis
5
+ from django.core.management import call_command
6
+ from django.test import override_settings
7
+
8
+
9
+ def _fake_client():
10
+ return fakeredis.FakeRedis()
11
+
12
+
13
+ def test_command_warns_when_release_version_empty(manifest_file):
14
+ out = StringIO()
15
+ with override_settings(RELEASE_VERSION="", VITE={"manifest_path": str(manifest_file)}):
16
+ call_command("refresh_vite_manifest", stdout=out)
17
+ output = out.getvalue()
18
+ assert "RELEASE_VERSION is empty" in output
19
+ assert "Vite manifest cached" in output
20
+
21
+
22
+ def test_command_records_version_and_caches_manifest(manifest_file):
23
+ client = _fake_client()
24
+ out = StringIO()
25
+ with override_settings(RELEASE_VERSION="v1", VITE={"manifest_path": str(manifest_file)}):
26
+ with patch("django_vite_rolling.management.commands.refresh_vite_manifest.get_redis_connection",
27
+ return_value=client):
28
+ call_command("refresh_vite_manifest", stdout=out)
29
+ versions = [v.decode("utf-8") for v in client.lrange("recent-manifest-versions", 0, -1)]
30
+ assert versions == ["v1"]
31
+ assert "Vite manifest cached" in out.getvalue()
32
+
33
+
34
+ def test_command_deletes_stale_versioned_keys(manifest_file):
35
+ client = _fake_client()
36
+ client.set("vite_manifest:old1", b"stale")
37
+ client.set("vite_manifest:old2", b"stale")
38
+ client.set("vite_manifest:keep", b"current")
39
+ client.lpush("recent-manifest-versions", b"keep")
40
+
41
+ out = StringIO()
42
+ with override_settings(RELEASE_VERSION="new", VITE={"manifest_path": str(manifest_file), "versions_to_keep": 2}):
43
+ with patch("django_vite_rolling.management.commands.refresh_vite_manifest.get_redis_connection",
44
+ return_value=client):
45
+ call_command("refresh_vite_manifest", stdout=out)
46
+
47
+ # "new" and "keep" should both be retained (versions_to_keep=2)
48
+ versions = {v.decode("utf-8") for v in client.lrange("recent-manifest-versions", 0, -1)}
49
+ assert versions == {"new", "keep"}
50
+ assert client.get("vite_manifest:old1") is None
51
+ assert client.get("vite_manifest:old2") is None
52
+ assert client.get("vite_manifest:keep") == b"current"
53
+
54
+
55
+ def test_command_trims_to_versions_to_keep(manifest_file):
56
+ client = _fake_client()
57
+ for v in ["a", "b", "c", "d", "e"]:
58
+ client.lpush("recent-manifest-versions", v.encode("utf-8"))
59
+
60
+ with override_settings(RELEASE_VERSION="f", VITE={"manifest_path": str(manifest_file), "versions_to_keep": 3}):
61
+ with patch("django_vite_rolling.management.commands.refresh_vite_manifest.get_redis_connection",
62
+ return_value=client):
63
+ call_command("refresh_vite_manifest", stdout=StringIO())
64
+
65
+ versions = [v.decode("utf-8") for v in client.lrange("recent-manifest-versions", 0, -1)]
66
+ assert len(versions) == 3
67
+ assert versions[0] == "f"
@@ -0,0 +1,55 @@
1
+ from django.template import Context, Template
2
+ from django.test import RequestFactory, override_settings
3
+
4
+
5
+ def _render(template_source: str, request=None) -> str:
6
+ template = Template(template_source)
7
+ return template.render(Context({"request": request}))
8
+
9
+
10
+ @override_settings(DEBUG=True)
11
+ def test_dev_mode_emits_vite_client_and_entry():
12
+ result = _render('{% load vite %}{% vite_scripts "src/main.ts" %}')
13
+ assert "@vite/client" in result
14
+ assert "localhost:3001/static/src/main.ts" in result
15
+
16
+
17
+ @override_settings(DEBUG=True)
18
+ def test_dev_mode_uses_request_host():
19
+ rf = RequestFactory()
20
+ request = rf.get("/", HTTP_HOST="dev.box:8000")
21
+ result = _render('{% load vite %}{% vite_scripts "src/main.ts" %}', request=request)
22
+ assert "dev.box:3001" in result
23
+
24
+
25
+ @override_settings(DEBUG=True, VITE={"dev_server_host": "fixed.host", "dev_server_port": 5173})
26
+ def test_dev_mode_respects_configured_host_and_port():
27
+ result = _render('{% load vite %}{% vite_scripts "src/main.ts" %}')
28
+ assert "fixed.host:5173" in result
29
+
30
+
31
+ def test_prod_mode_resolves_entry_and_imports(manifest_file):
32
+ with override_settings(DEBUG=False, VITE={"manifest_path": str(manifest_file)}, RELEASE_VERSION="v1"):
33
+ scripts = _render('{% load vite %}{% vite_scripts "src/main.ts" %}')
34
+ # Imported chunk should come before the entry chunk
35
+ assert scripts.index("shared-def456.js") < scripts.index("main-abc123.js")
36
+
37
+
38
+ def test_prod_mode_emits_styles(manifest_file):
39
+ with override_settings(DEBUG=False, VITE={"manifest_path": str(manifest_file)}, RELEASE_VERSION="v1"):
40
+ styles = _render('{% load vite %}{% vite_styles "src/main.ts" %}')
41
+ assert "main-abc123.css" in styles
42
+ assert "shared-def456.css" in styles
43
+ assert 'rel="stylesheet"' in styles
44
+
45
+
46
+ def test_prod_mode_passes_through_absolute_urls(tmp_path):
47
+ import json
48
+ path = tmp_path / "manifest.json"
49
+ path.write_text(json.dumps({
50
+ "src/main.ts": {"file": "https://cdn.example.com/main.js", "css": ["https://cdn.example.com/main.css"]},
51
+ }))
52
+ with override_settings(DEBUG=False, VITE={"manifest_path": str(path)}, RELEASE_VERSION="v1"):
53
+ scripts = _render('{% load vite %}{% vite_scripts "src/main.ts" %}')
54
+ assert "https://cdn.example.com/main.js" in scripts
55
+ assert "/static/https" not in scripts