Skip to content

Commit 6f6823d

Browse files
authored
feat(build): publish a source distribution without Node.js (#3295)
1 parent d29d210 commit 6f6823d

9 files changed

Lines changed: 245 additions & 69 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ jobs:
135135
- name: Test Reference count
136136
run: $CI_ENV_EXEC pytest tests/test_reference_count_async.py --browser=${{ matrix.browser }}
137137
- name: Test Wheel Installation
138-
run: $CI_ENV_EXEC pytest tests/test_installation.py --browser=${{ matrix.browser }}
138+
run: $CI_ENV_EXEC pytest tests/test_installation.py::test_install --browser=${{ matrix.browser }}
139139
- name: Test Sync API
140140
run: $CI_ENV_EXEC pytest tests/sync --browser=${{ matrix.browser }} -n ${{ env.WORKERS }} -p pytest_dot_reporter -rfE --color=yes
141141
- name: Test Async API
@@ -196,6 +196,42 @@ jobs:
196196
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
197197
run: $CI_ENV_EXEC pytest tests/async --browser=chromium --browser-channel=${{ matrix.browser-channel }} -n ${{ env.WORKERS }} -p pytest_dot_reporter -rfE --color=yes
198198

199+
sdist:
200+
name: Source distribution
201+
# The sdist has the driver without Node.js, for the installs that reject wheels
202+
# (poetry's installer.no-binary) and the platforms without a wheel. It runs with
203+
# the Node.js of PLAYWRIGHT_NODEJS_PATH.
204+
runs-on: ubuntu-latest
205+
timeout-minutes: 30
206+
steps:
207+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
208+
- name: Set up Python
209+
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
210+
with:
211+
python-version: "3.10"
212+
- name: Set up Node.js
213+
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
214+
with:
215+
node-version: '24'
216+
- name: Install dependencies
217+
run: |
218+
python -m pip install --upgrade pip
219+
pip install -r local-requirements.txt
220+
pip install -r requirements.txt
221+
pip install -e .
222+
- name: Get Playwright
223+
run: python scripts/upstream.py checkout
224+
# The wheel build puts the driver with Node.js into the checkout, for the
225+
# install-deps step below. The test installs the sdist in its own venv.
226+
- name: Build the driver and the wheel
227+
run: python -m build --wheel
228+
- name: Build the sdist
229+
run: python -m build --sdist
230+
- name: Install browser dependencies
231+
run: python -m playwright install-deps chromium
232+
- name: Test the sdist installation
233+
run: pytest tests/test_installation.py::test_install_sdist --browser=chromium
234+
199235
test_examples:
200236
name: Examples
201237
runs-on: ubuntu-22.04

‎.github/workflows/publish.yml‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,20 +48,22 @@ jobs:
4848
run: python scripts/upstream.py checkout
4949
# Each wheel build builds the driver of its platform; the build of
5050
# .upstream/ is shared.
51-
- name: Build the driver and the wheels
51+
- name: Build the driver, the wheels and the sdist
5252
run: |
5353
for wheel in $(python setup.py --list-wheels); do
5454
PLAYWRIGHT_TARGET_WHEEL=$wheel python -m build --wheel --outdir dist
5555
done
56-
- name: Check the wheels
56+
python -m build --sdist --outdir dist
57+
- name: Check the wheels and the sdist
5758
run: |
5859
ls -l dist
5960
test "$(ls dist/*.whl | wc -l)" -eq "$(python setup.py --list-wheels | wc -l)"
61+
test -f "dist/playwright-${TAG#v}.tar.gz"
6062
for file in dist/*; do
61-
if [[ "$(basename "$file")" != "playwright-${TAG#v}-"*.whl ]]; then
62-
echo "Unexpected file for $TAG: $file"
63-
exit 1
64-
fi
63+
case "$(basename "$file")" in
64+
"playwright-${TAG#v}-"*.whl | "playwright-${TAG#v}.tar.gz") ;;
65+
*) echo "Unexpected file for $TAG: $file"; exit 1 ;;
66+
esac
6567
done
6668
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
6769
with:

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ pip install -r local-requirements.txt
2626
pip install -e .
2727
python scripts/upstream.py checkout
2828
python -m build --wheel # builds the driver from .upstream/ into playwright/driver/
29+
python -m build --sdist # the driver without Node.js; runs with PLAYWRIGHT_NODEJS_PATH
2930
playwright install chromium # not --with-deps: sudo is denied here
3031
./test.sh [browser] # tests/sync and tests/async, under xvfb
3132
./scripts/lint.sh # the Lint job of CI; run it before you commit

‎playwright/__main__.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,28 @@
1515
import subprocess
1616
import sys
1717

18-
from playwright._impl._driver import compute_driver_executable, get_driver_env
18+
from playwright._impl._driver import (
19+
compute_driver_executable,
20+
get_driver_env,
21+
node_launch_error,
22+
)
23+
from playwright._impl._errors import Error
1924

2025

2126
def main() -> None:
2227
try:
2328
driver_executable, driver_cli = compute_driver_executable()
24-
completed_process = subprocess.run(
25-
[driver_executable, driver_cli, *sys.argv[1:]], env=get_driver_env()
26-
)
29+
try:
30+
completed_process = subprocess.run(
31+
[driver_executable, driver_cli, *sys.argv[1:]], env=get_driver_env()
32+
)
33+
except OSError as error:
34+
raise node_launch_error(driver_executable, error)
2735
sys.exit(completed_process.returncode)
36+
except Error as error:
37+
# No Node.js to run the driver with: a message, not a traceback.
38+
print(error.message, file=sys.stderr)
39+
sys.exit(1)
2840
except KeyboardInterrupt:
2941
sys.exit(130)
3042

‎playwright/_impl/_driver.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,29 @@
1919
from typing import Tuple
2020

2121
import playwright
22+
from playwright._impl._errors import Error
2223
from playwright._repo_version import version
2324

2425

2526
def compute_driver_executable() -> Tuple[str, str]:
2627
driver_path = Path(inspect.getfile(playwright)).parent / "driver"
2728
cli_path = str(driver_path / "package" / "cli.js")
28-
if sys.platform == "win32":
29-
return (
30-
os.getenv("PLAYWRIGHT_NODEJS_PATH", str(driver_path / "node.exe")),
31-
cli_path,
29+
node_path = os.getenv("PLAYWRIGHT_NODEJS_PATH")
30+
if node_path:
31+
return (node_path, cli_path)
32+
bundled_node = driver_path / ("node.exe" if sys.platform == "win32" else "node")
33+
if not bundled_node.is_file():
34+
# The source distribution ships the driver without Node.js.
35+
raise Error(
36+
"This installation of Playwright does not include Node.js. "
37+
"Set PLAYWRIGHT_NODEJS_PATH pointing to Node.js executable."
3238
)
33-
return (os.getenv("PLAYWRIGHT_NODEJS_PATH", str(driver_path / "node")), cli_path)
39+
return (str(bundled_node), cli_path)
40+
41+
42+
def node_launch_error(executable: str, error: OSError) -> Error:
43+
"""The error for a spawn of the driver that failed."""
44+
return Error(f"Cannot launch Node.js executable at {executable}: {error}")
3445

3546

3647
def get_driver_env() -> dict:

‎playwright/_impl/transport.py‎

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@
2929
from abc import ABC, abstractmethod
3030
from typing import Any, Callable, Dict, List, Optional, Union
3131

32-
from playwright._impl._driver import compute_driver_executable, get_driver_env
32+
from playwright._impl._driver import (
33+
compute_driver_executable,
34+
get_driver_env,
35+
node_launch_error,
36+
)
3337
from playwright._impl.errors import Error
3438
from playwright._impl.utils.tasks import wait_for_next_task
3539

@@ -133,17 +137,20 @@ async def connect(self) -> None:
133137
startupinfo.wShowWindow = subprocess.SW_HIDE
134138

135139
executable_path, entrypoint_path = compute_driver_executable()
136-
self._proc = await asyncio.create_subprocess_exec(
137-
executable_path,
138-
entrypoint_path,
139-
"run-driver",
140-
stdin=asyncio.subprocess.PIPE,
141-
stdout=asyncio.subprocess.PIPE,
142-
stderr=_get_stderr_fileno(),
143-
limit=32768,
144-
env=env,
145-
startupinfo=startupinfo,
146-
)
140+
try:
141+
self._proc = await asyncio.create_subprocess_exec(
142+
executable_path,
143+
entrypoint_path,
144+
"run-driver",
145+
stdin=asyncio.subprocess.PIPE,
146+
stdout=asyncio.subprocess.PIPE,
147+
stderr=_get_stderr_fileno(),
148+
limit=32768,
149+
env=env,
150+
startupinfo=startupinfo,
151+
)
152+
except OSError as error:
153+
raise node_launch_error(executable_path, error)
147154
except Exception as exc:
148155
self.on_error_future.set_exception(exc)
149156
raise exc

‎scripts/build_driver.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
LICENSE - the Node.js license
2929
package/** - the playwright-core npm package
3030
31+
The ``any`` bundle, for the source distribution, has only ``package/**``: it runs
32+
with the Node.js of ``PLAYWRIGHT_NODEJS_PATH``.
33+
3134
A bundle that exists is not built again. ``.upstream/`` must be checked out at the
3235
commit in ``DRIVER_VERSION`` (``python scripts/upstream.py checkout``), with no local
3336
changes. npm uses the registry and the credentials of a root-level ``.npmrc`` when
@@ -63,7 +66,7 @@
6366

6467
class Platform(NamedTuple):
6568
suffix: str # bundle suffix; matches the "zip_name" values in setup.py
66-
node_dir: str # the nodejs.org archive name infix: node-v<ver>-<node_dir>
69+
node_dir: str # the nodejs.org archive name infix: node-v<ver>-<node_dir>; "" for no Node.js
6770
windows: bool
6871

6972

@@ -75,6 +78,7 @@ class Platform(NamedTuple):
7578
Platform("linux-arm64", "linux-arm64", False),
7679
Platform("win32_x64", "win-x64", True),
7780
Platform("win32_arm64", "win-arm64", True),
81+
Platform("any", "", False), # the source distribution: no Node.js
7882
]
7983

8084

@@ -240,7 +244,8 @@ def assemble(
240244
if bundle_dir.exists():
241245
shutil.rmtree(bundle_dir)
242246
bundle_dir.mkdir(parents=True)
243-
fetch_node(platform, node_version, bundle_dir, work_dir)
247+
if platform.node_dir:
248+
fetch_node(platform, node_version, bundle_dir, work_dir)
244249
shutil.copytree(package_dir, bundle_dir / "package")
245250
destination = DRIVER_DIR / f"playwright-{commit}-{platform.suffix}.zip"
246251
zip_bundle(bundle_dir, destination)

‎setup.py‎

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,22 @@
2727
# in .upstream/, the microsoft/playwright checkout at the commit in DRIVER_VERSION,
2828
# and adds the Node.js binary of NODE_VERSION. The bundles are named after the
2929
# commit (driver/playwright-<commit>-<suffix>.zip), so a roll builds new ones.
30+
#
31+
# A sdist build (python -m build --sdist) builds the "any" bundle the same way, but
32+
# without the Node.js binary, and ships it. A wheel built from the sdist packs that
33+
# bundle, so it is py3-none-any and runs with the Node.js of PLAYWRIGHT_NODEJS_PATH.
3034
def read_driver_version() -> str:
3135
return (Path(__file__).parent / "DRIVER_VERSION").read_text().strip()
3236

3337

3438
driver_version = read_driver_version()
3539

40+
# The sdist unpacks with its metadata at the root; a checkout has none there.
41+
building_from_sdist = (Path(__file__).parent / "PKG-INFO").is_file()
42+
43+
# "wheel" is the platform tag of the wheel name, as in base_wheel_bundles.
44+
sdist_bundle = {"wheel": "any.whl", "zip_name": "any"}
45+
3646
base_wheel_bundles = [
3747
{
3848
"wheel": "macosx_10_13_x86_64.whl",
@@ -90,6 +100,7 @@ def read_driver_version() -> str:
90100
exit(0)
91101

92102
from setuptools import setup # noqa: E402
103+
from setuptools.command.sdist import sdist as SDistCommand # noqa: E402
93104

94105
try:
95106
from auditwheel.wheeltools import InWheel
@@ -107,10 +118,18 @@ def extractall(zip: zipfile.ZipFile, path: str) -> None:
107118
os.chmod(extracted_path, attr)
108119

109120

121+
def driver_bundle_path(zip_name: str) -> str:
122+
return f"driver/playwright-{driver_version}-{zip_name}.zip"
123+
124+
110125
def ensure_driver_bundle(zip_name: str) -> None:
111-
destination_path = f"driver/playwright-{driver_version}-{zip_name}.zip"
126+
destination_path = driver_bundle_path(zip_name)
112127
if os.path.exists(destination_path):
113128
return
129+
if building_from_sdist:
130+
raise RuntimeError(
131+
f"Driver bundle {destination_path} is missing from the source distribution."
132+
)
114133
# Build only this platform's bundle, so that a wheel build downloads just the
115134
# one Node.js binary it needs.
116135
build_script = os.path.join(os.path.dirname(__file__), "scripts", "build_driver.py")
@@ -121,6 +140,16 @@ def ensure_driver_bundle(zip_name: str) -> None:
121140
)
122141

123142

143+
class PlaywrightSDistCommand(SDistCommand):
144+
def make_distribution(self) -> None:
145+
# The driver without Node.js, built from .upstream/ like the platform
146+
# bundles; the wheel build of the sdist packs it. The file list comes
147+
# from egg_info (the files that git tracks), so the bundle is added here.
148+
ensure_driver_bundle(sdist_bundle["zip_name"])
149+
self.filelist.append(driver_bundle_path(sdist_bundle["zip_name"]))
150+
super().make_distribution()
151+
152+
124153
class PlaywrightBDistWheelCommand(BDistWheelCommand):
125154
def run(self) -> None:
126155
# setuptools copies the package into build/lib and keeps what is there: a
@@ -132,7 +161,9 @@ def run(self) -> None:
132161
self._download_and_extract_local_driver()
133162

134163
wheel = None
135-
if os.getenv("PLAYWRIGHT_TARGET_WHEEL", None):
164+
if building_from_sdist:
165+
wheel = sdist_bundle
166+
elif os.getenv("PLAYWRIGHT_TARGET_WHEEL", None):
136167
wheel = list(
137168
filter(
138169
lambda wheel: wheel["wheel"]
@@ -162,14 +193,16 @@ def _build_wheel(
162193
# Although the build produces every platform's bundle, only this wheel's
163194
# target platform driver is extracted and packed below, so the wheel
164195
# stays single-platform.
165-
zip_file = f"driver/playwright-{driver_version}-{wheel_bundle['zip_name']}.zip"
196+
zip_file = driver_bundle_path(wheel_bundle["zip_name"])
166197
extract_dir = f"driver/{wheel_bundle['zip_name']}"
167198
if os.path.exists(extract_dir):
168199
shutil.rmtree(extract_dir)
169200
with zipfile.ZipFile(zip_file, "r") as zip:
170201
extractall(zip, extract_dir)
171202
wheel_location = without_platform + wheel_bundle["wheel"]
172-
shutil.copy(base_wheel_location, wheel_location)
203+
# The base wheel is py3-none-any already: the "any" wheel adds to it in place.
204+
if wheel_location != base_wheel_location:
205+
shutil.copy(base_wheel_location, wheel_location)
173206
with zipfile.ZipFile(
174207
wheel_location, mode="a", compression=zipfile.ZIP_DEFLATED
175208
) as zip:
@@ -183,7 +216,8 @@ def _build_wheel(
183216
"playwright/driver/README.md",
184217
f"{wheel_bundle['wheel']} driver package",
185218
)
186-
os.remove(base_wheel_location)
219+
if wheel_location != base_wheel_location:
220+
os.remove(base_wheel_location)
187221
for whlfile in glob.glob(os.path.join(self.dist_dir, "*.whl")):
188222
os.makedirs("wheelhouse", exist_ok=True)
189223
if InWheel:
@@ -197,26 +231,32 @@ def _build_wheel(
197231
def _download_and_extract_local_driver(
198232
self,
199233
) -> None:
200-
zip_names_for_current_system = set(
201-
map(
202-
lambda wheel: wheel["zip_name"],
203-
filter(
204-
lambda wheel: wheel["machine"] == platform.machine().lower()
205-
and wheel["platform"] == sys.platform,
206-
base_wheel_bundles,
207-
),
234+
if building_from_sdist:
235+
zip_name = sdist_bundle["zip_name"]
236+
else:
237+
zip_names_for_current_system = set(
238+
map(
239+
lambda wheel: wheel["zip_name"],
240+
filter(
241+
lambda wheel: wheel["machine"] == platform.machine().lower()
242+
and wheel["platform"] == sys.platform,
243+
base_wheel_bundles,
244+
),
245+
)
208246
)
209-
)
210-
assert len(zip_names_for_current_system) == 1
211-
zip_name = zip_names_for_current_system.pop()
247+
assert len(zip_names_for_current_system) == 1
248+
zip_name = zip_names_for_current_system.pop()
212249
ensure_driver_bundle(zip_name)
213-
zip_file = f"driver/playwright-{driver_version}-{zip_name}.zip"
250+
zip_file = driver_bundle_path(zip_name)
214251
if os.path.exists("playwright/driver"):
215252
shutil.rmtree("playwright/driver")
216253
with zipfile.ZipFile(zip_file, "r") as zip:
217254
extractall(zip, "playwright/driver")
218255

219256

220257
setup(
221-
cmdclass={"bdist_wheel": PlaywrightBDistWheelCommand},
258+
cmdclass={
259+
"sdist": PlaywrightSDistCommand,
260+
"bdist_wheel": PlaywrightBDistWheelCommand,
261+
},
222262
)

0 commit comments

Comments
 (0)