Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ jobs:
- name: Test Reference count
run: $CI_ENV_EXEC pytest tests/test_reference_count_async.py --browser=${{ matrix.browser }}
- name: Test Wheel Installation
run: $CI_ENV_EXEC pytest tests/test_installation.py --browser=${{ matrix.browser }}
run: $CI_ENV_EXEC pytest tests/test_installation.py::test_install --browser=${{ matrix.browser }}
- name: Test Sync API
run: $CI_ENV_EXEC pytest tests/sync --browser=${{ matrix.browser }} -n ${{ env.WORKERS }} -p pytest_dot_reporter -rfE --color=yes
- name: Test Async API
Expand Down Expand Up @@ -196,6 +196,42 @@ jobs:
if: ${{ !cancelled() && steps.install.outcome == 'success' }}
run: $CI_ENV_EXEC pytest tests/async --browser=chromium --browser-channel=${{ matrix.browser-channel }} -n ${{ env.WORKERS }} -p pytest_dot_reporter -rfE --color=yes

sdist:
name: Source distribution
# The sdist has the driver without Node.js, for the installs that reject wheels
# (poetry's installer.no-binary) and the platforms without a wheel. It runs with
# the Node.js of PLAYWRIGHT_NODEJS_PATH.
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.10"
- name: Set up Node.js
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '24'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r local-requirements.txt
pip install -r requirements.txt
pip install -e .
- name: Get Playwright
run: python scripts/upstream.py checkout
# The wheel build puts the driver with Node.js into the checkout, for the
# install-deps step below. The test installs the sdist in its own venv.
- name: Build the driver and the wheel
run: python -m build --wheel
- name: Build the sdist
run: python -m build --sdist
- name: Install browser dependencies
run: python -m playwright install-deps chromium
- name: Test the sdist installation
run: pytest tests/test_installation.py::test_install_sdist --browser=chromium

test_examples:
name: Examples
runs-on: ubuntu-22.04
Expand Down
14 changes: 8 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,22 @@ jobs:
run: python scripts/upstream.py checkout
# Each wheel build builds the driver of its platform; the build of
# .upstream/ is shared.
- name: Build the driver and the wheels
- name: Build the driver, the wheels and the sdist
run: |
for wheel in $(python setup.py --list-wheels); do
PLAYWRIGHT_TARGET_WHEEL=$wheel python -m build --wheel --outdir dist
done
- name: Check the wheels
python -m build --sdist --outdir dist
- name: Check the wheels and the sdist
run: |
ls -l dist
test "$(ls dist/*.whl | wc -l)" -eq "$(python setup.py --list-wheels | wc -l)"
test -f "dist/playwright-${TAG#v}.tar.gz"
for file in dist/*; do
if [[ "$(basename "$file")" != "playwright-${TAG#v}-"*.whl ]]; then
echo "Unexpected file for $TAG: $file"
exit 1
fi
case "$(basename "$file")" in
"playwright-${TAG#v}-"*.whl | "playwright-${TAG#v}.tar.gz") ;;
*) echo "Unexpected file for $TAG: $file"; exit 1 ;;
esac
done
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pip install -r local-requirements.txt
pip install -e .
python scripts/upstream.py checkout
python -m build --wheel # builds the driver from .upstream/ into playwright/driver/
python -m build --sdist # the driver without Node.js; runs with PLAYWRIGHT_NODEJS_PATH
playwright install chromium # not --with-deps: sudo is denied here
./test.sh [browser] # tests/sync and tests/async, under xvfb
./scripts/lint.sh # the Lint job of CI; run it before you commit
Expand Down
20 changes: 16 additions & 4 deletions playwright/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,28 @@
import subprocess
import sys

from playwright._impl._driver import compute_driver_executable, get_driver_env
from playwright._impl._driver import (
compute_driver_executable,
get_driver_env,
node_launch_error,
)
from playwright._impl._errors import Error


def main() -> None:
try:
driver_executable, driver_cli = compute_driver_executable()
completed_process = subprocess.run(
[driver_executable, driver_cli, *sys.argv[1:]], env=get_driver_env()
)
try:
completed_process = subprocess.run(
[driver_executable, driver_cli, *sys.argv[1:]], env=get_driver_env()
)
except OSError as error:
raise node_launch_error(driver_executable, error)
sys.exit(completed_process.returncode)
except Error as error:
# No Node.js to run the driver with: a message, not a traceback.
print(error.message, file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
sys.exit(130)

Expand Down
21 changes: 16 additions & 5 deletions playwright/_impl/_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,29 @@
from typing import Tuple

import playwright
from playwright._impl._errors import Error
from playwright._repo_version import version


def compute_driver_executable() -> Tuple[str, str]:
driver_path = Path(inspect.getfile(playwright)).parent / "driver"
cli_path = str(driver_path / "package" / "cli.js")
if sys.platform == "win32":
return (
os.getenv("PLAYWRIGHT_NODEJS_PATH", str(driver_path / "node.exe")),
cli_path,
node_path = os.getenv("PLAYWRIGHT_NODEJS_PATH")
if node_path:
return (node_path, cli_path)
bundled_node = driver_path / ("node.exe" if sys.platform == "win32" else "node")
if not bundled_node.is_file():
# The source distribution ships the driver without Node.js.
raise Error(
"This installation of Playwright does not include Node.js. "
"Set PLAYWRIGHT_NODEJS_PATH pointing to Node.js executable."
)
return (os.getenv("PLAYWRIGHT_NODEJS_PATH", str(driver_path / "node")), cli_path)
return (str(bundled_node), cli_path)


def node_launch_error(executable: str, error: OSError) -> Error:
"""The error for a spawn of the driver that failed."""
return Error(f"Cannot launch Node.js executable at {executable}: {error}")


def get_driver_env() -> dict:
Expand Down
31 changes: 19 additions & 12 deletions playwright/_impl/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List, Optional, Union

from playwright._impl._driver import compute_driver_executable, get_driver_env
from playwright._impl._driver import (
compute_driver_executable,
get_driver_env,
node_launch_error,
)
from playwright._impl.errors import Error
from playwright._impl.utils.tasks import wait_for_next_task

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

executable_path, entrypoint_path = compute_driver_executable()
self._proc = await asyncio.create_subprocess_exec(
executable_path,
entrypoint_path,
"run-driver",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=_get_stderr_fileno(),
limit=32768,
env=env,
startupinfo=startupinfo,
)
try:
self._proc = await asyncio.create_subprocess_exec(
executable_path,
entrypoint_path,
"run-driver",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=_get_stderr_fileno(),
limit=32768,
env=env,
startupinfo=startupinfo,
)
except OSError as error:
raise node_launch_error(executable_path, error)
except Exception as exc:
self.on_error_future.set_exception(exc)
raise exc
Expand Down
9 changes: 7 additions & 2 deletions scripts/build_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@
LICENSE - the Node.js license
package/** - the playwright-core npm package

The ``any`` bundle, for the source distribution, has only ``package/**``: it runs
with the Node.js of ``PLAYWRIGHT_NODEJS_PATH``.

A bundle that exists is not built again. ``.upstream/`` must be checked out at the
commit in ``DRIVER_VERSION`` (``python scripts/upstream.py checkout``), with no local
changes. npm uses the registry and the credentials of a root-level ``.npmrc`` when
Expand Down Expand Up @@ -63,7 +66,7 @@

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


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


Expand Down Expand Up @@ -240,7 +244,8 @@ def assemble(
if bundle_dir.exists():
shutil.rmtree(bundle_dir)
bundle_dir.mkdir(parents=True)
fetch_node(platform, node_version, bundle_dir, work_dir)
if platform.node_dir:
fetch_node(platform, node_version, bundle_dir, work_dir)
shutil.copytree(package_dir, bundle_dir / "package")
destination = DRIVER_DIR / f"playwright-{commit}-{platform.suffix}.zip"
zip_bundle(bundle_dir, destination)
Expand Down
76 changes: 58 additions & 18 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,22 @@
# in .upstream/, the microsoft/playwright checkout at the commit in DRIVER_VERSION,
# and adds the Node.js binary of NODE_VERSION. The bundles are named after the
# commit (driver/playwright-<commit>-<suffix>.zip), so a roll builds new ones.
#
# A sdist build (python -m build --sdist) builds the "any" bundle the same way, but
# without the Node.js binary, and ships it. A wheel built from the sdist packs that
# bundle, so it is py3-none-any and runs with the Node.js of PLAYWRIGHT_NODEJS_PATH.
def read_driver_version() -> str:
return (Path(__file__).parent / "DRIVER_VERSION").read_text().strip()


driver_version = read_driver_version()

# The sdist unpacks with its metadata at the root; a checkout has none there.
building_from_sdist = (Path(__file__).parent / "PKG-INFO").is_file()

# "wheel" is the platform tag of the wheel name, as in base_wheel_bundles.
sdist_bundle = {"wheel": "any.whl", "zip_name": "any"}

base_wheel_bundles = [
{
"wheel": "macosx_10_13_x86_64.whl",
Expand Down Expand Up @@ -90,6 +100,7 @@ def read_driver_version() -> str:
exit(0)

from setuptools import setup # noqa: E402
from setuptools.command.sdist import sdist as SDistCommand # noqa: E402

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


def driver_bundle_path(zip_name: str) -> str:
return f"driver/playwright-{driver_version}-{zip_name}.zip"


def ensure_driver_bundle(zip_name: str) -> None:
destination_path = f"driver/playwright-{driver_version}-{zip_name}.zip"
destination_path = driver_bundle_path(zip_name)
if os.path.exists(destination_path):
return
if building_from_sdist:
raise RuntimeError(
f"Driver bundle {destination_path} is missing from the source distribution."
)
# Build only this platform's bundle, so that a wheel build downloads just the
# one Node.js binary it needs.
build_script = os.path.join(os.path.dirname(__file__), "scripts", "build_driver.py")
Expand All @@ -121,6 +140,16 @@ def ensure_driver_bundle(zip_name: str) -> None:
)


class PlaywrightSDistCommand(SDistCommand):
def make_distribution(self) -> None:
# The driver without Node.js, built from .upstream/ like the platform
# bundles; the wheel build of the sdist packs it. The file list comes
# from egg_info (the files that git tracks), so the bundle is added here.
ensure_driver_bundle(sdist_bundle["zip_name"])
self.filelist.append(driver_bundle_path(sdist_bundle["zip_name"]))
super().make_distribution()


class PlaywrightBDistWheelCommand(BDistWheelCommand):
def run(self) -> None:
# setuptools copies the package into build/lib and keeps what is there: a
Expand All @@ -132,7 +161,9 @@ def run(self) -> None:
self._download_and_extract_local_driver()

wheel = None
if os.getenv("PLAYWRIGHT_TARGET_WHEEL", None):
if building_from_sdist:
wheel = sdist_bundle
elif os.getenv("PLAYWRIGHT_TARGET_WHEEL", None):
wheel = list(
filter(
lambda wheel: wheel["wheel"]
Expand Down Expand Up @@ -162,14 +193,16 @@ def _build_wheel(
# Although the build produces every platform's bundle, only this wheel's
# target platform driver is extracted and packed below, so the wheel
# stays single-platform.
zip_file = f"driver/playwright-{driver_version}-{wheel_bundle['zip_name']}.zip"
zip_file = driver_bundle_path(wheel_bundle["zip_name"])
extract_dir = f"driver/{wheel_bundle['zip_name']}"
if os.path.exists(extract_dir):
shutil.rmtree(extract_dir)
with zipfile.ZipFile(zip_file, "r") as zip:
extractall(zip, extract_dir)
wheel_location = without_platform + wheel_bundle["wheel"]
shutil.copy(base_wheel_location, wheel_location)
# The base wheel is py3-none-any already: the "any" wheel adds to it in place.
if wheel_location != base_wheel_location:
shutil.copy(base_wheel_location, wheel_location)
with zipfile.ZipFile(
wheel_location, mode="a", compression=zipfile.ZIP_DEFLATED
) as zip:
Expand All @@ -183,7 +216,8 @@ def _build_wheel(
"playwright/driver/README.md",
f"{wheel_bundle['wheel']} driver package",
)
os.remove(base_wheel_location)
if wheel_location != base_wheel_location:
os.remove(base_wheel_location)
for whlfile in glob.glob(os.path.join(self.dist_dir, "*.whl")):
os.makedirs("wheelhouse", exist_ok=True)
if InWheel:
Expand All @@ -197,26 +231,32 @@ def _build_wheel(
def _download_and_extract_local_driver(
self,
) -> None:
zip_names_for_current_system = set(
map(
lambda wheel: wheel["zip_name"],
filter(
lambda wheel: wheel["machine"] == platform.machine().lower()
and wheel["platform"] == sys.platform,
base_wheel_bundles,
),
if building_from_sdist:
zip_name = sdist_bundle["zip_name"]
else:
zip_names_for_current_system = set(
map(
lambda wheel: wheel["zip_name"],
filter(
lambda wheel: wheel["machine"] == platform.machine().lower()
and wheel["platform"] == sys.platform,
base_wheel_bundles,
),
)
)
)
assert len(zip_names_for_current_system) == 1
zip_name = zip_names_for_current_system.pop()
assert len(zip_names_for_current_system) == 1
zip_name = zip_names_for_current_system.pop()
ensure_driver_bundle(zip_name)
zip_file = f"driver/playwright-{driver_version}-{zip_name}.zip"
zip_file = driver_bundle_path(zip_name)
if os.path.exists("playwright/driver"):
shutil.rmtree("playwright/driver")
with zipfile.ZipFile(zip_file, "r") as zip:
extractall(zip, "playwright/driver")


setup(
cmdclass={"bdist_wheel": PlaywrightBDistWheelCommand},
cmdclass={
"sdist": PlaywrightSDistCommand,
"bdist_wheel": PlaywrightBDistWheelCommand,
},
)
Loading
Loading