pax_global_header 0000666 0000000 0000000 00000000064 15212556142 0014515 g ustar 00root root 0000000 0000000 52 comment=7c024a3ef83a1c2951f2ab3925e85b81ebbc8bc0
python-django-js-asset-4.0.1/ 0000775 0000000 0000000 00000000000 15212556142 0016027 5 ustar 00root root 0000000 0000000 python-django-js-asset-4.0.1/.editorconfig 0000664 0000000 0000000 00000000310 15212556142 0020476 0 ustar 00root root 0000000 0000000 # top-most EditorConfig file
root = true
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.py]
indent_size = 4
python-django-js-asset-4.0.1/.github/ 0000775 0000000 0000000 00000000000 15212556142 0017367 5 ustar 00root root 0000000 0000000 python-django-js-asset-4.0.1/.github/dependabot.yml 0000664 0000000 0000000 00000000524 15212556142 0022220 0 ustar 00root root 0000000 0000000 version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "monthly"
groups:
python-deps:
patterns:
- "*"
python-django-js-asset-4.0.1/.github/workflows/ 0000775 0000000 0000000 00000000000 15212556142 0021424 5 ustar 00root root 0000000 0000000 python-django-js-asset-4.0.1/.github/workflows/publish.yml 0000664 0000000 0000000 00000001417 15212556142 0023620 0 ustar 00root root 0000000 0000000 name: Publish to PyPI
on:
push:
tags:
- '*'
jobs:
build:
name: Build distribution
runs-on: ubuntu-latest
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: 3.x
- name: Build
run: |
python -m pip install --upgrade build
python -m build
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write
steps:
- uses: actions/download-artifact@v8
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
python-django-js-asset-4.0.1/.github/workflows/tests.yml 0000664 0000000 0000000 00000001613 15212556142 0023312 0 ustar 00root root 0000000 0000000 name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: 37 1 1 * *
jobs:
tests:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version:
- '3.10'
- '3.11'
- '3.12'
- '3.13'
- '3.14'
steps:
- uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip wheel setuptools tox
- name: Run tox targets for ${{ matrix.python-version }}
run: |
ENV_PREFIX=$(tr -C -d "0-9" <<< "${{ matrix.python-version }}")
TOXENV=$(tox --listenvs | grep "^py$ENV_PREFIX" | tr '\n' ',') python -m tox
python-django-js-asset-4.0.1/.gitignore 0000664 0000000 0000000 00000000230 15212556142 0020012 0 ustar 00root root 0000000 0000000 *.py?
*~
*.sw?
\#*#
/secrets.py
.DS_Store
._*
/*.egg-info
/MANIFEST
/_build
/build
/dist
tests/test.zip
/docs/_build
/.eggs
.coverage
htmlcov
venv
.tox
python-django-js-asset-4.0.1/.pre-commit-config.yaml 0000664 0000000 0000000 00000001671 15212556142 0022315 0 ustar 00root root 0000000 0000000 exclude: ".yarn/|yarn.lock|\\.min\\.(css|js)$"
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-added-large-files
- id: check-builtin-literals
- id: check-executables-have-shebangs
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: detect-private-key
- id: end-of-file-fixer
- id: mixed-line-ending
- id: trailing-whitespace
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.30.0
hooks:
- id: django-upgrade
args: [--target-version, "3.2"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.15.16"
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.23.0
hooks:
- id: pyproject-fmt
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.25
hooks:
- id: validate-pyproject
python-django-js-asset-4.0.1/AGENTS.md 0000664 0000000 0000000 00000006732 15212556142 0017342 0 ustar 00root root 0000000 0000000 # django-js-asset — agent notes
`JS`, `CSS`, `JSON` support for `django.forms.Media`, plus importmap and CSP
nonce support.
## Running tests
Use **tox** (configured with `tox-uv`, so it provisions the right Python/Django
automatically — do not hand-roll a venv):
```bash
tox -e py312-dj42 # lowest supported combination
tox -e py314-dj62 # a recent combination
tox run-parallel # the whole matrix
```
The matrix lives in `tox.ini` (`tests/manage.py test testapp`).
## Compatibility (hard constraint)
- Python `>=3.10`, Django `>=4.2`. **Django 6.2+ is NOT an acceptable floor.**
- `JS`/`CSS` *produce* Django's own `Script`/`Stylesheet` (see Layout). Where
Django lacks them they are **backported** in `js_asset/_compat.py`, so the
4.2 floor holds — we provide the machinery, we don't *depend* on Django
having it. Availability in real Django: `Script` (5.2+), `Stylesheet` +
`MediaAsset.render(attrs=)` (6.1+), attribute-aware `__eq__`/`__hash__`
(6.2+). The `<5.2` backport mirrors the **6.2** contract.
- Don't rely on Django's `MediaAsset.render(attrs=...)` existing: it is absent
on 5.2/6.0. `media.py:_render_asset` injects the nonce itself by rebuilding
the tag from `element_template` + `flatatt`, which works on every version.
- Cross-version gotcha: Django >= 6.2 wraps bare js/css path strings into
`Script`/`Stylesheet` in `Media._js`/`._css`; older Django keeps raw strings.
`media.py:_render_{js,css}` wrap any leftover strings via `JS()`/`CSS()`, so
`_render_asset` always sees a `MediaAsset` (or `JSON`/`ImportMap`).
## Layout
- `js_asset/_compat.py` — `MediaAsset`/`Script`/`Stylesheet`: imported from
Django where present, backported (with the 6.2 contract) below 5.2/6.1.
- `js_asset/js.py` — `JS`/`CSS` are **factories** (a `_ProducesAsset`
metaclass): calling them returns a Django `Script`/`Stylesheet`/`InlineStyle`
so they dedup in `forms.Media.merge` against native assets *and* bare path
strings; `isinstance(x, JS)` still works via `__instancecheck__`. `JSON` and
`ImportMap` have no Django counterpart and stay standalone `@html_safe`
objects with `render(*, nonce="")`. Output is byte-identical to native Django
assets (flatatt sorts attributes), which the exact-string tests depend on.
Equality is Django's, so dedup is attribute-aware on 4.2-5.1 + 6.2+ and
path-only on 5.2-6.1 (`test_set` derives its expectation from this).
- `js_asset/media.py` — `Media(forms.Media)` subclass: merges embedded
`ImportMap`s into one tag and applies a nonce. Implements `__add__` **and**
`__radd__` so it keeps its type (and nonce) when combined with plain
`forms.Media` from either side. The nonce lives on the instance (constructor
`nonce=` or `with_nonce()` returning a copy); `render()` reads it, since
templates call `render()` with no arguments.
- Django >= 6.2 has built-in CSP support: the `{% csp_nonce_attr media %}` tag
(`django.utils.csp.nonce_attr`) renders media via
`media.render(attrs={"nonce": nonce})`. `Media.render()` therefore accepts
`attrs=` and honours its nonce — so our `Media` plugs into that tag on 6.2,
while `with_nonce()`/constructor cover older Django.
## Docs
- `README.rst` — the single doc: assets, import-map merging via `js_asset.Media`,
rendering in views/admin, and CSP nonces across Django 4.2 → main.
## Lint
`prek` / `ruff` (config in `pyproject.toml`).
## Commit Style
Commit without the `Co-Authored-By` attribution line (no `--co-author` / no `Co-Authored-By: Claude` trailer).
python-django-js-asset-4.0.1/CHANGELOG.rst 0000664 0000000 0000000 00000016213 15212556142 0020053 0 ustar 00root root 0000000 0000000
.. _changelog:
Change log
==========
Next version
~~~~~~~~~~~~
- Fixed ``js_asset.Media`` crashing with an ``AttributeError`` when rendering an
asset that only implements Django's plain ``__html__`` contract (neither a
``MediaAsset`` nor one of our ``ImportMap``/``JSON`` types). Such assets now
fall back to ``__html__()`` exactly like ``django.forms.Media`` does; the
nonce cannot be threaded into an opaque ``__html__`` asset, same as with stock
Django.
4.0 (2026-06-11)
~~~~~~~~~~~~~~~~
This is a major release. Most code keeps working unchanged and the rendered
HTML is the same, but **if you use import maps the upgrade is not automatic** --
see the import-map note at the end.
- Added ``js_asset.Media``, a ``django.forms.Media`` subclass with two extra
abilities. First, it merges every embedded ``ImportMap`` into a single
``
The attributes are automatically escaped. The data attributes may now be
accessed inside ``asset.js``:
.. code-block:: javascript
let answer = document.querySelector("#asset-script").dataset.answer;
Also, because the implementation of ``static`` differs between supported
Django versions (older do not take the presence of
``django.contrib.staticfiles`` in ``INSTALLED_APPS`` into account), a
``js_asset.static`` function is provided which does the right thing
automatically.
CSS and JSON support
====================
Since 3.0 django-js-asset also ships a ``CSS`` and ``JSON`` media object which
can be used to ship stylesheets, inline styles and JSON blobs to the frontend.
It's recommended to pass those through ``forms.Media(js=[])`` as well since
``js`` is a simple list while ``css`` uses a dictionary keyed with the media to
use for the stylesheet.
So, you can add everything at once:
.. code-block:: python
from js_asset import CSS, JS, JSON
forms.Media(js=[
JSON({"configuration": 42}, id="widget-configuration"),
CSS("widget/style.css"),
CSS("p{color:red;}", inline=True),
JS("widget/script.js", {"type": "module"}),
])
This produces:
.. code-block:: html
Compatibility
=============
At the time of writing this app is compatible with Django 4.2 and better
(up to and including the Django main branch), but have a look at the
`tox configuration
`_ for
definitive answers.
Deduplication
=============
``JS`` and ``CSS`` produce Django's own ``Script`` and ``Stylesheet`` objects
(backported on Django versions that lack them), so they de-duplicate against
each other, against plain path strings, and against Django's native assets when
``forms.Media`` merges the media of several forms and widgets. The rules for
what counts as a duplicate are Django's, and Django changed them between
releases:
* **Django 4.2 - 5.1 and 6.2+:** two assets are equal when both the path *and*
the attributes match, so ``JS("a.js", {"id": "x"})`` and
``JS("a.js", {"id": "y"})`` are distinct and both render.
* **Django 5.2 - 6.1:** Django compares the **path only**, so those same two
assets de-duplicate to one (whichever wins the merge) -- exactly as Django's
own ``Script``/``Stylesheet`` behave on those versions.
A given file referenced both as a bare string and via ``JS``/``CSS`` always
de-duplicates, and nothing is ever rendered twice for the same path. The
per-version difference only affects the unusual case of the same path carried
with *different* attributes in a single merge.
Import maps
===========
django-js-asset can ship `import maps
`__.
They let your modules import short names (``import { Stuff } from
"my-library"``) and have the browser resolve them to the real, possibly hashed,
URLs produced by Django's ``ManifestStaticFilesStorage`` -- without rewriting
the imports in your JavaScript.
Browsers do not reliably support more than one import map per page, so all of
them have to be merged into one. ``js_asset.Media`` does this for you: drop
``ImportMap`` objects into your media wherever they are relevant -- typically
next to the module that needs them -- and they are combined into a single
``` (identical).
- `CSS("a.css", media="all")` and `Stylesheet("a.css", media="all")` →
`` (identical, including the
`nonce` slot which sorts between `media` and `rel`).
The existing exact-string tests (`test_css`, `test_json`,
`test_boolean_attributes`, `test_asset`) are therefore expected to keep passing,
which satisfies the AGENTS.md byte-identity constraint — **but this must be
re-verified per supported Django version**, because the base class differs.
### Nonce rendering (the part that varies by version)
`js_asset` historically renders the nonce itself (so it works on 4.2). Django
6.2 renders it via `asset.render(attrs={"nonce": ...})`; 5.2–6.1 assets have only
`__str__`; <5.2 has no asset class.
Because we produce **native** objects on 6.2+, the clean path there is to let
Django render: `media.render(attrs={"nonce": nonce})` is exactly what the
built-in `csp_nonce_attr` tag does, and our `Media.render()` already forwards
`attrs`. On 5.2–6.1 (and via the backport on <5.2) js_asset's `Media` renders the
nonce by rebuilding the tag from the asset's `element_template` and
`flatatt({**asset.attributes, "nonce": nonce})` — the asset itself stays a plain
native `Script`. This keeps the *asset* objects pure Django and confines the
version-specific logic to js_asset's `Media._render_asset`.
`JSON` / `ImportMap` keep their own `render(*, nonce=...)` (they are not
`MediaAsset`s).
### Equality / hash — inherited, not reimplemented
Because `JS`/`CSS` *produce* `Script`/`Stylesheet`, equality and hashing are
**Django's**, version-appropriate by construction — we write none of it on 6.2+.
Consequences, all acceptable per the recorded decision to "match Django":
- 6.2+: path + attributes identity, plus the bare-string branch. A path with
differing attributes is a distinct asset; a path vs the same bare string
dedups. Verified against existing tests: `test_set` → 2, `test_asset_merging`
→ 3 still hold.
- 5.2–6.1: path-only identity (attributes ignored) — slightly looser dedup than
6.2, accepted under "be reasonable on older Django".
- The backport (<5.2) should mirror **6.2** semantics so the floor matches the
target rather than the awkward middle.
### The hard problems (call out explicitly)
1. **Inline CSS has no Django equivalent.** `CSS(src, inline=True)` renders a
`` block whose `src` is *CSS text*, not a path; `Stylesheet`
always renders `` and runs `_path` through `static()`. So `CSS` is a
factory that returns a small dedicated `InlineStyle(MediaAsset)` with a
`` template and a `path`
override that returns the source verbatim. It is still a `MediaAsset`, so it
flows through the same render path; its `_path`-keyed equality is on CSS text,
which is odd but harmless (inline blocks are rarely deduped).
2. **`JSON` and `ImportMap` stay custom.** No Django counterpart
(`json_script` / `'
def __init__(self, src, **attributes):
# Alter the signature to allow src to be passed as a keyword argument.
super().__init__(src, **attributes)
if django.VERSION >= (6, 1):
from django.forms.widgets import Stylesheet
else:
class Stylesheet(MediaAsset):
element_template = ''
def __init__(self, href, **attributes):
super().__init__(href, rel="stylesheet", **attributes)
python-django-js-asset-4.0.1/js_asset/js.py 0000664 0000000 0000000 00000011342 15212556142 0020631 0 ustar 00root root 0000000 0000000 import json
from dataclasses import dataclass, field
from typing import Any
from django.core.serializers.json import DjangoJSONEncoder
from django.forms.utils import flatatt
from django.templatetags.static import static
from django.utils.functional import lazy
from django.utils.html import html_safe, json_script, mark_safe
from js_asset._compat import MediaAsset, Script, Stylesheet
__all__ = [
"CSS",
"JS",
"JSON",
"ImportMap",
"InlineStyle",
"MediaAsset",
"Script",
"Stylesheet",
"static",
"static_lazy",
]
static_lazy = lazy(static, str)
def _canonical_hash(data):
# Hash an order-insensitive canonical form so the hash stays consistent
# with dict equality (``a == b`` must imply ``hash(a) == hash(b)``).
# ``DjangoJSONEncoder`` resolves lazy values, e.g. ``static_lazy`` paths.
return hash(json.dumps(data, sort_keys=True, cls=DjangoJSONEncoder))
class InlineStyle(MediaAsset):
"""
An inline ``"
def __init__(self, css, *, media="all", **attributes):
super().__init__(css, media=media, **attributes)
@property
def path(self):
return self._path
class _ProducesAsset(type):
"""
Metaclass turning ``JS``/``CSS`` into factories: calling them returns a
Django asset (``Script``/``Stylesheet``/``InlineStyle``) so they share
``forms.Media.merge`` buckets -- and dedup -- with Django's own assets and
with bare path strings. ``isinstance(x, JS)`` keeps answering truthfully by
delegating to the produced type(s).
"""
def __call__(cls, *args, **kwargs):
return cls._produce(*args, **kwargs)
def __instancecheck__(cls, instance):
return isinstance(instance, cls._produces)
def __subclasscheck__(cls, subclass):
return issubclass(subclass, cls._produces)
class JS(metaclass=_ProducesAsset):
_produces = Script
@staticmethod
def _produce(src, attrs=None):
return Script(src, **(attrs or {}))
class CSS(metaclass=_ProducesAsset):
_produces = (Stylesheet, InlineStyle)
@staticmethod
def _produce(src, media="all", *, inline=False):
if inline:
return InlineStyle(src, media=media)
return Stylesheet(src, media=media)
@html_safe
@dataclass(eq=True)
class JSON:
data: dict[str, Any]
id: str | None = field(default="", kw_only=True)
def __hash__(self):
# ``__eq__`` (dataclass) compares ``data`` order-insensitively, so the
# hash must too -- see ``_canonical_hash``.
return hash((_canonical_hash(self.data), self.id))
def render(self, *, nonce=""):
# A type="application/json" block is data, not executed JavaScript, so
# it is not governed by CSP and needs no nonce.
return json_script(self.data, self.id)
def __str__(self):
return self.render()
@html_safe
class ImportMap:
def __init__(self, importmap):
self._importmap = importmap
def __eq__(self, other):
return isinstance(other, ImportMap) and self._importmap == other._importmap
def __hash__(self):
# ``__eq__`` compares the underlying dict order-insensitively, so the
# hash must too -- see ``_canonical_hash``.
return _canonical_hash(self._importmap)
def render(self, *, nonce=""):
if self._importmap:
nonce_attr = mark_safe(flatatt({"nonce": nonce})) if nonce else ""
html = json_script(self._importmap).removeprefix(
'""",
)
c = ImportMap(
{
"imports": {
"/app/": "./original-app/",
"/app/helper": "./helper/index.mjs",
},
"scopes": {"/js": {"/app/": "./js-app/"}},
}
)
self.assertEqual(
str(a | b | c),
"""\
""",
)
python-django-js-asset-4.0.1/tests/testapp/test_js_asset.py 0000664 0000000 0000000 00000010261 15212556142 0024075 0 ustar 00root root 0000000 0000000 from django.forms import Media
from django.test import TestCase
from js_asset.js import CSS, JS, JSON, InlineStyle, Script, Stylesheet
class AssetTest(TestCase):
def test_asset(self):
media = Media(
css={"print": ["app/print.css"]},
js=[
"app/test.js",
JS("app/asset.js", {"id": "asset-script", "data-the-answer": 42}),
JS("app/asset-without.js", {}),
],
)
html = str(media)
# print(html)
self.assertInHTML(
'',
html,
)
self.assertInHTML(
'',
html,
)
self.assertInHTML(
'',
html,
)
self.assertInHTML(
'',
html,
)
def test_absolute(self):
media = Media(js=[JS("https://cdn.example.org/script.js")])
html = str(media)
self.assertInHTML(
'',
html,
)
def test_asset_merging(self):
media1 = Media(js=["thing.js", JS("other.js"), "some.js"])
media2 = Media(js=["thing.js", JS("other.js"), "some.js"])
media = media1 + media2
self.assertEqual(len(media._js), 3)
self.assertEqual(media._js[0], "thing.js")
self.assertEqual(media._js[2], "some.js")
def test_set(self):
media = [
JS("app/asset.js", {"id": "asset-script", "data-the-answer": 42}),
JS("app/asset.js", {"id": "asset-script", "data-the-answer": 42}),
JS("app/asset.js", {"id": "asset-script", "data-the-answer": 43}),
]
# ``JS`` produces a Django ``Script``, so identity follows Django's own
# contract: 6.2+ (and the < 5.2 backport) fold attributes into equality
# -- the differing third asset stays distinct -> 2. Django 5.2 - 6.1
# dedup on the path alone, so all three collapse -> 1.
attribute_aware = media[0] != media[2]
self.assertEqual(len(set(media)), 2 if attribute_aware else 1)
def test_boolean_attributes(self):
self.assertEqual(
str(JS("app/asset.js", {"bool": True, "cool": False})),
'',
)
def test_css(self):
self.assertEqual(
str(CSS("app/style.css")),
'',
)
self.assertEqual(
str(CSS("app/style.css", media="screen")),
'',
)
self.assertEqual(
str(CSS("p{color:red}", inline=True)),
'',
)
def test_js_produces_script(self):
asset = JS("app/asset.js", {"id": "x"})
# ``JS`` is a factory producing a Django ``Script`` ...
self.assertIsInstance(asset, Script)
self.assertNotIsInstance(asset, Stylesheet)
# ... while ``isinstance(x, JS)`` keeps answering truthfully.
self.assertIsInstance(asset, JS)
self.assertNotIsInstance(CSS("app/style.css"), JS)
def test_css_produces_stylesheet_or_inline(self):
link = CSS("app/style.css")
inline = CSS("p{color:red}", inline=True)
self.assertIsInstance(link, Stylesheet)
self.assertIsInstance(inline, InlineStyle)
# Both flavours answer ``isinstance(x, CSS)``.
self.assertIsInstance(link, CSS)
self.assertIsInstance(inline, CSS)
self.assertNotIsInstance(JS("app/asset.js"), CSS)
def test_json(self):
self.assertEqual(
str(JSON({"hello": "world"}, id="hello")),
'',
)
self.assertEqual(
str(JSON({"hello": "world"})),
'',
)
python-django-js-asset-4.0.1/tests/testapp/test_media.py 0000664 0000000 0000000 00000015700 15212556142 0023344 0 ustar 00root root 0000000 0000000 from django.forms import Media as DjangoMedia
from django.test import TestCase
from django.utils.html import html_safe
from js_asset import CSS, JS, JSON, ImportMap, Media
@html_safe
class HTMLOnlyAsset:
"""
A media asset using Django's plain ``__html__`` contract -- not a
``MediaAsset`` and not one of our ``ImportMap``/``JSON`` types. Stock
``forms.Media`` renders such assets via ``__html__()``.
"""
def __init__(self, markup):
self._markup = markup
def __str__(self):
return self._markup
class MediaTest(TestCase):
def test_nonce_applied_to_assets(self):
media = Media(
nonce="r@nd0m",
css={"all": [CSS("app.css")]},
js=[JS("app.js")],
)
html = media.render()
self.assertInHTML(
'',
html,
)
self.assertInHTML(
'',
html,
)
def test_no_nonce_keeps_plain_output(self):
media = Media(js=[JS("app.js")])
self.assertEqual(media.render(), '')
def test_importmaps_are_merged_and_rendered_first(self):
media = Media(
nonce="r@nd0m",
js=[
ImportMap({"imports": {"a": "/static/a.js"}}),
JS("app.js", {"type": "module"}),
ImportMap({"imports": {"b": "/static/b.js"}}),
],
)
self.assertEqual(
media.render(),
'\n'
'',
)
def test_type_and_nonce_preserved_when_merging(self):
ours = Media(nonce="abc", js=[ImportMap({"imports": {"a": "/static/a.js"}})])
plain = DjangoMedia(js=["app.js"])
# Our Media on the right-hand side (the case plain forms.Media drops).
merged = plain + ours
self.assertIsInstance(merged, Media)
self.assertEqual(merged.nonce, "abc")
# ... and on the left-hand side.
merged = ours + plain
self.assertIsInstance(merged, Media)
self.assertEqual(merged.nonce, "abc")
def test_bare_string_assets_get_nonce(self):
# Bare paths stay strings on Django < 6.2 and are wrapped into
# MediaAsset objects on >= 6.2; both paths must receive the nonce.
media = Media(nonce="n0nce", css={"all": ["plain.css"]}, js=["plain.js"])
html = media.render()
self.assertInHTML(
'',
html,
)
self.assertInHTML(
'',
html,
)
def test_merging_combines_css_and_js(self):
ours = Media(nonce="n", css={"all": [CSS("a.css")]}, js=[JS("a.js")])
plain = DjangoMedia(css={"all": ["b.css"]}, js=["b.js"])
merged = plain + ours # via __radd__
self.assertIsInstance(merged, Media)
html = merged.render()
for snippet in (
'',
'',
'',
'',
):
self.assertInHTML(snippet, html)
def test_render_accepts_attrs_nonce(self):
# Django >= 6.2's CSP integration calls media.render(attrs={"nonce": ...}).
media = Media(js=[JS("app.js")])
self.assertInHTML(
'',
media.render(attrs={"nonce": "from-attrs"}),
)
# A passed nonce overrides the stored one.
media = Media(nonce="stored", js=[JS("app.js")])
self.assertInHTML(
'',
media.render(attrs={"nonce": "from-attrs"}),
)
def test_html_only_asset_renders_via_html(self):
# A plain ``__html__``-only asset (e.g. a build-tool helper) is neither
# a MediaAsset nor one of our types; it must fall back to ``__html__()``
# the way stock ``forms.Media`` does, instead of crashing on a missing
# ``render`` method.
media = Media(js=[HTMLOnlyAsset('')])
self.assertEqual(media.render(), '')
def test_html_only_asset_renders_with_nonce_set(self):
# The nonce cannot be threaded into an opaque ``__html__`` asset (same
# as stock Django), but its presence must not break rendering.
media = Media(
nonce="n0nce",
js=[HTMLOnlyAsset('')],
)
self.assertEqual(media.render(), '')
def test_json_asset_rendered_through_media(self):
# A JSON block carried as a JS asset renders via its own ``render``.
# It is data, not executed script, so it stays nonce-free even when a
# nonce is set on the media.
media = Media(nonce="n0nce", js=[JSON({"a": 1}, id="cfg")])
self.assertInHTML(
'',
media.render(),
)
def test_adding_non_media_is_not_supported(self):
with self.assertRaises(TypeError):
Media() + 3
# The reverse case dispatches to Media.__radd__, which must also return
# NotImplemented (a clean TypeError) rather than raise AttributeError.
with self.assertRaises(TypeError):
3 + Media()
def test_from_media_wraps_existing_instance(self):
plain = DjangoMedia(
js=[ImportMap({"imports": {"a": "/static/a.js"}}), JS("app.js")]
)
wrapped = Media.from_media(plain, nonce="w")
self.assertIsInstance(wrapped, Media)
self.assertEqual(wrapped.nonce, "w")
html = wrapped.render()
self.assertInHTML(
'',
html,
)
self.assertInHTML(
'',
html,
)
def test_with_nonce_returns_a_copy(self):
media = Media(js=[JS("app.js")])
request_media = media.with_nonce("xyz")
self.assertIsInstance(request_media, Media)
self.assertEqual(request_media.nonce, "xyz")
# The shared (e.g. cached widget) media is left untouched.
self.assertEqual(media.nonce, "")
self.assertInHTML(
'',
request_media.render(),
)
python-django-js-asset-4.0.1/tox.ini 0000664 0000000 0000000 00000001027 15212556142 0017342 0 ustar 00root root 0000000 0000000 [tox]
envlist =
py{310,311}-dj{42}
py{312}-dj{42,52,60,main}
py{313}-dj{52,60,61,main}
py{314}-dj{52,60,61,main}
[testenv]
usedevelop = true
extras = tests
commands =
python -Wd {envbindir}/coverage run tests/manage.py test -v2 --keepdb {posargs:testapp}
coverage report -m
deps =
dj42: Django>=4.2,<5.0
dj50: Django>=5.0,<5.1
dj51: Django>=5.1,<5.2
dj52: Django>=5.2,<6.0
dj60: Django>=6.0,<6.1
dj61: Django>=6.1a1,<6.2
djmain: https://github.com/django/django/archive/main.tar.gz