Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
emscripten-core
GitHub Repository: emscripten-core/emscripten
Path: blob/main/tools/ports/__init__.py
6161 views
1
# Copyright 2014 The Emscripten Authors. All rights reserved.
2
# Emscripten is available under two separate licenses, the MIT license and the
3
# University of Illinois/NCSA Open Source License. Both these licenses can be
4
# found in the LICENSE file.
5
6
import glob
7
import hashlib
8
import importlib.util
9
import logging
10
import os
11
import shutil
12
import subprocess
13
import sys
14
from inspect import signature
15
from pathlib import Path
16
from urllib.request import urlopen
17
18
from tools import cache, config, shared, system_libs, utils
19
from tools.settings import settings
20
from tools.toolchain_profiler import ToolchainProfiler
21
22
ports = []
23
24
ports_by_name: dict[str, object] = {}
25
26
ports_needed = set()
27
28
# Variant builds that we want to support for certain ports
29
# {variant_name: (port_name, extra_settings)}
30
port_variants = {}
31
32
ports_dir = os.path.dirname(os.path.abspath(__file__))
33
34
logger = logging.getLogger('ports')
35
36
37
def get_port_by_name(name):
38
port = ports_by_name[name]
39
if port.is_external:
40
load_external_port(port)
41
return ports_by_name[name]
42
else:
43
return port
44
45
46
def init_port(name, port):
47
ports.append(port)
48
port.is_contrib = name.startswith('contrib.')
49
port.is_external = hasattr(port, 'EXTERNAL_PORT')
50
port.name = name
51
ports_by_name[port.name] = port
52
if port.is_external:
53
init_external_port(name, port)
54
else:
55
init_local_port(name, port)
56
57
58
def init_local_port(name, port):
59
if not hasattr(port, 'needed'):
60
port.needed = lambda s: name in ports_needed
61
else:
62
needed = port.needed
63
port.needed = lambda s: needed(s) or name in ports_needed
64
if not hasattr(port, 'process_dependencies'):
65
port.process_dependencies = lambda x: 0
66
if not hasattr(port, 'linker_setup'):
67
port.linker_setup = lambda x, y: 0
68
if not hasattr(port, 'deps'):
69
port.deps = []
70
if not hasattr(port, 'process_args'):
71
port.process_args = lambda x: []
72
if not hasattr(port, 'variants'):
73
# port variants (default: no variants)
74
port.variants = {}
75
if not hasattr(port, 'show'):
76
port.show = lambda: f'{port.name} (--use-port={port.name}; {port.LICENSE})'
77
78
for variant, extra_settings in port.variants.items():
79
if variant in port_variants:
80
utils.exit_with_error('duplicate port variant: `%s`' % variant)
81
port_variants[variant] = (port.name, extra_settings)
82
83
validate_port(port)
84
85
86
def load_port_module(module_name, port_file):
87
spec = importlib.util.spec_from_file_location(module_name, port_file)
88
port = importlib.util.module_from_spec(spec)
89
spec.loader.exec_module(port)
90
return port
91
92
93
def load_external_port(external_port):
94
name = external_port.name
95
up_to_date = Ports.fetch_port_artifact(name, external_port.EXTERNAL_PORT, external_port.SHA512)
96
port_file = os.path.join(Ports.get_dir(), name, external_port.PORT_FILE)
97
local_port = load_port_module(f'tools.ports.external.{name}', port_file)
98
ports.remove(external_port)
99
for a in ['URL', 'DESCRIPTION', 'LICENSE']:
100
if not hasattr(local_port, a):
101
setattr(local_port, a, getattr(external_port, a))
102
init_port(name, local_port)
103
if not up_to_date:
104
Ports.clear_project_build(name)
105
106
107
def init_external_port(name, port):
108
expected_attrs = ['SHA512', 'PORT_FILE', 'URL', 'DESCRIPTION', 'LICENSE']
109
for a in expected_attrs:
110
assert hasattr(port, a), 'port %s is missing %s' % (port, a)
111
port.needed = lambda s: name in ports_needed
112
port.show = lambda: f'{port.name} (--use-port={port.name}; {port.LICENSE})'
113
114
115
def load_port(path, name=None):
116
if not name:
117
name = utils.unsuffixed_basename(path)
118
if name in ports_by_name:
119
utils.exit_with_error(f'port path [`{path}`] is invalid: duplicate port name `{name}`')
120
port = load_port_module(f'tools.ports.{name}', path)
121
init_port(name, port)
122
return name
123
124
125
def validate_port(port):
126
expected_attrs = ['get', 'clear', 'show']
127
if port.is_contrib:
128
expected_attrs += ['URL', 'DESCRIPTION', 'LICENSE']
129
if hasattr(port, 'handle_options'):
130
expected_attrs += ['OPTIONS']
131
for a in expected_attrs:
132
assert hasattr(port, a), 'port %s is missing %s' % (port, a)
133
134
135
@ToolchainProfiler.profile()
136
def read_ports():
137
for filename in os.listdir(ports_dir):
138
if not filename.endswith('.py') or filename == '__init__.py':
139
continue
140
load_port(os.path.join(ports_dir, filename))
141
142
contrib_dir = os.path.join(ports_dir, 'contrib')
143
for filename in os.listdir(contrib_dir):
144
if not filename.endswith('.py') or filename == '__init__.py':
145
continue
146
name = 'contrib.' + utils.unsuffixed(filename)
147
load_port(os.path.join(contrib_dir, filename), name)
148
149
150
def get_all_files_under(dirname):
151
for path, _, files in os.walk(dirname):
152
for name in files:
153
yield os.path.join(path, name)
154
155
156
def dir_is_newer(dir_a, dir_b):
157
assert os.path.exists(dir_a)
158
assert os.path.exists(dir_b)
159
files_a = ((x, os.path.getmtime(x)) for x in get_all_files_under(dir_a))
160
files_b = ((x, os.path.getmtime(x)) for x in get_all_files_under(dir_b))
161
newest_a = max(files_a, key=lambda f: f[1])
162
newest_b = max(files_b, key=lambda f: f[1])
163
logger.debug('newest_a: %s %s', *newest_a)
164
logger.debug('newest_b: %s %s', *newest_b)
165
return newest_a[1] > newest_b[1]
166
167
168
def maybe_copy(src, dest):
169
"""Just like shutil.copyfile, but will do nothing if the destination already
170
exists and has the same contents as the source.
171
172
In the case where a library is built in multiple different configurations,
173
we want to avoids racing between processes that are reading headers (without
174
holding the cache lock) (e.g. normal compile steps) and a process that is
175
building/installing a new flavor of a given library. In this case the
176
headers will be "re-installed" but we skip the actual filesystem mods
177
to avoid racing with other processes that might be reading these files.
178
"""
179
if os.path.exists(dest) and utils.read_binary(src) == utils.read_binary(dest):
180
return
181
shutil.copyfile(src, dest)
182
183
184
class Ports:
185
"""emscripten-ports library management (https://github.com/emscripten-ports).
186
"""
187
188
@staticmethod
189
def get_include_dir(*parts):
190
dirname = cache.get_include_dir(*parts)
191
utils.safe_ensure_dirs(dirname)
192
return dirname
193
194
@staticmethod
195
def install_header_dir(src_dir, target=None):
196
"""Like install_headers but recursively copied all files in a directory"""
197
if not target:
198
target = os.path.basename(src_dir)
199
dest = Ports.get_include_dir(target)
200
logger.debug(f'installing headers: {dest}')
201
shutil.copytree(src_dir, dest, dirs_exist_ok=True, copy_function=maybe_copy)
202
203
@staticmethod
204
def install_file(filename, target):
205
sysroot = cache.get_sysroot_dir()
206
target_dir = os.path.join(sysroot, os.path.dirname(target))
207
os.makedirs(target_dir, exist_ok=True)
208
maybe_copy(filename, os.path.join(sysroot, target))
209
210
@staticmethod
211
def install_headers(src_dir, pattern='*.h', target=None):
212
logger.debug('install_headers')
213
dest = Ports.get_include_dir()
214
assert os.path.exists(dest)
215
if target:
216
dest = os.path.join(dest, target)
217
utils.safe_ensure_dirs(dest)
218
matches = glob.glob(os.path.join(src_dir, pattern))
219
assert matches, f'no headers found to install in {src_dir}'
220
for f in matches:
221
logger.debug('installing: ' + os.path.join(dest, os.path.basename(f)))
222
maybe_copy(f, os.path.join(dest, os.path.basename(f)))
223
224
@staticmethod
225
def build_port(src_dir, output_path, port_name, includes=[], flags=[], cxxflags=[], exclude_files=[], exclude_dirs=[], srcs=[]): # noqa
226
mangled_name = str(Path(output_path).relative_to(Path(cache.get_sysroot(True)) / 'lib'))
227
mangled_name = mangled_name.replace(os.sep, '_').replace('.a', '').replace('-emscripten', '')
228
build_dir = os.path.join(Ports.get_build_dir(), port_name, mangled_name)
229
logger.debug(f'build_port: {port_name} {output_path} in {build_dir}')
230
if srcs:
231
srcs = [os.path.join(src_dir, s) for s in srcs]
232
else:
233
srcs = []
234
for root, dirs, files in os.walk(src_dir):
235
for ex in exclude_dirs:
236
if ex in dirs:
237
dirs.remove(ex)
238
for f in files:
239
ext = utils.suffix(f)
240
if ext in ('.c', '.cpp') and not any((excluded in f) for excluded in exclude_files):
241
srcs.append(os.path.join(root, f))
242
243
cflags = system_libs.get_base_cflags(build_dir) + ['-O2', '-I' + src_dir] + flags
244
for include in includes:
245
cflags.append('-I' + include)
246
247
if system_libs.USE_NINJA:
248
os.makedirs(build_dir, exist_ok=True)
249
ninja_file = os.path.join(build_dir, 'build.ninja')
250
system_libs.ensure_sysroot()
251
system_libs.create_ninja_file(srcs, ninja_file, output_path, cflags=cflags)
252
if not os.getenv('EMBUILDER_PORT_BUILD_DEFERRED'):
253
system_libs.run_ninja(build_dir)
254
else:
255
commands = []
256
objects = []
257
for src in srcs:
258
relpath = os.path.relpath(src, src_dir)
259
obj = os.path.join(build_dir, relpath) + '.o'
260
dirname = os.path.dirname(obj)
261
os.makedirs(dirname, exist_ok=True)
262
cmd = [shared.EMCC, '-c', src, '-o', obj] + cflags
263
if utils.suffix(src) in ('.cc', '.cxx', '.cpp'):
264
cmd[0] = shared.EMXX
265
cmd += cxxflags
266
commands.append(cmd)
267
objects.append(obj)
268
269
system_libs.run_build_commands(commands, num_inputs=len(srcs))
270
system_libs.create_lib(output_path, objects)
271
272
return output_path
273
274
@staticmethod
275
def get_dir(*parts):
276
dirname = os.path.join(config.PORTS, *parts)
277
utils.safe_ensure_dirs(dirname)
278
return dirname
279
280
@staticmethod
281
def erase():
282
dirname = Ports.get_dir()
283
utils.delete_dir(dirname)
284
285
@staticmethod
286
def get_build_dir():
287
return system_libs.get_build_dir()
288
289
name_cache: set[str] = set()
290
291
@staticmethod
292
def fetch_port_artifact(name, url, sha512hash=None):
293
"""This function only fetches the port and returns True when the port is up to date, False otherwise"""
294
# To compute the sha512 hash, run `curl URL | sha512sum`.
295
fullname = Ports.get_dir(name)
296
297
if name not in Ports.name_cache: # only mention each port once in log
298
logger.debug(f'including port: {name}')
299
logger.debug(f' (at {fullname})')
300
Ports.name_cache.add(name)
301
302
# EMCC_LOCAL_PORTS: A hacky way to use a local directory for a port. This
303
# is not tested but can be useful for debugging
304
# changes to a port.
305
#
306
# if EMCC_LOCAL_PORTS is set, we use a local directory as our ports. This is useful
307
# for testing. This env var should be in format
308
# name=dir,name=dir
309
# e.g.
310
# sdl2=/home/username/dev/ports/SDL2
311
# so you could run
312
# EMCC_LOCAL_PORTS="sdl2=/home/alon/Dev/ports/SDL2" ./test/runner.py browser.test_sdl2_mouse
313
# this will simply copy that directory into the ports directory for sdl2, and use that. It also
314
# clears the build, so that it is rebuilt from that source.
315
local_ports = os.environ.get('EMCC_LOCAL_PORTS')
316
if local_ports:
317
logger.warning('using local ports: %s' % local_ports)
318
local_ports = [pair.split('=', 1) for pair in local_ports.split(',')]
319
for local_name, path in local_ports:
320
if name == local_name:
321
port = ports_by_name.get(name)
322
if not port:
323
utils.exit_with_error('%s is not a known port' % name)
324
if not hasattr(port, 'SUBDIR'):
325
utils.exit_with_error(f'port {name} lacks .SUBDIR attribute, which we need in order to override it locally, please update it')
326
subdir = port.SUBDIR
327
target = os.path.join(fullname, subdir)
328
329
uptodate_message = f'not grabbing local port: {name} from {path} to {fullname} (subdir: {subdir}) as the destination {target} is newer (run emcc --clear-ports if that is incorrect)'
330
# before acquiring the lock we have an early out if the port already exists
331
if os.path.exists(target) and dir_is_newer(path, target):
332
logger.warning(uptodate_message)
333
return True
334
with cache.lock('unpack local port'):
335
# Another early out in case another process unpackage the library while we were
336
# waiting for the lock
337
if os.path.exists(target) and not dir_is_newer(path, target):
338
logger.warning(uptodate_message)
339
return True
340
logger.warning(f'grabbing local port: {name} from {path} to {fullname} (subdir: {subdir})')
341
utils.delete_dir(fullname)
342
shutil.copytree(path, target)
343
return False
344
345
url_filename = url.rsplit('/')[-1]
346
ext = url_filename.split('.', 1)[1]
347
fullpath = fullname + '.' + ext
348
349
def retrieve():
350
# retrieve from remote server
351
logger.info(f'retrieving port: {name} from {url}')
352
353
if utils.MACOS:
354
# Use `curl` over `urllib` on macOS to avoid issues with
355
# certificate verification.
356
# https://stackoverflow.com/questions/40684543/how-to-make-python-use-ca-certificates-from-mac-os-truststore
357
# Unlike on Windows or Linux, curl is guaranteed to always be
358
# available on macOS.
359
data = subprocess.check_output(['curl', '-sSL', url])
360
else:
361
f = urlopen(url)
362
data = f.read()
363
364
if sha512hash:
365
actual_hash = hashlib.sha512(data).hexdigest()
366
if actual_hash != sha512hash:
367
utils.exit_with_error(f'Unexpected hash: {actual_hash}\n'
368
'If you are updating the port, please update the hash.')
369
utils.write_binary(fullpath, data)
370
371
marker = os.path.join(fullname, '.emscripten_url')
372
373
def unpack():
374
logger.info(f'unpacking port: {name}')
375
utils.safe_ensure_dirs(fullname)
376
shutil.unpack_archive(filename=fullpath, extract_dir=fullname)
377
utils.write_file(marker, url + '\n')
378
379
def up_to_date():
380
return os.path.exists(marker) and utils.read_file(marker).strip() == url
381
382
# before acquiring the lock we have an early out if the port already exists
383
if up_to_date():
384
return True
385
386
# main logic. do this under a cache lock, since we don't want multiple jobs to
387
# retrieve the same port at once
388
cache.ensure() # TODO: find a better place for this (necessary at the moment)
389
with cache.lock('unpack port'):
390
if os.path.exists(fullpath):
391
# Another early out in case another process unpackage the library while we were
392
# waiting for the lock
393
if up_to_date():
394
return True
395
# file exists but tag is bad
396
logger.warning('local copy of port is not correct, retrieving from remote server')
397
utils.delete_dir(fullname)
398
utils.delete_file(fullpath)
399
400
retrieve()
401
unpack()
402
403
return False
404
405
@staticmethod
406
def fetch_project(name, url, sha512hash=None):
407
if not Ports.fetch_port_artifact(name, url, sha512hash):
408
# we unpacked a new version, clear the build in the cache
409
Ports.clear_project_build(name)
410
411
@staticmethod
412
def clear_project_build(name):
413
port = get_port_by_name(name)
414
port.clear(Ports, settings, shared)
415
build_dir = os.path.join(Ports.get_build_dir(), name)
416
logger.debug(f'clearing port build: {name} {build_dir}')
417
utils.delete_dir(build_dir)
418
return build_dir
419
420
@staticmethod
421
def write_file(filename, contents):
422
if os.path.exists(filename) and utils.read_file(filename) == contents:
423
return
424
utils.write_file(filename, contents)
425
426
@staticmethod
427
def make_pkg_config(name, version, flags):
428
pkgconfig_dir = cache.get_sysroot_dir('lib/pkgconfig')
429
filename = os.path.join(pkgconfig_dir, name + '.pc')
430
Ports.write_file(filename, f'''
431
Name: {name}
432
Description: {name} port from emscripten
433
Version: {version}
434
Libs: {flags}
435
Cflags: {flags}
436
''')
437
438
439
class OrderedSet:
440
"""Partial implementation of OrderedSet. Just enough for what we need here."""
441
def __init__(self, items):
442
self.dict = {}
443
for i in items:
444
self.dict[i] = True
445
446
def __repr__(self):
447
return f"OrderedSet({list(self.dict.keys())})"
448
449
def __len__(self):
450
return len(self.dict.keys())
451
452
def copy(self):
453
return OrderedSet(self.dict.keys())
454
455
def __iter__(self):
456
return iter(self.dict.keys())
457
458
def pop(self, index=-1):
459
key = list(self.dict.keys())[index]
460
self.dict.pop(key)
461
return key
462
463
def add(self, item):
464
self.dict[item] = True
465
466
def remove(self, item):
467
del self.dict[item]
468
469
470
def dependency_order(port_list):
471
# Perform topological sort of ports according to the dependency DAG
472
port_map = {p.name: p for p in port_list}
473
474
# Perform depth first search of dependency graph adding nodes to
475
# the stack only after all children have been explored.
476
stack = []
477
unsorted = OrderedSet(port_list)
478
479
def dfs(node):
480
for dep in node.deps:
481
dep, _ = split_port_options(dep)
482
child = port_map[dep]
483
if child in unsorted:
484
unsorted.remove(child)
485
dfs(child)
486
stack.append(node)
487
488
while unsorted:
489
dfs(unsorted.pop())
490
491
return stack
492
493
494
def resolve_dependencies(port_set, settings, cflags_only=False):
495
def add_deps(node):
496
sig = signature(node.process_dependencies)
497
if len(sig.parameters) == 2:
498
# The optional second parameter here is useful for ports that want
499
# to mutate linker-only settings. Modifying these settings during the
500
# compile phase (or in a compile-only) command generates errors.
501
node.process_dependencies(settings, cflags_only)
502
else:
503
node.process_dependencies(settings)
504
for d in node.deps:
505
d, _ = split_port_options(d)
506
if d not in ports_by_name:
507
utils.exit_with_error(f'unknown dependency `{d}` for port `{node.name}`')
508
dep = get_port_by_name(d)
509
if dep not in port_set:
510
port_set.add(dep)
511
add_deps(dep)
512
513
for port in port_set.copy():
514
add_deps(port)
515
516
517
def handle_use_port_error(arg, message):
518
utils.exit_with_error(f'error with `--use-port={arg}` | {message}')
519
520
521
def show_port_help_and_exit(port):
522
print(port.show())
523
if hasattr(port, 'DESCRIPTION'):
524
print(port.DESCRIPTION)
525
if hasattr(port, 'OPTIONS'):
526
print("Options:")
527
for option, desc in port.OPTIONS.items():
528
print(f'* {option}: {desc}')
529
else:
530
print("No options.")
531
if hasattr(port, 'URL'):
532
print(f'More info: {port.URL}')
533
sys.exit(0)
534
535
536
# extract dict and delegate to port.handle_options for handling (format is 'option1=value1:option2=value2')
537
def handle_port_options(name, options, error_handler):
538
port = get_port_by_name(name)
539
if options == 'help':
540
show_port_help_and_exit(port)
541
if not hasattr(port, 'handle_options'):
542
error_handler(f'no options available for port `{name}`')
543
else:
544
options_dict = {}
545
for name_value in options.replace('::', '\0').split(':'):
546
name_value = name_value.replace('\0', ':')
547
nv = name_value.split('=', 1)
548
if len(nv) != 2:
549
error_handler(f'`{name_value}` is missing a value')
550
if nv[0] not in port.OPTIONS:
551
error_handler(f'`{nv[0]}` is not supported; available options are {port.OPTIONS}')
552
if nv[0] in options_dict:
553
error_handler(f'duplicate option `{nv[0]}`')
554
options_dict[nv[0]] = nv[1]
555
port.handle_options(options_dict, error_handler)
556
557
558
# handle port dependencies (ex: deps=['sdl2_image:formats=jpg'])
559
def handle_port_deps(name, error_handler):
560
port = get_port_by_name(name)
561
for dep in port.deps:
562
dep_name, dep_options = split_port_options(dep)
563
if dep_name not in ports_by_name:
564
error_handler(f'unknown dependency `{dep_name}`')
565
if dep_options:
566
handle_port_options(dep_name, dep_options, error_handler)
567
handle_port_deps(dep_name, error_handler)
568
569
570
def split_port_options(arg):
571
# Ignore ':' in first or second char of string since we could be dealing with a windows drive separator
572
pos = arg.find(':', 2)
573
if pos != -1:
574
return arg[:pos], arg[pos + 1:]
575
else:
576
return arg, None
577
578
579
def handle_use_port_arg(settings, arg, error_handler=None):
580
if not error_handler:
581
def error_handler(message):
582
handle_use_port_error(arg, message)
583
name, options = split_port_options(arg)
584
if name.endswith('.py'):
585
port_file_path = name
586
if not os.path.isfile(port_file_path):
587
error_handler(f'not a valid port path: {port_file_path}')
588
name = load_port(port_file_path)
589
elif name not in ports_by_name:
590
error_handler(f'invalid port name: `{name}`')
591
ports_needed.add(name)
592
if options:
593
handle_port_options(name, options, error_handler)
594
handle_port_deps(name, error_handler)
595
return name
596
597
598
def get_needed_ports(settings, cflags_only=False):
599
# Start with directly needed ports, and transitively add dependencies
600
needed = OrderedSet(get_port_by_name(p.name) for p in ports if p.needed(settings))
601
resolve_dependencies(needed, settings, cflags_only)
602
return needed
603
604
605
def build_port(port_name, settings):
606
port = get_port_by_name(port_name)
607
port_set = OrderedSet([port])
608
resolve_dependencies(port_set, settings)
609
for port in dependency_order(port_set):
610
port.get(Ports, settings, shared)
611
612
613
def clear_port(port_name, settings):
614
port = get_port_by_name(port_name)
615
port.clear(Ports, settings, shared)
616
617
618
def clear():
619
Ports.erase()
620
621
622
def get_libs(settings):
623
"""Called add link time to calculate the list of port libraries.
624
Can have the side effect of building and installing the needed ports.
625
"""
626
ret = []
627
needed = get_needed_ports(settings)
628
629
for port in dependency_order(needed):
630
port.linker_setup(Ports, settings)
631
# port.get returns a list of libraries to link
632
ret += port.get(Ports, settings, shared)
633
634
ret.reverse()
635
return ret
636
637
638
def add_cflags(args, settings):
639
"""Called during compile phase add any compiler flags (e.g -Ifoo) needed
640
by the selected ports. Can also add/change settings.
641
642
Can have the side effect of building and installing the needed ports.
643
"""
644
645
# Legacy SDL1 port is not actually a port at all but builtin
646
if settings.USE_SDL == 1:
647
args += ['-I' + Ports.get_include_dir('SDL')]
648
649
needed = get_needed_ports(settings, cflags_only=True)
650
651
# Now get (i.e. build) the ports in dependency order. This is important because the
652
# headers from one port might be needed before we can build the next.
653
for port in dependency_order(needed):
654
# When using embuilder, don't build the dependencies
655
if not os.getenv('EMBUILDER_PORT_BUILD_DEFERRED'):
656
port.get(Ports, settings, shared)
657
args += port.process_args(Ports)
658
659
660
def show_ports():
661
sorted_ports = sorted(ports, key=lambda p: p.name)
662
print('Available official ports:')
663
for port in sorted_ports:
664
if not port.is_contrib:
665
print(' ', port.show())
666
print('Available contrib ports:')
667
for port in sorted_ports:
668
if port.is_contrib:
669
print(' ', port.show())
670
671
672
read_ports()
673
674