Skip to content

Commit eddd4f9

Browse files
authored
Add suppport for external ports (#21316)
1 parent 24697df commit eddd4f9

File tree

10 files changed

+225
-31
lines changed

10 files changed

+225
-31
lines changed

ChangeLog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ See docs/process.md for more on how version tagging works.
4242
available via `--use-port=contrib.glfw3`: an emscripten port of glfw written
4343
in C++ with many features like support for multiple windows. (#21244 and
4444
#21276)
45+
- Added concept of external ports which live outside emscripten and are
46+
loaded on demand using the syntax `--use-port=/path/to/my_port.py` (#21316)
4547

4648

4749
3.1.53 - 01/29/24

site/source/docs/compiling/Building-Projects.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,15 @@ The simplest way to add a new port is to put it under the ``contrib`` directory.
278278
* Make sure the port is open source and has a suitable license.
279279
* Read the ``README.md`` file under ``tools/ports/contrib`` which contains more information.
280280

281+
External ports
282+
--------------
283+
284+
Emscripten also supports external ports (ports that are not part of the
285+
distribution). In order to use such a port, you simply provide its path:
286+
``--use-port=/path/to/my_port.py``
287+
288+
.. note:: Be aware that if you are working on the code of a port, the port API
289+
used by emscripten is not 100% stable and could change between versions.
281290

282291
Build system issues
283292
===================

test/other/ports/external.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright 2024 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 os
7+
from typing import Dict, Optional
8+
9+
OPTIONS = {
10+
'value1': 'Value for define TEST_VALUE_1',
11+
'value2': 'Value for define TEST_VALUE_2',
12+
'dependency': 'A dependency'
13+
}
14+
15+
# user options (from --use-port)
16+
opts: Dict[str, Optional[str]] = {
17+
'value1': None,
18+
'value2': None,
19+
'dependency': None
20+
}
21+
22+
deps = []
23+
24+
25+
def get_lib_name(settings):
26+
return 'lib_external.a'
27+
28+
29+
def get(ports, settings, shared):
30+
# for simplicity in testing, the source is in the same folder as the port and not fetched as a tarball
31+
source_dir = os.path.dirname(__file__)
32+
33+
def create(final):
34+
ports.install_headers(source_dir)
35+
print(f'about to build {source_dir}')
36+
ports.build_port(source_dir, final, 'external')
37+
38+
return [shared.cache.get_lib(get_lib_name(settings), create, what='port')]
39+
40+
41+
def clear(ports, settings, shared):
42+
shared.cache.erase_lib(get_lib_name(settings))
43+
44+
45+
def process_args(ports):
46+
args = ['-isystem', ports.get_include_dir('external')]
47+
if opts['value1']:
48+
args.append(f'-DTEST_VALUE_1={opts["value1"]}')
49+
if opts['value2']:
50+
args.append(f'-DTEST_VALUE_2={opts["value2"]}')
51+
if opts['dependency']:
52+
args.append(f'-DTEST_DEPENDENCY_{opts["dependency"].upper()}')
53+
return args
54+
55+
56+
def process_dependencies(settings):
57+
if opts['dependency']:
58+
deps.append(opts['dependency'])
59+
60+
61+
def handle_options(options):
62+
opts.update(options)

test/other/ports/my_port.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
int my_port_fn(int value) {
2+
return value;
3+
}

test/other/ports/my_port.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
int my_port_fn(int);

test/other/ports/simple.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Copyright 2024 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 os
7+
8+
9+
def get_lib_name(settings):
10+
return 'lib_simple.a'
11+
12+
13+
def get(ports, settings, shared):
14+
# for simplicity in testing, the source is in the same folder as the port and not fetched as a tarball
15+
source_dir = os.path.dirname(__file__)
16+
17+
def create(final):
18+
ports.install_headers(source_dir)
19+
ports.build_port(source_dir, final, 'simple')
20+
21+
return [shared.cache.get_lib(get_lib_name(settings), create, what='port')]
22+
23+
24+
def clear(ports, settings, shared):
25+
shared.cache.erase_lib(get_lib_name(settings))

test/other/test_external_ports.c

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*
2+
* Copyright 2024 The Emscripten Authors. All rights reserved.
3+
* Emscripten is available under two separate licenses, the MIT license and the
4+
* University of Illinois/NCSA Open Source License. Both these licenses can be
5+
* found in the LICENSE file.
6+
*/
7+
8+
#include <my_port.h>
9+
#include <assert.h>
10+
#include <stdio.h>
11+
12+
#ifdef TEST_DEPENDENCY_SDL2
13+
#include <SDL2/SDL.h>
14+
#endif
15+
16+
// TEST_VALUE_1 and TEST_VALUE_2 are defined via port options
17+
#ifndef TEST_VALUE_1
18+
#define TEST_VALUE_1 0
19+
#endif
20+
#ifndef TEST_VALUE_2
21+
#define TEST_VALUE_2 0
22+
#endif
23+
24+
int main() {
25+
assert(my_port_fn(99) == 99); // check that we can call a function from my_port.h
26+
printf("value1=%d&value2=%d\n", TEST_VALUE_1, TEST_VALUE_2);
27+
#ifdef TEST_DEPENDENCY_SDL2
28+
SDL_version version;
29+
SDL_VERSION(&version);
30+
printf("sdl2=%d\n", version.major);
31+
#endif
32+
return 0;
33+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/*
2+
* Copyright 2024 The Emscripten Authors. All rights reserved.
3+
* Emscripten is available under two separate licenses, the MIT license and the
4+
* University of Illinois/NCSA Open Source License. Both these licenses can be
5+
* found in the LICENSE file.
6+
*/
7+
8+
#include <my_port.h>
9+
#include <assert.h>
10+
11+
int main() {
12+
assert(my_port_fn(99) == 99); // check that we can call a function from my_port.h
13+
return 0;
14+
}

test/test_other.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2393,6 +2393,31 @@ def test_contrib_ports(self):
23932393
# with a different contrib port when there is another one
23942394
self.emcc(test_file('other/test_contrib_ports.cpp'), ['--use-port=contrib.glfw3'])
23952395

2396+
@crossplatform
2397+
def test_external_ports_simple(self):
2398+
if config.FROZEN_CACHE:
2399+
self.skipTest("test doesn't work with frozen cache")
2400+
simple_port_path = test_file("other/ports/simple.py")
2401+
self.do_runf('other/test_external_ports_simple.c', emcc_args=[f'--use-port={simple_port_path}'])
2402+
2403+
@crossplatform
2404+
def test_external_ports(self):
2405+
if config.FROZEN_CACHE:
2406+
self.skipTest("test doesn't work with frozen cache")
2407+
external_port_path = test_file("other/ports/external.py")
2408+
# testing no option
2409+
self.do_runf('other/test_external_ports.c', 'value1=0&value2=0\n', emcc_args=[f'--use-port={external_port_path}'])
2410+
# testing 1 option
2411+
self.do_runf('other/test_external_ports.c', 'value1=12&value2=0\n', emcc_args=[f'--use-port={external_port_path}:value1=12'])
2412+
# testing 2 options
2413+
self.do_runf('other/test_external_ports.c', 'value1=12&value2=36\n', emcc_args=[f'--use-port={external_port_path}:value1=12:value2=36'])
2414+
# testing dependency
2415+
self.do_runf('other/test_external_ports.c', 'sdl2=2\n', emcc_args=[f'--use-port={external_port_path}:dependency=sdl2'])
2416+
# testing invalid dependency
2417+
stderr = self.expect_fail([EMCC, test_file('other/test_external_ports.c'), f'--use-port={external_port_path}:dependency=invalid', '-o', 'a4.out.js'])
2418+
self.assertFalse(os.path.exists('a4.out.js'))
2419+
self.assertContained('Unknown dependency `invalid` for port `external`', stderr)
2420+
23962421
def test_link_memcpy(self):
23972422
# memcpy can show up *after* optimizations, so after our opportunity to link in libc, so it must be special-cased
23982423
create_file('main.c', r'''
@@ -14528,16 +14553,16 @@ def test_js_preprocess_pre_post(self):
1452814553
def test_use_port_errors(self, compiler):
1452914554
stderr = self.expect_fail([compiler, test_file('hello_world.c'), '--use-port=invalid', '-o', 'out.js'])
1453014555
self.assertFalse(os.path.exists('out.js'))
14531-
self.assertContained('Error with --use-port=invalid | invalid port name: invalid', stderr)
14556+
self.assertContained('Error with `--use-port=invalid` | invalid port name: `invalid`', stderr)
1453214557
stderr = self.expect_fail([compiler, test_file('hello_world.c'), '--use-port=sdl2:opt1=v1', '-o', 'out.js'])
1453314558
self.assertFalse(os.path.exists('out.js'))
14534-
self.assertContained('Error with --use-port=sdl2:opt1=v1 | no options available for port sdl2', stderr)
14559+
self.assertContained('Error with `--use-port=sdl2:opt1=v1` | no options available for port `sdl2`', stderr)
1453514560
stderr = self.expect_fail([compiler, test_file('hello_world.c'), '--use-port=sdl2_image:format=jpg', '-o', 'out.js'])
1453614561
self.assertFalse(os.path.exists('out.js'))
14537-
self.assertContained('Error with --use-port=sdl2_image:format=jpg | format is not supported', stderr)
14562+
self.assertContained('Error with `--use-port=sdl2_image:format=jpg` | `format` is not supported', stderr)
1453814563
stderr = self.expect_fail([compiler, test_file('hello_world.c'), '--use-port=sdl2_image:formats', '-o', 'out.js'])
1453914564
self.assertFalse(os.path.exists('out.js'))
14540-
self.assertContained('Error with --use-port=sdl2_image:formats | formats is missing a value', stderr)
14565+
self.assertContained('Error with `--use-port=sdl2_image:formats` | `formats` is missing a value', stderr)
1454114566
stderr = self.expect_fail([compiler, test_file('hello_world.c'), '--use-port=sdl2_image:formats=jpg:formats=png', '-o', 'out.js'])
1454214567
self.assertFalse(os.path.exists('out.js'))
14543-
self.assertContained('Error with --use-port=sdl2_image:formats=jpg:formats=png | duplicate option formats', stderr)
14568+
self.assertContained('Error with `--use-port=sdl2_image:formats=jpg:formats=png` | duplicate option `formats`', stderr)

tools/ports/__init__.py

Lines changed: 46 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
import os
99
import shutil
1010
import glob
11+
import importlib.util
12+
import sys
1113
from typing import Set
1214
from tools import cache
1315
from tools import config
@@ -33,8 +35,7 @@
3335
logger = logging.getLogger('ports')
3436

3537

36-
def load_port(name):
37-
port = __import__(name, globals(), level=1, fromlist=[None])
38+
def init_port(name, port):
3839
ports.append(port)
3940
port.is_contrib = name.startswith('contrib.')
4041
port.name = name
@@ -60,9 +61,29 @@ def load_port(name):
6061

6162
for variant, extra_settings in port.variants.items():
6263
if variant in port_variants:
63-
utils.exit_with_error('duplicate port variant: %s' % variant)
64+
utils.exit_with_error('duplicate port variant: `%s`' % variant)
6465
port_variants[variant] = (port.name, extra_settings)
6566

67+
validate_port(port)
68+
69+
70+
def load_port_by_name(name):
71+
port = __import__(name, globals(), level=1, fromlist=[None])
72+
init_port(name, port)
73+
74+
75+
def load_port_by_path(path):
76+
name = os.path.splitext(os.path.basename(path))[0]
77+
if name in ports_by_name:
78+
utils.exit_with_error(f'port path [`{path}`] is invalid: duplicate port name `{name}`')
79+
module_name = f'tools.ports.{name}'
80+
spec = importlib.util.spec_from_file_location(module_name, path)
81+
port = importlib.util.module_from_spec(spec)
82+
sys.modules[module_name] = port
83+
spec.loader.exec_module(port)
84+
init_port(name, port)
85+
return name
86+
6687

6788
def validate_port(port):
6889
expected_attrs = ['get', 'clear', 'show']
@@ -74,30 +95,20 @@ def validate_port(port):
7495
assert hasattr(port, a), 'port %s is missing %s' % (port, a)
7596

7697

77-
def validate_ports():
78-
for port in ports:
79-
validate_port(port)
80-
for dep in port.deps:
81-
if dep not in ports_by_name:
82-
utils.exit_with_error('unknown dependency in port: %s' % dep)
83-
84-
8598
@ToolchainProfiler.profile()
8699
def read_ports():
87100
for filename in os.listdir(ports_dir):
88101
if not filename.endswith('.py') or filename == '__init__.py':
89102
continue
90103
filename = os.path.splitext(filename)[0]
91-
load_port(filename)
104+
load_port_by_name(filename)
92105

93106
contrib_dir = os.path.join(ports_dir, 'contrib')
94107
for filename in os.listdir(contrib_dir):
95108
if not filename.endswith('.py') or filename == '__init__.py':
96109
continue
97110
filename = os.path.splitext(filename)[0]
98-
load_port('contrib.' + filename)
99-
100-
validate_ports()
111+
load_port_by_name('contrib.' + filename)
101112

102113

103114
def get_all_files_under(dirname):
@@ -386,6 +397,8 @@ def resolve_dependencies(port_set, settings):
386397
def add_deps(node):
387398
node.process_dependencies(settings)
388399
for d in node.deps:
400+
if d not in ports_by_name:
401+
utils.exit_with_error(f'Unknown dependency `{d}` for port `{node.name}`')
389402
dep = ports_by_name[d]
390403
if dep not in port_set:
391404
port_set.add(dep)
@@ -396,31 +409,38 @@ def add_deps(node):
396409

397410

398411
def handle_use_port_error(arg, message):
399-
utils.exit_with_error(f'Error with --use-port={arg} | {message}')
412+
utils.exit_with_error(f'Error with `--use-port={arg}` | {message}')
400413

401414

402415
def handle_use_port_arg(settings, arg):
403-
args = arg.split(':', 1)
404-
name, options = args[0], None
405-
if len(args) == 2:
406-
options = args[1]
407-
if name not in ports_by_name:
408-
handle_use_port_error(arg, f'invalid port name: {name}')
416+
# Ignore ':' in first or second char of string since we could be dealing with a windows drive separator
417+
pos = arg.find(':', 2)
418+
if pos != -1:
419+
name, options = arg[:pos], arg[pos + 1:]
420+
else:
421+
name, options = arg, None
422+
if name.endswith('.py'):
423+
port_file_path = name
424+
if not os.path.isfile(port_file_path):
425+
handle_use_port_error(arg, f'not a valid port path: {port_file_path}')
426+
name = load_port_by_path(port_file_path)
427+
elif name not in ports_by_name:
428+
handle_use_port_error(arg, f'invalid port name: `{name}`')
409429
ports_needed.add(name)
410430
if options:
411431
port = ports_by_name[name]
412432
if not hasattr(port, 'handle_options'):
413-
handle_use_port_error(arg, f'no options available for port {name}')
433+
handle_use_port_error(arg, f'no options available for port `{name}`')
414434
else:
415435
options_dict = {}
416436
for name_value in options.split(':'):
417437
nv = name_value.split('=', 1)
418438
if len(nv) != 2:
419-
handle_use_port_error(arg, f'{name_value} is missing a value')
439+
handle_use_port_error(arg, f'`{name_value}` is missing a value')
420440
if nv[0] not in port.OPTIONS:
421-
handle_use_port_error(arg, f'{nv[0]} is not supported; available options are {port.OPTIONS}')
441+
handle_use_port_error(arg, f'`{nv[0]}` is not supported; available options are {port.OPTIONS}')
422442
if nv[0] in options_dict:
423-
handle_use_port_error(arg, f'duplicate option {nv[0]}')
443+
handle_use_port_error(arg, f'duplicate option `{nv[0]}`')
424444
options_dict[nv[0]] = nv[1]
425445
port.handle_options(options_dict)
426446

0 commit comments

Comments
 (0)