Skip to content
File

Blob: src/pyodide/internal/setupPackages.ts

typescript248 lines
1// Copyright (c) 2026 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5import { parseTarInfo } from 'pyodide-internal:tar';
6import { createMetadataFS } from 'pyodide-internal:metadatafs';
7import { LOCKFILE } from 'pyodide-internal:metadata';
8import {
9 invalidateCaches,
10 PythonWorkersInternalError,
11 PythonUserError,
12 simpleRunPython,
13} from 'pyodide-internal:util';
14import { default as EmbeddedPackagesTarReader } from 'pyodide-internal:packages_tar_reader';
15 
16const canonicalizeNameRegex = /[-_.]+/g;
17const DYNLIB_PATH = '/usr/lib';
18 
19/**
20 * Canonicalize a package name. Port of Python's packaging.utils.canonicalize_name.
21 * @param name The package name to canonicalize.
22 * @returns The canonicalize package name.
23 * @private
24 */
25function canonicalizePackageName(name: string): string {
26 return name.replace(canonicalizeNameRegex, '-').toLowerCase();
27}
28 
29// The "name" field in the lockfile is not canonicalized
30export const STDLIB_PACKAGES: string[] = Object.values(LOCKFILE.packages)
31 .filter(({ package_type }) => package_type === 'cpython_module')
32 .map(({ name }) => canonicalizePackageName(name));
33 
34// Each item in the list is an element of the file path, for example
35// `folder/file.txt` -> `["folder", "file.txt"]
36export type FilePath = string[];
37 
38function createTarFsInfo(): TarFSInfo {
39 return {
40 children: new Map(),
41 mode: 0o777,
42 type: '5',
43 modtime: 0,
44 size: 0,
45 path: '',
46 name: '',
47 parts: [],
48 reader: null,
49 };
50}
51 
52/**
53 * VirtualizedDir keeps track of the virtualized view of the site-packages
54 * directory generated for each worker as well as a virtualized view of the dynamic libraries stored
55 * in /usr/lib.
56 */
57class VirtualizedDir {
58 // TODO(soon): Can we use the # syntax here?
59 // eslint-disable-next-line no-restricted-syntax
60 private rootInfo: TarFSInfo; // site-packages directory
61 // TODO(soon): Can we use the # syntax here?
62 // eslint-disable-next-line no-restricted-syntax
63 private dynlibTarFs: TarFSInfo; // /usr/lib directory
64 // TODO(soon): Can we use the # syntax here?
65 // eslint-disable-next-line no-restricted-syntax
66 private soFiles: FilePath[];
67 // TODO(soon): Can we use the # syntax here?
68 // eslint-disable-next-line no-restricted-syntax
69 private loadedRequirements: Set<string>;
70 constructor() {
71 this.rootInfo = createTarFsInfo();
72 this.dynlibTarFs = createTarFsInfo();
73 this.soFiles = [];
74 this.loadedRequirements = new Set();
75 }
76 
77 /**
78 * mountOverlay "overlays" a directory onto the site-packages root directory.
79 * All files and subdirectories in the overlay will be accessible at site-packages by the worker.
80 * If a file or directory already exists, an error is thrown.
81 * @param {TarInfo} overlayInfo The directory that is to be "copied" into site-packages
82 */
83 mountOverlay(overlayInfo: TarFSInfo, dir: InstallDir): void {
84 const dest = dir == 'dynlib' ? this.dynlibTarFs : this.rootInfo;
85 overlayInfo.children!.forEach((val, key) => {
86 if (dest.children!.has(key)) {
87 throw new PythonWorkersInternalError(
88 `File/folder ${key} being written by multiple packages`
89 );
90 }
91 dest.children!.set(key, val);
92 });
93 }
94 
95 /**
96 * A small bundle contains just a single package, it can be thought of as a wheel.
97 *
98 * The entire bundle will be overlaid onto site-packages or /usr/lib depending on its install_dir.
99 *
100 * @param {TarInfo} tarInfo The root tarInfo for the small bundle (See tar.js)
101 * @param {List<String>} soFiles A list of .so files contained in the small bundle
102 * @param {String} requirement The canonicalized package name this small bundle corresponds to
103 * @param {InstallDir} installDir The `install_dir` field from the metadata about the package taken from the lockfile
104 */
105 addSmallBundle(
106 tarInfo: TarFSInfo,
107 soFiles: string[],
108 requirement: string,
109 installDir: InstallDir
110 ): void {
111 for (const soFile of soFiles) {
112 this.soFiles.push(soFile.split('/'));
113 }
114 this.mountOverlay(tarInfo, installDir);
115 this.loadedRequirements.add(requirement);
116 }
117 
118 /**
119 * A big bundle contains multiple packages, each package contained in a folder whose name is the canonicalized package name.
120 * This function overlays the requested packages onto the site-packages directory.
121 * @param {TarInfo} tarInfo The root tarInfo for the big bundle (See tar.js)
122 * @param {List<String>} soFiles A list of .so files contained in the big bundle
123 * @param {List<String>} requirements canonicalized list of packages to pick from the big bundle
124 */
125 addBigBundle(
126 tarInfo: TarFSInfo,
127 soFiles: string[],
128 requirements: Set<string>
129 ): void {
130 // add all the .so files we will need to preload from the big bundle
131 for (const soFile of soFiles) {
132 // If folder is in list of requirements include .so file in list to preload.
133 const [pkg, ...rest] = soFile.split('/');
134 if (requirements.has(pkg!)) {
135 this.soFiles.push(rest);
136 }
137 }
138 
139 for (const req of requirements) {
140 const child = tarInfo.children!.get(req);
141 if (!child) {
142 throw new PythonUserError(
143 `Requirement ${req} not found in pyodide packages tar`
144 );
145 }
146 this.mountOverlay(child, 'site');
147 this.loadedRequirements.add(req);
148 }
149 }
150 
151 getSitePackagesRoot(): TarFSInfo {
152 return this.rootInfo;
153 }
154 
155 getDynlibRoot(): TarFSInfo {
156 return this.dynlibTarFs;
157 }
158 
159 /** Only used for Pyodide 0.26.0a2 */
160 getSoFilesToLoad(): FilePath[] {
161 return this.soFiles;
162 }
163 
164 hasRequirementLoaded(req: string): boolean {
165 return this.loadedRequirements.has(req);
166 }
167 
168 mount(Module: Module, tarFS: EmscriptenFS<TarFSInfo>): void {
169 Module.FS.mkdirTree(Module.FS.sessionSitePackages);
170 Module.FS.mount(
171 tarFS,
172 { info: this.rootInfo },
173 Module.FS.sessionSitePackages
174 );
175 Module.FS.mkdirTree(DYNLIB_PATH);
176 Module.FS.mount(tarFS, { info: this.dynlibTarFs }, DYNLIB_PATH);
177 }
178}
179 
180/**
181 * This stitches together the view of the site packages directory. Each
182 * requirement corresponds to a folder in the original tar file. For each
183 * requirement in the list we grab the corresponding folder and stitch them
184 * together into a combined folder.
185 *
186 * This also returns the list of soFiles in the resulting site-packages
187 * directory so we can preload them.
188 *
189 * TODO(later): This needs to be removed when external package loading is enabled.
190 */
191export function buildVirtualizedDir(): VirtualizedDir {
192 if (EmbeddedPackagesTarReader.read === undefined) {
193 // Package retrieval is enabled, so the embedded tar reader isn't initialized.
194 // All packages, including STDLIB_PACKAGES, are loaded in `loadPackages`.
195 return new VirtualizedDir();
196 }
197 
198 const [bigTarInfo, bigTarSoFiles] = parseTarInfo(EmbeddedPackagesTarReader);
199 
200 const requirementsInBigBundle = new Set(STDLIB_PACKAGES);
201 const res = new VirtualizedDir();
202 res.addBigBundle(bigTarInfo, bigTarSoFiles, requirementsInBigBundle);
203 
204 return res;
205}
206 
207/**
208 * Patch loadPackage:
209 * - in workerd, disable integrity checks
210 * - otherwise, disable it entirely
211 *
212 * TODO: stop using loadPackage in workerd.
213 */
214export function patchLoadPackage(pyodide: Pyodide): void {
215 pyodide.loadPackage = disabledLoadPackage;
216 return;
217}
218 
219function disabledLoadPackage(): never {
220 throw new PythonWorkersInternalError(
221 'pyodide.loadPackage is disabled because packages are encoded in the binary'
222 );
223}
224 
225/**
226 * This mounts the metadataFS (which contains user code).
227 */
228export function mountWorkerFiles(Module: Module): void {
229 Module.FS.mkdirTree('/session/metadata');
230 const mdFS = createMetadataFS(Module);
231 Module.FS.mount(mdFS, {}, '/session/metadata');
232 invalidateCaches(Module);
233}
234 
235/**
236 * Add the directories created by mountLib to sys.path.
237 * Has to run after the runtime is initialized but before memory snapshot is collected.
238 */
239export function adjustSysPath(Module: Module): void {
240 const site_packages = Module.FS.sessionSitePackages;
241 simpleRunPython(
242 Module,
243 `import sys; sys.path.append("/session/metadata"); sys.path.append("${site_packages}"); del sys`
244 );
245}
246 
247export const VIRTUALIZED_DIR = buildVirtualizedDir();