Plain is headed towards 1.0! Subscribe for development updates →

plain.vendor

Download those CDN scripts and styles.

What about source maps?

It's fairly common right now to get an error during plain compile that says it can't find the source map for one of your vendored files. Right now, the fix is add the source map itself to your vendored dependencies too. In the future plain vendor might discover those during the vendoring process and download them automatically with the compiled files.

  1import re
  2
  3import requests
  4import tomlkit
  5
  6from plain.assets.finders import APP_ASSETS_DIR
  7
  8from .exceptions import (
  9    UnknownContentTypeError,
 10    VersionMismatchError,
 11)
 12
 13VENDOR_DIR = APP_ASSETS_DIR / "vendor"
 14
 15
 16class Dependency:
 17    def __init__(self, name, **config):
 18        self.name = name
 19        self.url = config.get("url", "")
 20        self.installed = config.get("installed", "")
 21        self.filename = config.get("filename", "")
 22
 23    @staticmethod
 24    def parse_version_from_url(url):
 25        match = re.search(r"\d+\.\d+\.\d+", url)
 26        if match:
 27            return match.group(0)
 28        return ""
 29
 30    def __str__(self):
 31        return f"{self.name} -> {self.url}"
 32
 33    def download(self, version):
 34        # If the string contains a {version} placeholder, replace it
 35        download_url = self.url.replace("{version}", version)
 36
 37        response = requests.get(download_url)
 38        response.raise_for_status()
 39
 40        content_type = response.headers.get("content-type")
 41        if content_type not in (
 42            "application/javascript; charset=utf-8",
 43            "application/json; charset=utf-8",
 44            "text/css; charset=utf-8",
 45        ):
 46            raise UnknownContentTypeError(
 47                f"Unknown content type for {self.name}: {content_type}"
 48            )
 49
 50        # Good chance it will redirect to a more final URL (which we hope is versioned)
 51        url = response.url
 52        version = self.parse_version_from_url(url)
 53
 54        return version, response
 55
 56    def install(self):
 57        if self.installed:
 58            version, response = self.download(self.installed)
 59            if version != self.installed:
 60                raise VersionMismatchError(
 61                    f"Version mismatch for {self.name}: {self.installed} != {version}"
 62                )
 63            return self.vendor(response)
 64        else:
 65            return self.update()
 66
 67    def update(self):
 68        def try_version(v):
 69            try:
 70                version, response = self.download(v)
 71                return version, response
 72            except requests.RequestException:
 73                return "", None
 74
 75        if not self.installed:
 76            # If we don't know the installed version yet,
 77            # just use the url as given
 78            version, response = self.download("")
 79        else:
 80            version, response = try_version("latest")  # A lot of CDNs support this
 81            if not version:
 82                # Try bumping semver major version
 83                current_major = self.installed.split(".")[0]
 84                version, response = try_version(f"{int(current_major) + 1}.0.0")
 85            if not version:
 86                # Try bumping semver minor version
 87                current_minor = self.installed.split(".")[1]
 88                version, response = try_version(
 89                    f"{current_major}.{int(current_minor) + 1}.0"
 90                )
 91            if not version:
 92                # Try bumping semver patch version
 93                current_patch = self.installed.split(".")[2]
 94                version, response = try_version(
 95                    f"{current_major}.{current_minor}.{int(current_patch) + 1}"
 96                )
 97
 98        if not version:
 99            # Use the currently installed version if we found nothing else
100            version, response = self.download(self.installed)
101
102        vendored_path = self.vendor(response)
103        self.installed = version
104        # If the exact version was in the string, replace it with {version} placeholder
105        self.url = self.url.replace(self.installed, "{version}")
106        self.save_config()
107        return vendored_path
108
109    def save_config(self):
110        with open("pyproject.toml") as f:
111            pyproject = tomlkit.load(f)
112
113        pyproject["tool"]["plain"]["vendor"]["dependencies"][self.name] = {
114            "url": self.url,
115            "installed": self.installed,
116        }
117
118        with open("pyproject.toml", "w") as f:
119            f.write(tomlkit.dumps(pyproject))
120
121    def vendor(self, response):
122        if not VENDOR_DIR.exists():
123            VENDOR_DIR.mkdir(parents=True)
124
125        if self.filename:
126            # Use a specific filename from config
127            filename = self.filename
128        else:
129            # Otherwise, use the filename from the URL
130            filename = response.url.split("/")[-1]
131
132        vendored_path = VENDOR_DIR / filename
133
134        with open(vendored_path, "wb") as f:
135            f.write(response.content)
136
137        return vendored_path
138
139
140def get_deps():
141    with open("pyproject.toml") as f:
142        pyproject = tomlkit.load(f)
143
144    config = (
145        pyproject.get("tool", {})
146        .get("plain", {})
147        .get("vendor", {})
148        .get("dependencies", {})
149    )
150
151    deps = []
152
153    for name, data in config.items():
154        deps.append(Dependency(name, **data))
155
156    return deps