Fast, Python-native, npm-free ESM dependency vendoring with import maps and Subresource Integrity
Project description
pyesm
Use modern JavaScript libraries in a Python web app, without Node, npm, a bundler, or a CDN dependency at runtime.
You want a JS library (React, lit, htmx, Alpine, CodeMirror) in a site with a Python backend. The
usual options are to load it from a CDN at runtime (now your site depends on that CDN being up, fast,
and untampered, and it breaks offline) or to stand up a whole Node + npm + bundler toolchain just to
ship a few .js files.
pyesm is a third way. Declare the library in pyproject.toml, run one command, and pyesm downloads the
compiled ES modules (the entire dependency graph) into your static directory and writes a standard
import map. You
serve your own files: nothing hits a CDN at runtime, it works offline, and the whole thing is pinned by
a lockfile and checked with Subresource Integrity.
- Pure Python, no Node.
pip install pyesmwith no Node toolchain, nonode_modules, no bundler, no compiled extensions. - Vendors the whole graph. A real backtracking npm-semver resolver picks one version per package and downloads every module locally, deduped to a single copy.
- Standard import maps. Emits a plain
importmap.jsonthe browser understands natively, soimport "react"just works. Framework-agnostic. - Locked & reproducible. A committed
pyesm.lockmakessyncdeterministic and offline; every file carries asha384, re-verified on every sync. - Optional Django integration that survives
ManifestStaticFilesStorage/ WhiteNoise filename hashing.
Requires Python 3.12+. Four pure-Python dependencies (httpx, tomlkit, semantic-version,
resolvelib); only httpx pulls a transitive stack.
Quick start
$ pip install pyesm
$ pyesm add react@^18.2.0 react-dom@^18.2.0 # resolve → lock → download → write the import map
Reference the generated import map and import as normal:
<script type="importmap" src="/static/pyesm/importmap.json"></script>
<script type="module">import "react"</script>
That's it: react and its whole module graph now load from your own static files, with zero requests
to a CDN at runtime. Omit the @range to take the latest; already have deps in pyproject.toml? Skip
add and run pyesm sync.
How it works
pyesm add react runs four steps, all reproducible from the lockfile afterward:
- Resolve. Reads each package's
package.jsonranges and runs a real backtracking resolver (resolvelib+semantic-versionfor npm semver) to choose one version per package satisfying every constraint, so shared transitive dependencies collapse to a single copy. An unsatisfiable graph (e.g. react 17 vs 18) is a clean error that changes nothing. - Vendor. Downloads the compiled ESM for the entire graph from a CDN (jsDelivr's
+esmby default) intooutput-dir. Files are content-addressed in a shared global cache (~/.cache/pyesm/<hash>) and hardlinked into place, so identical modules download once, ever. - Remap. CDN modules reference their siblings by absolute path (
/npm/react@18.3.1/+esm); pyesm points each of those at the local copy through the import map. It never rewrites the imports inside the files (the import map is the only indirection), so a module's bytes don't depend on what else you vendor. - Verify. Each module's
sha384is written to the lock and the import map, andsyncre-checks it on every run, failing loudly if a CDN ever serves different bytes under a pinned URL.
Configuration
All configuration lives under [tool.pyesm] in pyproject.toml.
| Key | Default | Meaning |
|---|---|---|
provider |
"jsdelivr" |
CDN to vendor from: jsdelivr or esmsh. |
output-dir |
"static/pyesm" |
Where vendored files are written (relative to project root). |
base-url |
"/static/pyesm/" |
Public URL prefix used in the static import map. Must end with /. |
importmap |
"static/pyesm/importmap.json" |
Output path for the static import map. |
production |
true |
Request production (vs dev) builds where the CDN distinguishes (esm.sh). |
shims |
"auto" |
es-module-shims injection: auto, always, or never. |
concurrency |
16 |
Max parallel downloads. |
integrity |
true |
Emit the SRI integrity block in the import map. |
Dependencies go in a separate table. Keys containing dots, slashes, or scopes must be quoted. Each
value is a version range; the key is what you import. Deep imports work too: list a package's
subpaths under one entry so they share a single pinned version:
[tool.pyesm.dependencies]
react = "^18.2.0"
"react-dom" = "^18.2.0"
lit = "3"
"htmx.org" = "2"
# multiple subpaths of one package, one shared version (one vendored copy):
lodash-es = { version = "^4.17.21", subpaths = ["cloneDeep", "debounce", "throttle"] }
pyesm add lodash-es/debounce writes/merges that grouped table for you; plain packages stay in the
name = "range" shorthand. (For long subpath lists, the equivalent nested form
[tool.pyesm.dependencies.lodash-es] reads the same.) A grouped table imports only its subpaths;
add root = true to also import the bare package (pyesm add sigma followed by pyesm add sigma/rendering sets this for you, so both sigma and sigma/rendering resolve).
CSS dependencies
A dependency whose specifier ends in .css is vendored as a stylesheet rather than a JS module. Point
at the package's CSS file:
$ pyesm add katex/dist/katex.min.css
pyesm vendors the raw CSS plus its @import / url() closure (fonts, images, imported stylesheets),
preserving the directory structure so the relative references resolve against the vendored copies. Nothing
is rewritten inside the files, and every asset is integrity-checked and pinned in the lock like any module.
You reference the result with <link> rather than the import map:
-
Static mode:
pyesm build/syncwrites astylesheets.htmlsnippet (path configurable via thestylesheetskey, defaultstatic/pyesm/stylesheets.html) of ready-to-use tags. Include or serve it:<link rel="stylesheet" href="/static/pyesm/katex@0.16.9/dist/katex.min.css" integrity="sha384-…" crossorigin>
-
Django:
{% pyesm_stylesheets %}emits the<link>tags throughstaticfilesstorage, so they surviveManifestStaticFilesStorage/ WhiteNoise hashing.
SRI note: in static mode the <link> carries an integrity attribute. Under Django's
ManifestStaticFilesStorage, CSS is rewritten at collectstatic (it hashes the url() font/image
names), so the <link> is emitted without SRI; the storage's content-hashed filenames provide the
cache-busting instead. External url()/@import references (e.g. Google Fonts) are left untouched and
remain a runtime dependency. CSS is supported on the jsdelivr provider.
CLI reference
The single entry point is pyesm. Running it bare prints help.
| Command | Network? | Behavior |
|---|---|---|
pyesm add <pkg>[@range] … |
yes | Add to [tool.pyesm.dependencies], re-resolve, update lock, vendor. |
pyesm remove <pkg> … |
yes | Remove from deps, re-resolve, prune now-unused vendored files. |
pyesm lock |
yes | Re-resolve from pyproject.toml, rewrite pyesm.lock. |
pyesm sync (alias install) |
only if cold | Make local files + import map match the lock; download missing modules and verify every integrity. Offline & near-instant when the cache is warm. |
pyesm build |
no | (Re)emit the static importmap.json from the lock. |
pyesm clean |
no | Remove the contents of output-dir (keeps the lock). |
pyesm outdated |
yes | Report deps whose range now resolves to a newer pinned version. |
add accepts version ranges inline, scope-aware:
$ pyesm add lit@3 "@scope/pkg@1.2.3"
$ pyesm remove react-dom
Global flags
| Flag | Effect |
|---|---|
--frozen |
Fail if pyesm.lock is missing or stale. Never mutates the lock (a CI gate). |
--offline |
Never hit the network; fail if a needed module isn't cached. |
--provider <p> |
Override the configured provider for this run. |
-q / -v |
Quieter / more verbose output. |
--version |
Print the pyesm version. |
The lockfile (pyesm.lock)
lock writes a deterministic JSON lockfile next to pyproject.toml. Commit it: it drives
reproducible, offline sync in CI and deploys. It captures:
providerandinputs_hash: a hash of the resolution inputs (provider, declared dependency table, production flag, shims); letssyncskip re-resolution whenpyproject.tomlis unchanged.imports: each bare specifier → its pinned entry-module URL.modules: every node in the crawled graph:url(canonical CDN URL),path(local file),integrity(sha384-…),deps, andkeys(the root-relative specifiers that map to it).
Two lock runs on an unchanged pyproject.toml produce byte-identical files (modulo genuine CDN
drift, which surfaces as an explicit failure, never a silent change).
Static mode (default)
pyesm build (and sync) writes importmap.json using base-url to form public URLs. Embed it
however you like:
<!-- external -->
<script type="importmap" src="/static/pyesm/importmap.json"></script>
<!-- or inline the JSON contents directly into a <script type="importmap"> -->
<script type="module">import "react"</script>
es-module-shims and cross-browser SRI
There are two distinct integrity layers, and only the first is unconditional:
- At vendor time, every module's
sha384is stored in the lock and re-verified bysyncon every run (it fails loudly on mismatch). This always holds; it's what guarantees the bytes you ship are the bytes you locked. - In the browser, the import map's
integrityfield is enforced only where the runtime understands it. Recent Chromium enforces it natively; browsers that have import maps but not native import-map integrity (currently Firefox and Safari) ignore the field and load those modules unverified.
pyesm can vendor and inject the es-module-shims
polyfill to extend runtime enforcement, controlled by shims:
auto(default) /always: vendor and inject the polyfill. It enforces integrity only when it actually takes over module loading: on browsers with no native import-map support (where it fully engages), or in its opt-in "shim mode". On a browser that already has native import maps but not native integrity, the polyfill stays out of the native loader's way, so there theintegrityfield stays advisory. In other words the polyfill closes the gap for older browsers, not for current Firefox/Safari.never: don't vendor or inject.
The polyfill is vendored like every other file: downloaded once (at lock/sync) from the
configured provider (the minified ~43KB build on jsDelivr), stored in the lock with its own sha384,
and served from output-dir with an integrity attribute. Production makes no CDN request for it. In Django mode the <script> tag is
emitted for you; in static mode reference the vendored file yourself
(<base-url>es-module-shims@<version>.js, integrity in the lock).
Django integration
Install the extra and add the app to your settings:
$ pip install "pyesm[django]"
INSTALLED_APPS = [
# …
"pyesm.contrib.django",
]
Render the import map (and CSS <link>s) at request time with the template tags:
{% load pyesm %}
<head>
{% pyesm_stylesheets %} {# emits a <link rel="stylesheet"> per CSS dependency #}
{% pyesm_importmap %} {# emits <script type="importmap">…</script>, plus the shims tag per `shims` #}
</head>
<script type="module">import "react"</script>
{% pyesm_importmap %} renders the import map for your JS dependencies; {% pyesm_stylesheets %}
renders one <link> per .css dependency (omit it if you have none). Both are optional and independent.
Why request-time instead of static files: the tags route only the URLs through
staticfiles_storage.url("pyesm/<path>"), so the rendered output contains storage-hashed URLs
(e.g. /static/pyesm/react@18.3.1/+esm.4af3.js). This makes them survive ManifestStaticFilesStorage
and WhiteNoise filename hashing. For the import map, the integrity values come straight from the lock
and stay valid because the vendored JS content is never rewritten; the <link> tags carry no SRI
because ManifestStaticFilesStorage rewrites CSS during collectstatic (see CSS dependencies). The
rendered output is cached per process and invalidated when the staticfiles manifest changes.
A typical deploy is pyesm sync → collectstatic.
Relevant settings (optional):
| Setting | Default | Meaning |
|---|---|---|
PYESM_PROJECT_ROOT |
auto-detected | Directory containing pyproject.toml / pyesm.lock. |
PYESM_STATIC_PREFIX |
"pyesm" |
Static path prefix the vendored files live under. |
Caching & performance
- Global content-addressed cache at
~/.cache/pyesm/<sha384>, shared across projects. Override the location with thePYESM_CACHE_DIRenvironment variable (orXDG_CACHE_HOME). - Modules are hardlinked from the cache into
output-dir(a byte copy only when crossing filesystems). Bytes are never rewritten. - The crawl and the downloads run concurrently on
asynciovia a single pooledhttpx.AsyncClient, bounded byconcurrency. - A warm-cache
syncof a small graph completes in well under a second and makes no network calls.
Continuous integration
sync is the command to run in CI and on deploy. It's deterministic and needs no network when the
cache is warm.
$ pyesm sync --frozen # fail if pyesm.lock is missing or out of date with pyproject.toml
$ pyesm sync --offline # fail rather than touch the network (requires a warm cache)
--frozen never mutates the lock, so it's a safe gate against forgetting to commit a lock update.
Providers
No provider requires Node. (JSPM is intentionally excluded: its generator is Node-only.)
jsdelivr(default): vendors transformed ESM fromcdn.jsdelivr.net/npm/<name>@<ver>/+esm. pyesm resolves the whole dependency graph itself (the Resolve step above), enumerating versions from jsDelivr's data API and reading eachpackage.json, then vendors exactly those resolved versions, deduped to one copy per package.esmsh: vendors fromesm.sh, which pins a range by redirect. Its entry URLs aren't version-pinned in the path, so pyesm follows the redirect and vendors the frozen re-export shim plus its pinned target. esm.sh dedupes its graph server-side, so pyesm doesn't run its own resolver for this provider; everything is locked by integrity.
Switch per-run with --provider, or set provider in config.
Limitations
- Runtime-computed dynamic imports (
import(someVariable)) can't be discovered statically, so their targets aren't vendored; they'd load from the CDN at runtime. Staticimport("…literal…")is discovered. outdatedis a no-op for esm.sh deps, because esm.sh entry URLs don't pin a version in the URL to compare against. jsDelivr pins exactly and reports accurately.- Runtime SRI enforcement is browser-dependent. The
integrityfield is always emitted (and always verified at vendor time), but the browser only enforces it natively on recent Chromium; on Firefox/Safari (import maps, no native integrity) the field is advisory, and the es-module-shims polyfill only covers browsers without native import maps. See es-module-shims and cross-browser SRI. - CDN output (
+esm, esm.sh transforms) is not guaranteed byte-stable across CDN updates. That's fine at serve time because you host your own frozen copy, but asyncthat finds a hash mismatch against a still-pinned URL fails loudly rather than silently overwriting. - CSS is vendored on the
jsdelivrprovider only; externalurl()/@importreferences are left as runtime dependencies;<link>order is sorted (cross-library cascade order isn't yet controllable).
Development
$ uv sync # create the venv and install deps
$ uv run pre-commit install # enable the git hooks (ruff + pyright)
$ uv run pytest # run the test suite
$ uv build # build the wheel/sdist
Pre-commit runs ruff format, ruff check, the standard hygiene hooks, and pyright. Run them on
demand with uv run pre-commit run --all-files.
Releasing
Pushing a v* tag (matching the pyproject.toml version) builds the sdist + wheel and publishes to
PyPI via Trusted Publishing (OIDC, no stored token):
$ git tag v0.1.0 && git push --tags
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyesm-0.3.6.tar.gz.
File metadata
- Download URL: pyesm-0.3.6.tar.gz
- Upload date:
- Size: 67.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
68291cbb297fcc634728b5cb46752962bbc189650225da79df2392603401a32f
|
|
| MD5 |
d8b6cd71a25b19d70c739b8045374e98
|
|
| BLAKE2b-256 |
1ecf3f63e6dfe87a6cccf1b953aa15241d802333e5fed4be2848e9588f2de9dd
|
Provenance
The following attestation bundles were made for pyesm-0.3.6.tar.gz:
Publisher:
release.yml on novucs/pyesm
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyesm-0.3.6.tar.gz -
Subject digest:
68291cbb297fcc634728b5cb46752962bbc189650225da79df2392603401a32f - Sigstore transparency entry: 1753683565
- Sigstore integration time:
-
Permalink:
novucs/pyesm@82df8a4d97666db8e7833e26009cc262ffa21562 -
Branch / Tag:
refs/tags/v0.3.6 - Owner: https://github.com/novucs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@82df8a4d97666db8e7833e26009cc262ffa21562 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pyesm-0.3.6-py3-none-any.whl.
File metadata
- Download URL: pyesm-0.3.6-py3-none-any.whl
- Upload date:
- Size: 47.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d9c5ac4c7a846181e43069ce1d15e4697ddb7980689bed23e02d70c8475a825a
|
|
| MD5 |
29df1da1682e9b76568a94c92a66dea2
|
|
| BLAKE2b-256 |
8e7b7a525571f64e09fd2d7f7a64a3ecb12c9abfe2f250ec568a29a251ff4896
|
Provenance
The following attestation bundles were made for pyesm-0.3.6-py3-none-any.whl:
Publisher:
release.yml on novucs/pyesm
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyesm-0.3.6-py3-none-any.whl -
Subject digest:
d9c5ac4c7a846181e43069ce1d15e4697ddb7980689bed23e02d70c8475a825a - Sigstore transparency entry: 1753683598
- Sigstore integration time:
-
Permalink:
novucs/pyesm@82df8a4d97666db8e7833e26009cc262ffa21562 -
Branch / Tag:
refs/tags/v0.3.6 - Owner: https://github.com/novucs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@82df8a4d97666db8e7833e26009cc262ffa21562 -
Trigger Event:
push
-
Statement type: