Skip to content

Commit defbbd6

Browse files
tiranbrettcannon
andauthored
bpo-40280: WASM docs and smaller browser builds (GH-32412)
Co-authored-by: Brett Cannon <brett@python.org>
1 parent dc14e33 commit defbbd6

File tree

6 files changed

+231
-54
lines changed

6 files changed

+231
-54
lines changed

Doc/library/sys.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,7 +1183,9 @@ always available.
11831183
System ``platform`` value
11841184
================ ===========================
11851185
AIX ``'aix'``
1186+
Emscripten ``'emscripten'``
11861187
Linux ``'linux'``
1188+
WASI ``'wasi'``
11871189
Windows ``'win32'``
11881190
Windows/Cygwin ``'cygwin'``
11891191
macOS ``'darwin'``

Makefile.pre.in

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -812,8 +812,9 @@ $(DLLLIBRARY) libpython$(LDVERSION).dll.a: $(LIBRARY_OBJS)
812812
# --preload-file turns a relative asset path into an absolute path.
813813

814814
$(WASM_STDLIB): $(srcdir)/Lib/*.py $(srcdir)/Lib/*/*.py \
815-
pybuilddir.txt $(srcdir)/Tools/wasm/wasm_assets.py \
816-
python.html python.worker.js
815+
$(srcdir)/Tools/wasm/wasm_assets.py \
816+
Makefile pybuilddir.txt Modules/Setup.local \
817+
python.html python.worker.js
817818
$(PYTHON_FOR_BUILD) $(srcdir)/Tools/wasm/wasm_assets.py \
818819
--builddir . --prefix $(prefix)
819820

Tools/wasm/README.md

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,21 @@ possible to build for *wasm32-wasi* out-of-the-box yet.
99

1010
## wasm32-emscripten build
1111

12-
Cross compiling to wasm32-emscripten platform needs the [Emscripten](https://emscripten.org/)
13-
tool chain and a build Python interpreter.
14-
All commands below are relative to a repository checkout.
12+
Cross compiling to the wasm32-emscripten platform needs the
13+
[Emscripten](https://emscripten.org/) SDK and a build Python interpreter.
14+
Emscripten 3.1.8 or newer are recommended. All commands below are relative
15+
to a repository checkout.
16+
17+
Christian Heimes maintains a container image with Emscripten SDK, Python
18+
build dependencies, WASI-SDK, wasmtime, and several additional tools.
19+
20+
```
21+
# Fedora, RHEL, CentOS
22+
podman run --rm -ti -v $(pwd):/python-wasm/cpython:Z quay.io/tiran/cpythonbuild:emsdk3
23+
24+
# other
25+
docker run --rm -ti -v $(pwd):/python-wasm/cpython quay.io/tiran/cpythonbuild:emsdk3
26+
```
1527

1628
### Compile a build Python interpreter
1729

@@ -167,3 +179,77 @@ linker options.
167179
- pthread support requires WASM threads and SharedArrayBuffer (bulk memory).
168180
The runtime keeps a pool of web workers around. Each web worker uses
169181
several file descriptors (eventfd, epoll, pipe).
182+
183+
# Hosting Python WASM builds
184+
185+
The simple REPL terminal uses SharedArrayBuffer. For security reasons
186+
browsers only provide the feature in secure environents with cross-origin
187+
isolation. The webserver must send cross-origin headers and correct MIME types
188+
for the JavaScript and WASM files. Otherwise the terminal will fail to load
189+
with an error message like ``Browsers disable shared array buffer``.
190+
191+
## Apache HTTP .htaccess
192+
193+
Place a ``.htaccess`` file in the same directory as ``python.wasm``.
194+
195+
```
196+
# .htaccess
197+
Header set Cross-Origin-Opener-Policy same-origin
198+
Header set Cross-Origin-Embedder-Policy require-corp
199+
200+
AddType application/javascript js
201+
AddType application/wasm wasm
202+
203+
<IfModule mod_deflate.c>
204+
AddOutputFilterByType DEFLATE text/html application/javascript application/wasm
205+
</IfModule>
206+
```
207+
208+
# Detect WebAssembly builds
209+
210+
## Python code
211+
212+
```# python
213+
import os, sys
214+
215+
if sys.platform == "emscripten":
216+
# Python on Emscripten
217+
if sys.platform == "wasi":
218+
# Python on WASI
219+
220+
if os.name == "posix":
221+
# WASM platforms identify as POSIX-like.
222+
# Windows does not provide os.uname().
223+
machine = os.uname().machine
224+
if machine.startswith("wasm"):
225+
# WebAssembly (wasm32 or wasm64)
226+
```
227+
228+
## C code
229+
230+
Emscripten SDK and WASI SDK define several built-in macros. You can dump a
231+
full list of built-ins with ``emcc -dM -E - < /dev/null`` and
232+
``/path/to/wasi-sdk/bin/clang -dM -E - < /dev/null``.
233+
234+
```# C
235+
#ifdef __EMSCRIPTEN__
236+
// Python on Emscripten
237+
#endif
238+
```
239+
240+
* WebAssembly ``__wasm__`` (also ``__wasm``)
241+
* wasm32 ``__wasm32__`` (also ``__wasm32``)
242+
* wasm64 ``__wasm64__``
243+
* Emscripten ``__EMSCRIPTEN__`` (also ``EMSCRIPTEN``)
244+
* Emscripten version ``__EMSCRIPTEN_major__``, ``__EMSCRIPTEN_minor__``, ``__EMSCRIPTEN_tiny__``
245+
* WASI ``__wasi__``
246+
247+
Feature detection flags:
248+
249+
* ``__EMSCRIPTEN_PTHREADS__``
250+
* ``__EMSCRIPTEN_SHARED_MEMORY__``
251+
* ``__wasm_simd128__``
252+
* ``__wasm_sign_ext__``
253+
* ``__wasm_bulk_memory__``
254+
* ``__wasm_atomics__``
255+
* ``__wasm_mutable_globals__``

Tools/wasm/Setup.local.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Module/Setup.local with reduced stdlib
2+
*disabled*
3+
_asyncio
4+
audioop
5+
_bz2
6+
_crypt
7+
_decimal
8+
_pickle
9+
pyexpat _elementtree
10+
_sha3 _blake2
11+
_zoneinfo
12+
xxsubtype
13+
14+
# cjk codecs
15+
#_multibytecodec _codecs_cn _codecs_hk _codecs_iso2022 _codecs_jp _codecs_kr _codecs_tw

Tools/wasm/wasm_assets.py

Lines changed: 115 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
SRCDIR_LIB = SRCDIR / "Lib"
2121

2222
# sysconfig data relative to build dir.
23-
SYSCONFIGDATA_GLOB = "build/lib.*/_sysconfigdata_*.py"
23+
SYSCONFIGDATA = pathlib.PurePath(
24+
"build",
25+
f"lib.emscripten-wasm32-{sys.version_info.major}.{sys.version_info.minor}",
26+
"_sysconfigdata__emscripten_wasm32-emscripten.py",
27+
)
2428

2529
# Library directory relative to $(prefix).
2630
WASM_LIB = pathlib.PurePath("lib")
@@ -38,33 +42,44 @@
3842
OMIT_FILES = (
3943
# regression tests
4044
"test/",
41-
# user interfaces: TK, curses
42-
"curses/",
43-
"idlelib/",
44-
"tkinter/",
45-
"turtle.py",
46-
"turtledemo/",
4745
# package management
4846
"ensurepip/",
4947
"venv/",
5048
# build system
5149
"distutils/",
5250
"lib2to3/",
53-
# concurrency
54-
"concurrent/",
55-
"multiprocessing/",
5651
# deprecated
5752
"asyncore.py",
5853
"asynchat.py",
59-
# Synchronous network I/O and protocols are not supported; for example,
60-
# socket.create_connection() raises an exception:
61-
# "BlockingIOError: [Errno 26] Operation in progress".
54+
"uu.py",
55+
"xdrlib.py",
56+
# other platforms
57+
"_aix_support.py",
58+
"_bootsubprocess.py",
59+
"_osx_support.py",
60+
# webbrowser
61+
"antigravity.py",
62+
"webbrowser.py",
63+
# Pure Python implementations of C extensions
64+
"_pydecimal.py",
65+
"_pyio.py",
66+
# Misc unused or large files
67+
"pydoc_data/",
68+
"msilib/",
69+
)
70+
71+
# Synchronous network I/O and protocols are not supported; for example,
72+
# socket.create_connection() raises an exception:
73+
# "BlockingIOError: [Errno 26] Operation in progress".
74+
OMIT_NETWORKING_FILES = (
6275
"cgi.py",
6376
"cgitb.py",
6477
"email/",
6578
"ftplib.py",
6679
"http/",
6780
"imaplib.py",
81+
"mailbox.py",
82+
"mailcap.py",
6883
"nntplib.py",
6984
"poplib.py",
7085
"smtpd.py",
@@ -77,26 +92,28 @@
7792
"urllib/response.py",
7893
"urllib/robotparser.py",
7994
"wsgiref/",
80-
"xmlrpc/",
81-
# dbm / gdbm
82-
"dbm/",
83-
# other platforms
84-
"_aix_support.py",
85-
"_bootsubprocess.py",
86-
"_osx_support.py",
87-
# webbrowser
88-
"antigravity.py",
89-
"webbrowser.py",
90-
# ctypes
91-
"ctypes/",
92-
# Pure Python implementations of C extensions
93-
"_pydecimal.py",
94-
"_pyio.py",
95-
# Misc unused or large files
96-
"pydoc_data/",
97-
"msilib/",
9895
)
9996

97+
OMIT_MODULE_FILES = {
98+
"_asyncio": ["asyncio/"],
99+
"audioop": ["aifc.py", "sunau.py", "wave.py"],
100+
"_crypt": ["crypt.py"],
101+
"_curses": ["curses/"],
102+
"_ctypes": ["ctypes/"],
103+
"_decimal": ["decimal.py"],
104+
"_dbm": ["dbm/ndbm.py"],
105+
"_gdbm": ["dbm/gnu.py"],
106+
"_json": ["json/"],
107+
"_multiprocessing": ["concurrent/", "multiprocessing/"],
108+
"pyexpat": ["xml/", "xmlrpc/"],
109+
"readline": ["rlcompleter.py"],
110+
"_sqlite3": ["sqlite3/"],
111+
"_ssl": ["ssl.py"],
112+
"_tkinter": ["idlelib/", "tkinter/", "turtle.py", "turtledemo/"],
113+
114+
"_zoneinfo": ["zoneinfo/"],
115+
}
116+
100117
# regression test sub directories
101118
OMIT_SUBDIRS = (
102119
"ctypes/test/",
@@ -105,34 +122,59 @@
105122
)
106123

107124

108-
OMIT_ABSOLUTE = {SRCDIR_LIB / name for name in OMIT_FILES}
109-
OMIT_SUBDIRS_ABSOLUTE = tuple(str(SRCDIR_LIB / name) for name in OMIT_SUBDIRS)
110-
111-
112-
def filterfunc(name: str) -> bool:
113-
return not name.startswith(OMIT_SUBDIRS_ABSOLUTE)
114-
115-
116125
def create_stdlib_zip(
117-
args: argparse.Namespace, compression: int = zipfile.ZIP_DEFLATED, *, optimize: int = 0
126+
args: argparse.Namespace,
127+
*,
128+
optimize: int = 0,
118129
) -> None:
119-
sysconfig_data = list(args.builddir.glob(SYSCONFIGDATA_GLOB))
120-
if not sysconfig_data:
121-
raise ValueError("No sysconfigdata file found")
130+
def filterfunc(name: str) -> bool:
131+
return not name.startswith(args.omit_subdirs_absolute)
122132

123133
with zipfile.PyZipFile(
124-
args.wasm_stdlib_zip, mode="w", compression=compression, optimize=0
134+
args.wasm_stdlib_zip, mode="w", compression=args.compression, optimize=optimize
125135
) as pzf:
136+
if args.compresslevel is not None:
137+
pzf.compresslevel = args.compresslevel
138+
pzf.writepy(args.sysconfig_data)
126139
for entry in sorted(args.srcdir_lib.iterdir()):
127140
if entry.name == "__pycache__":
128141
continue
129-
if entry in OMIT_ABSOLUTE:
142+
if entry in args.omit_files_absolute:
130143
continue
131144
if entry.name.endswith(".py") or entry.is_dir():
132145
# writepy() writes .pyc files (bytecode).
133146
pzf.writepy(entry, filterfunc=filterfunc)
134-
for entry in sysconfig_data:
135-
pzf.writepy(entry)
147+
148+
149+
def detect_extension_modules(args: argparse.Namespace):
150+
modules = {}
151+
152+
# disabled by Modules/Setup.local ?
153+
with open(args.builddir / "Makefile") as f:
154+
for line in f:
155+
if line.startswith("MODDISABLED_NAMES="):
156+
disabled = line.split("=", 1)[1].strip().split()
157+
for modname in disabled:
158+
modules[modname] = False
159+
break
160+
161+
# disabled by configure?
162+
with open(args.sysconfig_data) as f:
163+
data = f.read()
164+
loc = {}
165+
exec(data, globals(), loc)
166+
167+
for name, value in loc["build_time_vars"].items():
168+
if value not in {"yes", "missing", "disabled", "n/a"}:
169+
continue
170+
if not name.startswith("MODULE_"):
171+
continue
172+
if name.endswith(("_CFLAGS", "_DEPS", "_LDFLAGS")):
173+
continue
174+
modname = name.removeprefix("MODULE_").lower()
175+
if modname not in modules:
176+
modules[modname] = value == "yes"
177+
return modules
136178

137179

138180
def path(val: str) -> pathlib.Path:
@@ -147,7 +189,10 @@ def path(val: str) -> pathlib.Path:
147189
type=path,
148190
)
149191
parser.add_argument(
150-
"--prefix", help="install prefix", default=pathlib.Path("/usr/local"), type=path
192+
"--prefix",
193+
help="install prefix",
194+
default=pathlib.Path("/usr/local"),
195+
type=path,
151196
)
152197

153198

@@ -162,6 +207,27 @@ def main():
162207
args.wasm_stdlib = args.wasm_root / WASM_STDLIB
163208
args.wasm_dynload = args.wasm_root / WASM_DYNLOAD
164209

210+
# bpo-17004: zipimport supports only zlib compression.
211+
# Emscripten ZIP_STORED + -sLZ4=1 linker flags results in larger file.
212+
args.compression = zipfile.ZIP_DEFLATED
213+
args.compresslevel = 9
214+
215+
args.sysconfig_data = args.builddir / SYSCONFIGDATA
216+
if not args.sysconfig_data.is_file():
217+
raise ValueError(f"sysconfigdata file {SYSCONFIGDATA} missing.")
218+
219+
extmods = detect_extension_modules(args)
220+
omit_files = list(OMIT_FILES)
221+
omit_files.extend(OMIT_NETWORKING_FILES)
222+
for modname, modfiles in OMIT_MODULE_FILES.items():
223+
if not extmods.get(modname):
224+
omit_files.extend(modfiles)
225+
226+
args.omit_files_absolute = {args.srcdir_lib / name for name in omit_files}
227+
args.omit_subdirs_absolute = tuple(
228+
str(args.srcdir_lib / name) for name in OMIT_SUBDIRS
229+
)
230+
165231
# Empty, unused directory for dynamic libs, but required for site initialization.
166232
args.wasm_dynload.mkdir(parents=True, exist_ok=True)
167233
marker = args.wasm_dynload / ".empty"
@@ -170,7 +236,7 @@ def main():
170236
shutil.copy(args.srcdir_lib / "os.py", args.wasm_stdlib)
171237
# The rest of stdlib that's useful in a WASM context.
172238
create_stdlib_zip(args)
173-
size = round(args.wasm_stdlib_zip.stat().st_size / 1024 ** 2, 2)
239+
size = round(args.wasm_stdlib_zip.stat().st_size / 1024**2, 2)
174240
parser.exit(0, f"Created {args.wasm_stdlib_zip} ({size} MiB)\n")
175241

176242

Tools/wasm/wasm_webserver.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@
1414

1515

1616
class MyHTTPRequestHandler(server.SimpleHTTPRequestHandler):
17+
extensions_map = server.SimpleHTTPRequestHandler.extensions_map.copy()
18+
extensions_map.update(
19+
{
20+
".wasm": "application/wasm",
21+
}
22+
)
23+
1724
def end_headers(self):
1825
self.send_my_headers()
1926
super().end_headers()

0 commit comments

Comments
 (0)