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 build 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
 16def iter_next_version(version):
 17    if len(version.split(".")) == 2:
 18        major, minor = version.split(".")
 19        yield f"{int(major) + 1}.0"
 20        yield f"{major}.{int(minor) + 1}"
 21    elif len(version.split(".")) == 3:
 22        major, minor, patch = version.split(".")
 23        yield f"{int(major) + 1}.0.0"
 24        yield f"{major}.{int(minor) + 1}.0"
 25        yield f"{major}.{minor}.{int(patch) + 1}"
 26    else:
 27        raise ValueError(f"Unable to iterate next version for {version}")
 28
 29
 30class Dependency:
 31    def __init__(self, name, **config):
 32        self.name = name
 33        self.url = config.get("url", "")
 34        self.installed = config.get("installed", "")
 35        self.filename = config.get("filename", "")
 36        self.sourcemap = config.get("sourcemap", "")
 37
 38    @staticmethod
 39    def parse_version_from_url(url):
 40        if match := re.search(r"\d+\.\d+\.\d+", url):
 41            return match.group(0)
 42
 43        if match := re.search(r"\d+\.\d+", url):
 44            return match.group(0)
 45
 46        return ""
 47
 48    def __str__(self):
 49        return f"{self.name} -> {self.url}"
 50
 51    def download(self, version):
 52        # If the string contains a {version} placeholder, replace it
 53        download_url = self.url.replace("{version}", version)
 54
 55        response = requests.get(download_url)
 56        response.raise_for_status()
 57
 58        content_type = response.headers.get("content-type")
 59        if content_type.lower() not in (
 60            "application/javascript; charset=utf-8",
 61            "text/javascript; charset=utf-8",
 62            "application/json; charset=utf-8",
 63            "text/css; charset=utf-8",
 64        ):
 65            raise UnknownContentTypeError(
 66                f"Unknown content type for {self.name}: {content_type}"
 67            )
 68
 69        # Good chance it will redirect to a more final URL (which we hope is versioned)
 70        url = response.url
 71        version = self.parse_version_from_url(url)
 72
 73        return version, response
 74
 75    def install(self):
 76        if self.installed:
 77            version, response = self.download(self.installed)
 78            if version != self.installed:
 79                raise VersionMismatchError(
 80                    f"Version mismatch for {self.name}: {self.installed} != {version}"
 81                )
 82            return self.vendor(response)
 83        else:
 84            return self.update()
 85
 86    def update(self):
 87        def try_version(v):
 88            try:
 89                version, response = self.download(v)
 90                return version, response
 91            except requests.RequestException:
 92                return "", None
 93
 94        if not self.installed:
 95            # If we don't know the installed version yet,
 96            # just use the url as given
 97            version, response = self.download("")
 98        else:
 99            version, response = try_version("latest")  # A lot of CDNs support this
100            if not version:
101                # Try the next few versions
102                for v in iter_next_version(self.installed):
103                    version, response = try_version(v)
104                    if version:
105                        break
106
107                    # TODO ideally this would keep going -- if we move to 2.0, and no 3.0, try 2.1, 2.2, etc.
108
109        if not version:
110            # Use the currently installed version if we found nothing else
111            version, response = self.download(self.installed)
112
113        vendored_path = self.vendor(response)
114        self.installed = version
115
116        if self.installed:
117            # If the exact version was in the string, replace it with {version} placeholder
118            self.url = self.url.replace(self.installed, "{version}")
119
120        self.save_config()
121        return vendored_path
122
123    def save_config(self):
124        with open("pyproject.toml") as f:
125            pyproject = tomlkit.load(f)
126
127        # Force [tool.plain.vendor.dependencies] to be a table
128        dependencies = tomlkit.table()
129        dependencies.update(
130            pyproject.get("tool", {})
131            .get("plain", {})
132            .get("vendor", {})
133            .get("dependencies", {})
134        )
135
136        # Force [tool.plain.vendor.dependencies.{name}] to be an inline table
137        # name = { url = "https://example.com", installed = "1.0.0" }
138        dependencies[self.name] = tomlkit.inline_table()
139        dependencies[self.name]["url"] = self.url
140        dependencies[self.name]["installed"] = self.installed
141        if self.filename:
142            dependencies[self.name]["filename"] = self.filename
143        if self.sourcemap:
144            dependencies[self.name]["sourcemap"] = self.sourcemap
145
146        # Have to give it the right structure in case they don't exist
147        if "tool" not in pyproject:
148            pyproject["tool"] = tomlkit.table()
149        if "plain" not in pyproject["tool"]:
150            pyproject["tool"]["plain"] = tomlkit.table()
151        if "vendor" not in pyproject["tool"]["plain"]:
152            pyproject["tool"]["plain"]["vendor"] = tomlkit.table()
153
154        pyproject["tool"]["plain"]["vendor"]["dependencies"] = dependencies
155
156        with open("pyproject.toml", "w") as f:
157            f.write(tomlkit.dumps(pyproject))
158
159    def vendor(self, response):
160        if not VENDOR_DIR.exists():
161            VENDOR_DIR.mkdir(parents=True)
162
163        if self.filename:
164            # Use a specific filename from config
165            filename = self.filename
166        else:
167            # Otherwise, use the filename from the URL
168            filename = response.url.split("/")[-1]
169
170        vendored_path = VENDOR_DIR / filename
171
172        with open(vendored_path, "wb") as f:
173            f.write(response.content)
174
175        # If a sourcemap is requested, download it as well
176        if self.sourcemap:
177            if isinstance(self.sourcemap, str):
178                # Use a specific filename from config
179                sourcemap_filename = self.sourcemap
180            else:
181                # Otherwise, append .map to the URL
182                sourcemap_filename = f"{filename}.map"
183
184            sourcemap_url = "/".join(
185                response.url.split("/")[:-1] + [sourcemap_filename]
186            )
187            sourcemap_response = requests.get(sourcemap_url)
188            sourcemap_response.raise_for_status()
189
190            sourcemap_path = VENDOR_DIR / sourcemap_filename
191
192            with open(sourcemap_path, "wb") as f:
193                f.write(sourcemap_response.content)
194
195        return vendored_path
196
197
198def get_deps():
199    with open("pyproject.toml") as f:
200        pyproject = tomlkit.load(f)
201
202    config = (
203        pyproject.get("tool", {})
204        .get("plain", {})
205        .get("vendor", {})
206        .get("dependencies", {})
207    )
208
209    deps = []
210
211    for name, data in config.items():
212        deps.append(Dependency(name, **data))
213
214    return deps