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