#!/usr/bin/env python3 """Assemble a fully-nested node_modules tree from the npm registry, without npm. Fully nested (no hoisting) so Node's upward resolution can never pick a wrong version: every package's deps live inside its own node_modules/. """ import json import os import shutil import subprocess import sys import tarfile import urllib.parse import urllib.request CACHE = os.path.abspath("_npmcache") os.makedirs(CACHE, exist_ok=True) _meta = {} def meta(name): if name not in _meta: path = os.path.join(CACHE, name.replace("/", "_") + ".json") if not os.path.exists(path): url = "https://registry.npmjs.org/" + urllib.parse.quote(name, safe="") urllib.request.urlretrieve(url, path) with open(path) as fh: _meta[name] = json.load(fh) return _meta[name] def parse(v): core = v.split("-")[0].split("+")[0] return tuple(int(x) for x in core.split(".")) def satisfies(version, rng): if "-" in version: # never auto-pick a prerelease return False rng = rng.strip() v = parse(version) if rng in ("", "*", "latest", "x"): return True if rng.startswith("^"): b = parse(rng[1:]) if v < b: return False if b[0] > 0: return v[0] == b[0] if b[1] > 0: return v[0] == 0 and v[1] == b[1] return v[0] == 0 and v[1] == 0 and v[2] == b[2] if rng.startswith("~"): b = parse(rng[1:]) return v >= b and v[0] == b[0] and v[1] == b[1] if rng.startswith(">="): return v >= parse(rng[2:]) if rng[0].isdigit(): return version == rng raise ValueError("unsupported range %r" % rng) def pick(name, rng): m = meta(name) ok = [v for v in m["versions"] if satisfies(v, rng)] if not ok: raise SystemExit("no version of %s satisfies %s" % (name, rng)) best = max(ok, key=parse) return best, m["versions"][best] def install(name, rng, target_nm, chain, log): version, info = pick(name, rng) if (name, version) in chain: return # already provided by an ancestor dest = os.path.join(target_nm, *name.split("/")) if os.path.exists(dest): return os.makedirs(os.path.dirname(dest), exist_ok=True) tgz = os.path.join(CACHE, "%s-%s.tgz" % (name.replace("/", "_"), version)) if not os.path.exists(tgz): urllib.request.urlretrieve(info["dist"]["tarball"], tgz) tmp = dest + ".tmp" shutil.rmtree(tmp, ignore_errors=True) with tarfile.open(tgz) as tf: tf.extractall(tmp) inner = os.path.join(tmp, "package") shutil.move(inner if os.path.isdir(inner) else tmp, dest) shutil.rmtree(tmp, ignore_errors=True) log.append("%s%s@%s" % (" " * len(chain), name, version)) deps = info.get("dependencies") or {} if deps: child_nm = os.path.join(dest, "node_modules") for dname, drng in sorted(deps.items()): install(dname, drng, child_nm, chain | {(name, version)}, log) if __name__ == "__main__": root_nm, name, rng = sys.argv[1], sys.argv[2], sys.argv[3] os.makedirs(root_nm, exist_ok=True) log = [] install(name, rng, root_nm, frozenset(), log) print("\n".join(log)) print("\n%d packages" % len(log))