mirror of
https://github.com/apache/arrow.git
synced 2026-10-29 00:56:31 +08:00
### Rationale for this change Fix #50424 `Python Emscripten Crossbow` job with multiple failing points A) failing to install Chrome - `Requested Chrome major 148, but apt repo currently publishes 150.0.7871.100`, B) and underlying Abseil failure - `error: expected '(' before 'ABSL_INTERNAL_CONSTEXPR_SINCE_CXX17'` [logB](https://github.com/ursacomputing/crossbow/actions/runs/28963702667/job/85941369345#step:6:2995) C) pytest 8.1.1 failing on three tests with the message - `E TypeError: importorskip() got an unexpected keyword argument 'exc_type'` [logC](https://github.com/ursacomputing/crossbow/actions/runs/28972366846/job/85971005157#step:6:6354) The remaining intermittent issue be best dealt with in a follow up PR: D) intermittent `loadPyodide` / Selenium timeout hang - `selenium.common.exceptions.TimeoutException: Message: script timeout` [logD](https://github.com/ursacomputing/crossbow/actions/runs/28997397611/job/86049819728#step:6:6856) and [hang log with debug](https://github.com/ursacomputing/crossbow/actions/runs/29015193964/job/86108340808#step:6:7248) ### What changes are included in this PR? A) Changed `chrome_version: "148"` to `chrome_version: "latest"` B) Disabled zlib for protobuf build of the host in order to avoid conda include path overriding vendor Abseil headers. C) Added work-around for current Pyodide's bundled pytest version 8.1.1 which lacks `importorskip(..., exc_type=...)` ### Are these changes tested? Yes, test-conda-python-emscripten job completed successfully on rerun. Some earlier runs hit the intermittent Chrome/Selenium timeout mentioned above. ### Are there any user-facing changes? No. * GitHub Issue: #50424 Authored-by: Tadeja Kadunc <tadeja.kadunc@gmail.com> Signed-off-by: Sutou Kouhei <kou@clear-code.com>
360 lines
12 KiB
Python
360 lines
12 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
|
|
import argparse
|
|
import contextlib
|
|
import http.server
|
|
import os
|
|
import queue
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import threading
|
|
|
|
from pathlib import Path
|
|
from io import BytesIO
|
|
|
|
from selenium import webdriver
|
|
|
|
|
|
class TemplateOverrider(http.server.SimpleHTTPRequestHandler):
|
|
def log_request(self, code="-", size="-"):
|
|
# don't log successful requests but log errors
|
|
if isinstance(code, int) and code >= 400:
|
|
sys.stderr.write(f"HTTP {code} for {self.path}\n")
|
|
return
|
|
|
|
def do_GET(self) -> bytes | None:
|
|
if self.path.endswith(PYARROW_WHEEL_PATH.name):
|
|
self.send_response(200)
|
|
self.send_header("Content-type", "application/x-zip")
|
|
self.end_headers()
|
|
with PYARROW_WHEEL_PATH.open(mode="rb") as wheel:
|
|
self.copyfile(wheel, self.wfile)
|
|
elif self.path.endswith("/test.html"):
|
|
body = b"""
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<script>
|
|
window.python_done_callback = undefined;
|
|
window.python_logs = [];
|
|
function capturelogs(evt) {
|
|
if ('results' in evt.data) {
|
|
if (window.python_done_callback) {
|
|
let callback = window.python_done_callback;
|
|
window.python_done_callback = undefined;
|
|
callback({result:evt.data.results});
|
|
}
|
|
}
|
|
if ('print' in evt.data) {
|
|
evt.data.print.forEach((x)=>{window.python_logs.push(x)});
|
|
}
|
|
}
|
|
window.pyworker = new Worker("worker.js");
|
|
window.pyworker.onmessage = capturelogs;
|
|
</script>
|
|
</head>
|
|
<body></body>
|
|
</html>
|
|
"""
|
|
self.send_response(200)
|
|
self.send_header("Content-type", "text/html")
|
|
self.send_header("Content-length", len(body))
|
|
self.end_headers()
|
|
self.copyfile(BytesIO(body), self.wfile)
|
|
elif self.path.endswith("/worker.js"):
|
|
body = b"""
|
|
importScripts("./pyodide.js");
|
|
onmessage = async function (e) {
|
|
const data = e.data;
|
|
if (!self.pyodide) {
|
|
self.pyodide = await loadPyodide();
|
|
}
|
|
function do_print(arg) {
|
|
let databytes = Array.from(arg);
|
|
self.postMessage({print:databytes});
|
|
return databytes.length;
|
|
}
|
|
self.pyodide.setStdout({write:do_print,isatty:data.isatty});
|
|
self.pyodide.setStderr({write:do_print,isatty:data.isatty});
|
|
|
|
await self.pyodide.loadPackagesFromImports(data.python);
|
|
let results = await self.pyodide.runPythonAsync(data.python);
|
|
self.postMessage({results});
|
|
}
|
|
"""
|
|
self.send_response(200)
|
|
self.send_header("Content-type", "application/javascript")
|
|
self.send_header("Content-length", len(body))
|
|
self.end_headers()
|
|
self.copyfile(BytesIO(body), self.wfile)
|
|
|
|
else:
|
|
return super().do_GET()
|
|
|
|
def end_headers(self):
|
|
# Enable Cross-Origin Resource Sharing (CORS)
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
super().end_headers()
|
|
|
|
|
|
def run_server_thread(dist_dir, q):
|
|
global _SERVER_ADDRESS
|
|
os.chdir(dist_dir)
|
|
server = http.server.HTTPServer(("127.0.0.1", 0), TemplateOverrider)
|
|
q.put(server.server_address)
|
|
print(f"Starting server for {dist_dir} at: {server.server_address}")
|
|
server.serve_forever()
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def launch_server(dist_dir):
|
|
q = queue.Queue()
|
|
p = threading.Thread(target=run_server_thread, args=[dist_dir, q], daemon=True)
|
|
p.start()
|
|
address = q.get(timeout=50)
|
|
time.sleep(0.1) # wait to make sure server is started
|
|
yield address
|
|
p.terminate()
|
|
|
|
|
|
class NodeDriver:
|
|
import subprocess
|
|
|
|
def __init__(self, hostname, port):
|
|
self.process = subprocess.Popen(
|
|
[shutil.which("script"), "-c", shutil.which("node")],
|
|
stdin=subprocess.PIPE,
|
|
shell=False,
|
|
bufsize=0,
|
|
)
|
|
print(self.process)
|
|
time.sleep(0.1) # wait for node to start
|
|
self.hostname = hostname
|
|
self.port = port
|
|
self.last_ret_code = None
|
|
|
|
def load_pyodide(self, dist_dir):
|
|
self.execute_js(
|
|
f"""
|
|
const {{ loadPyodide }} = require('{dist_dir}/pyodide.js');
|
|
let pyodide = await loadPyodide();
|
|
"""
|
|
)
|
|
|
|
def clear_logs(self):
|
|
pass # we don't handle logs for node
|
|
|
|
def write_stdin(self, buffer):
|
|
# because we use unbuffered IO for
|
|
# stdout, stdin.write is also unbuffered
|
|
# so might under-run on writes
|
|
while len(buffer) > 0 and self.process.poll() is None:
|
|
written = self.process.stdin.write(buffer)
|
|
if written == len(buffer):
|
|
break
|
|
elif written == 0:
|
|
# full buffer - wait
|
|
time.sleep(0.01)
|
|
else:
|
|
buffer = buffer[written:]
|
|
|
|
def execute_js(self, code, wait_for_terminate=True):
|
|
self.write_stdin((code + "\n").encode("utf-8"))
|
|
|
|
def load_arrow(self):
|
|
self.execute_js(f"await pyodide.loadPackage('{PYARROW_WHEEL_PATH}');")
|
|
|
|
def execute_python(self, code, wait_for_terminate=True):
|
|
js_code = f"""
|
|
python = `{code}`;
|
|
await pyodide.loadPackagesFromImports(python);
|
|
python_output = await pyodide.runPythonAsync(python);
|
|
"""
|
|
self.last_ret_code = self.execute_js(js_code, wait_for_terminate)
|
|
return self.last_ret_code
|
|
|
|
def wait_for_done(self):
|
|
# in node we just let it run above
|
|
# then send EOF and join process
|
|
self.write_stdin(b"process.exit(python_output)\n")
|
|
return self.process.wait()
|
|
|
|
|
|
class BrowserDriver:
|
|
def __init__(self, hostname, port, driver):
|
|
self.driver = driver
|
|
self.driver.get(f"http://{hostname}:{port}/test.html")
|
|
# Chrome on CI takes longer than locally to compile.
|
|
self.driver.set_script_timeout(1200)
|
|
|
|
def load_pyodide(self, dist_dir):
|
|
pass
|
|
|
|
def load_arrow(self):
|
|
self.execute_python(
|
|
f"import pyodide_js as pjs\n"
|
|
f"await pjs.loadPackage('{PYARROW_WHEEL_PATH.name}')\n"
|
|
)
|
|
|
|
def execute_python(self, code, wait_for_terminate=True):
|
|
if wait_for_terminate:
|
|
self.driver.execute_async_script(
|
|
f"""
|
|
let callback = arguments[arguments.length-1];
|
|
python = `{code}`;
|
|
window.python_done_callback = callback;
|
|
window.pyworker.postMessage(
|
|
{{python, isatty: {'true' if sys.stdout.isatty() else 'false'}}});
|
|
"""
|
|
)
|
|
else:
|
|
self.driver.execute_script(
|
|
f"""
|
|
let python = `{code}`;
|
|
window.python_done_callback= (x) => {{window.python_script_done=x;}};
|
|
window.pyworker.postMessage(
|
|
{{python,isatty:{'true' if sys.stdout.isatty() else 'false'}}});
|
|
"""
|
|
)
|
|
|
|
def clear_logs(self):
|
|
self.driver.execute_script("window.python_logs = [];")
|
|
|
|
def wait_for_done(self):
|
|
while True:
|
|
# poll for console.log messages from our webworker
|
|
# which are the output of pytest
|
|
lines = self.driver.execute_script(
|
|
"let temp = window.python_logs;window.python_logs=[];return temp;"
|
|
)
|
|
if len(lines) > 0:
|
|
sys.stdout.buffer.write(bytes(lines))
|
|
done = self.driver.execute_script("return window.python_script_done;")
|
|
if done is not None:
|
|
value = done["result"]
|
|
self.driver.execute_script("delete window.python_script_done;")
|
|
return value
|
|
time.sleep(0.1)
|
|
|
|
|
|
class ChromeDriver(BrowserDriver):
|
|
def __init__(self, hostname, port):
|
|
from selenium.webdriver.chrome.options import Options
|
|
|
|
options = Options()
|
|
options.add_argument("--headless")
|
|
options.add_argument("--no-sandbox")
|
|
driver = webdriver.Chrome(options=options)
|
|
driver.command_executor._client_config.timeout = 1200
|
|
super().__init__(hostname, port, driver)
|
|
|
|
|
|
class FirefoxDriver(BrowserDriver):
|
|
def __init__(self, hostname, port):
|
|
from selenium.webdriver.firefox.options import Options
|
|
|
|
options = Options()
|
|
options.add_argument("--headless")
|
|
|
|
super().__init__(hostname, port, webdriver.Firefox(options=options))
|
|
|
|
|
|
def _load_pyarrow_in_runner(driver, wheel_name):
|
|
driver.load_arrow()
|
|
driver.execute_python(
|
|
"""import sys
|
|
import micropip
|
|
if "pyarrow" not in sys.modules:
|
|
await micropip.install("hypothesis")
|
|
import pyodide_js as pjs
|
|
await pjs.loadPackage("tzdata")
|
|
await pjs.loadPackage("numpy")
|
|
await pjs.loadPackage("pandas")
|
|
import pytest
|
|
import inspect
|
|
if "exc_type" not in inspect.signature(pytest.importorskip).parameters:
|
|
_original_importorskip = pytest.importorskip
|
|
def _importorskip(modname, minversion=None, reason=None, *,
|
|
exc_type=ImportError):
|
|
return _original_importorskip(modname, minversion=minversion,
|
|
reason=reason)
|
|
pytest.importorskip = _importorskip
|
|
import pandas # import pandas after pyarrow package load for pandas/pyarrow
|
|
# functions to work
|
|
import pyarrow
|
|
""",
|
|
wait_for_terminate=True,
|
|
)
|
|
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"-d",
|
|
"--dist-dir",
|
|
type=str,
|
|
help="Pyodide distribution directory",
|
|
default="./pyodide",
|
|
)
|
|
parser.add_argument("wheel", type=str, help="Wheel to run tests from")
|
|
parser.add_argument(
|
|
"-t", "--test-submodule", help="Submodule that tests live in", default="test"
|
|
)
|
|
parser.add_argument(
|
|
"-r",
|
|
"--runtime",
|
|
type=str,
|
|
choices=["chrome", "node", "firefox"],
|
|
help="Runtime to run tests in",
|
|
default="chrome",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
PYARROW_WHEEL_PATH = Path(args.wheel).resolve()
|
|
|
|
dist_dir = Path(os.getcwd(), args.dist_dir).resolve()
|
|
print(f"dist dir={dist_dir}")
|
|
with launch_server(dist_dir) as (hostname, port):
|
|
if args.runtime == "chrome":
|
|
driver = ChromeDriver(hostname, port)
|
|
elif args.runtime == "node":
|
|
driver = NodeDriver(hostname, port)
|
|
elif args.runtime == "firefox":
|
|
driver = FirefoxDriver(hostname, port)
|
|
|
|
print("Load pyodide in browser")
|
|
driver.load_pyodide(dist_dir)
|
|
print("Load pyarrow in browser")
|
|
_load_pyarrow_in_runner(driver, Path(args.wheel).name)
|
|
driver.clear_logs()
|
|
print("Run pytest in browser")
|
|
driver.execute_python(
|
|
"""
|
|
import pyarrow,pathlib
|
|
pyarrow_dir = pathlib.Path(pyarrow.__file__).parent
|
|
# Substrait expression serialization crashes pyodide with a
|
|
# "Cannot convert a BigInt value to a number" error.
|
|
pytest.main([pyarrow_dir, '-r', 's', '-m', 'not substrait'])
|
|
""",
|
|
wait_for_terminate=False,
|
|
)
|
|
print("Wait for done")
|
|
os._exit(driver.wait_for_done())
|