playwright-byob 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- playwright_byob-0.1.0/.github/workflows/ci-tests.yml +56 -0
- playwright_byob-0.1.0/.github/workflows/docs.yml +31 -0
- playwright_byob-0.1.0/.github/workflows/mypy.yml +34 -0
- playwright_byob-0.1.0/.github/workflows/ruff-check.yml +25 -0
- playwright_byob-0.1.0/.gitignore +13 -0
- playwright_byob-0.1.0/.python-version +1 -0
- playwright_byob-0.1.0/CHANGELOG.md +34 -0
- playwright_byob-0.1.0/LICENSE +20 -0
- playwright_byob-0.1.0/PKG-INFO +104 -0
- playwright_byob-0.1.0/README.md +77 -0
- playwright_byob-0.1.0/pyproject.toml +82 -0
- playwright_byob-0.1.0/src/playwright_byob/__init__.py +53 -0
- playwright_byob-0.1.0/src/playwright_byob/_chrome.py +502 -0
- playwright_byob-0.1.0/src/playwright_byob/py.typed +0 -0
- playwright_byob-0.1.0/tests/test_chrome.py +314 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
name: CI tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- main
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
strategy:
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
17
|
+
fail-fast: false
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v7
|
|
21
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
22
|
+
uses: actions/setup-python@v6
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
- name: Install dependencies
|
|
26
|
+
run: |
|
|
27
|
+
# we are using the -e flag, so that code cov finds the source.
|
|
28
|
+
# this is not ideal, since installing an editable can technically
|
|
29
|
+
# differ from a normal install in surprising ways.
|
|
30
|
+
pip install -e '.[all]'
|
|
31
|
+
- name: Test with pytest
|
|
32
|
+
run: |
|
|
33
|
+
pip install pytest
|
|
34
|
+
pytest
|
|
35
|
+
|
|
36
|
+
# - name: Upload coverage reports to Codecov
|
|
37
|
+
# uses: codecov/codecov-action@v4
|
|
38
|
+
# with:
|
|
39
|
+
# name: "py${{ matrix.python-version }}"
|
|
40
|
+
# token: ${{ secrets.CODECOV_TOKEN }}
|
|
41
|
+
|
|
42
|
+
test-windows:
|
|
43
|
+
runs-on: windows-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@v7
|
|
46
|
+
- name: Set up Python
|
|
47
|
+
uses: actions/setup-python@v6
|
|
48
|
+
with:
|
|
49
|
+
python-version: "3.14"
|
|
50
|
+
- name: Install dependencies
|
|
51
|
+
run: |
|
|
52
|
+
pip install -e '.[all]'
|
|
53
|
+
- name: Test with pytest
|
|
54
|
+
run: |
|
|
55
|
+
pip install pytest
|
|
56
|
+
pytest
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Documentation
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
pages: write
|
|
11
|
+
id-token: write
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
deploy:
|
|
15
|
+
environment:
|
|
16
|
+
name: github-pages
|
|
17
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/configure-pages@v6
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
- uses: actions/setup-python@v6
|
|
23
|
+
with:
|
|
24
|
+
python-version: 3.x
|
|
25
|
+
- run: pip install zensical mkdocstrings-python
|
|
26
|
+
- run: zensical build --clean
|
|
27
|
+
- uses: actions/upload-pages-artifact@v5
|
|
28
|
+
with:
|
|
29
|
+
path: site
|
|
30
|
+
- uses: actions/deploy-pages@v5
|
|
31
|
+
id: deployment
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: Mypy check
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- "**"
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
mypy:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout repository
|
|
17
|
+
uses: actions/checkout@v7
|
|
18
|
+
|
|
19
|
+
- name: Set up Python
|
|
20
|
+
uses: actions/setup-python@v6
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.14"
|
|
23
|
+
|
|
24
|
+
- name: Install uv
|
|
25
|
+
run: |
|
|
26
|
+
pip install uv
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: |
|
|
30
|
+
uv sync --dev
|
|
31
|
+
|
|
32
|
+
- name: Run Type Checks
|
|
33
|
+
run: |
|
|
34
|
+
uv run mypy .
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Ruff check
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
branches:
|
|
9
|
+
- "**"
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
ruff-check:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v7
|
|
16
|
+
- name: Install Python
|
|
17
|
+
uses: actions/setup-python@v6
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.14"
|
|
20
|
+
- name: Install dependencies
|
|
21
|
+
run: |
|
|
22
|
+
python -m pip install --upgrade pip
|
|
23
|
+
pip install ruff
|
|
24
|
+
- name: Run Ruff
|
|
25
|
+
run: ruff check --output-format=github .
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14.6
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## playwright-byob 0.1.0
|
|
4
|
+
|
|
5
|
+
### New features
|
|
6
|
+
|
|
7
|
+
- Add sync and async helpers, `launch_chrome()` and `async_launch_chrome()`,
|
|
8
|
+
for launching Playwright with a persistent Google Chrome context.
|
|
9
|
+
- Add automatic detection for installed Google Chrome and the platform Chrome
|
|
10
|
+
user data directory on macOS, Windows, and Linux.
|
|
11
|
+
- Add sensible headed Chrome defaults: persistent profile,
|
|
12
|
+
`Default` profile selection, no fixed Playwright viewport, maximized window,
|
|
13
|
+
and removal of Playwright's `--enable-automation` default argument.
|
|
14
|
+
- Add explicit customization for Chrome executable path, user data directory,
|
|
15
|
+
profile directory, Chrome flags, and arbitrary Playwright launch options.
|
|
16
|
+
- Add environment variable overrides via `PLAYWRIGHT_BYOB_CHROME_PATH`,
|
|
17
|
+
`PLAYWRIGHT_BYOB_USER_DATA_DIR`, and `PLAYWRIGHT_BYOB_PROFILE_DIRECTORY`.
|
|
18
|
+
- Add `build_chrome_launch_config()` for tests, dry runs, and inspecting
|
|
19
|
+
resolved launch options without starting a browser.
|
|
20
|
+
- Add typed public exceptions for missing Chrome executables,
|
|
21
|
+
missing Chrome profile directories, and invalid configuration.
|
|
22
|
+
|
|
23
|
+
### Documentation
|
|
24
|
+
|
|
25
|
+
- Add README and Zensical documentation covering installation,
|
|
26
|
+
sync and async usage, defaults, customization, environment variables,
|
|
27
|
+
and privacy guidance.
|
|
28
|
+
- Add coding-agent guidance documenting privacy rules, development commands,
|
|
29
|
+
and design preferences for future maintenance.
|
|
30
|
+
|
|
31
|
+
### Testing
|
|
32
|
+
|
|
33
|
+
- Add a pytest suite that verifies launch configuration and platform detection
|
|
34
|
+
without reading real user browser data or requiring Chrome in CI.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright (c) 2026, playwright-byob authors
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: playwright-byob
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Playwright but bring your own browser
|
|
5
|
+
Project-URL: Homepage, https://nanx.me/playwright-byob/
|
|
6
|
+
Project-URL: Documentation, https://nanx.me/playwright-byob/
|
|
7
|
+
Project-URL: Repository, https://github.com/nanxstats/playwright-byob
|
|
8
|
+
Project-URL: Issues, https://github.com/nanxstats/playwright-byob/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/nanxstats/playwright-byob/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: Nan Xiao <me@nanx.me>
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
23
|
+
Classifier: Topic :: Software Development :: Testing
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: playwright>=1.60.0
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# playwright-byob
|
|
29
|
+
|
|
30
|
+
[](https://pypi.org/project/playwright-byob/)
|
|
31
|
+

|
|
32
|
+
[](https://github.com/nanxstats/playwright-byob/actions/workflows/ci-tests.yml)
|
|
33
|
+
[](https://github.com/nanxstats/playwright-byob/actions/workflows/mypy.yml)
|
|
34
|
+
[](https://github.com/nanxstats/playwright-byob/actions/workflows/ruff-check.yml)
|
|
35
|
+
[](https://nanx.me/playwright-byob/)
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
Bring your own browser to Playwright.
|
|
39
|
+
|
|
40
|
+
playwright-byob is a tiny Python helper for launching Playwright against the
|
|
41
|
+
real Google Chrome installation and profile already present on a machine.
|
|
42
|
+
It keeps the API close to Playwright, but chooses practical defaults for headed,
|
|
43
|
+
persistent Chrome automation.
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install playwright-byob
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
With `uv`:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv add playwright-byob
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from playwright.sync_api import sync_playwright
|
|
61
|
+
from playwright_byob import launch_chrome
|
|
62
|
+
|
|
63
|
+
with sync_playwright() as p:
|
|
64
|
+
context = launch_chrome(p)
|
|
65
|
+
page = context.new_page()
|
|
66
|
+
page.goto("https://example.com")
|
|
67
|
+
print(page.title())
|
|
68
|
+
context.close()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The default launch uses installed Chrome when detected, falls back to
|
|
72
|
+
Playwright's `channel="chrome"`, opens headed, uses the platform Chrome user
|
|
73
|
+
data directory, selects the `Default` profile, disables Playwright's fixed
|
|
74
|
+
viewport, and removes the `--enable-automation` default argument.
|
|
75
|
+
|
|
76
|
+
## Customize the browser or profile
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from playwright.sync_api import sync_playwright
|
|
80
|
+
from playwright_byob import launch_chrome
|
|
81
|
+
|
|
82
|
+
with sync_playwright() as p:
|
|
83
|
+
context = launch_chrome(
|
|
84
|
+
p,
|
|
85
|
+
browser_path="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
86
|
+
user_data_dir="~/Library/Application Support/Google/Chrome",
|
|
87
|
+
profile_directory="Profile 1",
|
|
88
|
+
args=["--window-size=1440,1000"],
|
|
89
|
+
timeout=30_000,
|
|
90
|
+
)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
You can also use environment variables:
|
|
94
|
+
|
|
95
|
+
- `PLAYWRIGHT_BYOB_CHROME_PATH`
|
|
96
|
+
- `PLAYWRIGHT_BYOB_USER_DATA_DIR`
|
|
97
|
+
- `PLAYWRIGHT_BYOB_PROFILE_DIRECTORY`
|
|
98
|
+
|
|
99
|
+
## Privacy note
|
|
100
|
+
|
|
101
|
+
A real Chrome profile can contain cookies, local storage, saved sessions,
|
|
102
|
+
and other sensitive state, **so use this intentionally**.
|
|
103
|
+
Tests in this project never read or launch a real user profile.
|
|
104
|
+
They use temporary directories and fake Playwright objects.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# playwright-byob
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/playwright-byob/)
|
|
4
|
+

|
|
5
|
+
[](https://github.com/nanxstats/playwright-byob/actions/workflows/ci-tests.yml)
|
|
6
|
+
[](https://github.com/nanxstats/playwright-byob/actions/workflows/mypy.yml)
|
|
7
|
+
[](https://github.com/nanxstats/playwright-byob/actions/workflows/ruff-check.yml)
|
|
8
|
+
[](https://nanx.me/playwright-byob/)
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
Bring your own browser to Playwright.
|
|
12
|
+
|
|
13
|
+
playwright-byob is a tiny Python helper for launching Playwright against the
|
|
14
|
+
real Google Chrome installation and profile already present on a machine.
|
|
15
|
+
It keeps the API close to Playwright, but chooses practical defaults for headed,
|
|
16
|
+
persistent Chrome automation.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install playwright-byob
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
With `uv`:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv add playwright-byob
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from playwright.sync_api import sync_playwright
|
|
34
|
+
from playwright_byob import launch_chrome
|
|
35
|
+
|
|
36
|
+
with sync_playwright() as p:
|
|
37
|
+
context = launch_chrome(p)
|
|
38
|
+
page = context.new_page()
|
|
39
|
+
page.goto("https://example.com")
|
|
40
|
+
print(page.title())
|
|
41
|
+
context.close()
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The default launch uses installed Chrome when detected, falls back to
|
|
45
|
+
Playwright's `channel="chrome"`, opens headed, uses the platform Chrome user
|
|
46
|
+
data directory, selects the `Default` profile, disables Playwright's fixed
|
|
47
|
+
viewport, and removes the `--enable-automation` default argument.
|
|
48
|
+
|
|
49
|
+
## Customize the browser or profile
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from playwright.sync_api import sync_playwright
|
|
53
|
+
from playwright_byob import launch_chrome
|
|
54
|
+
|
|
55
|
+
with sync_playwright() as p:
|
|
56
|
+
context = launch_chrome(
|
|
57
|
+
p,
|
|
58
|
+
browser_path="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
59
|
+
user_data_dir="~/Library/Application Support/Google/Chrome",
|
|
60
|
+
profile_directory="Profile 1",
|
|
61
|
+
args=["--window-size=1440,1000"],
|
|
62
|
+
timeout=30_000,
|
|
63
|
+
)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
You can also use environment variables:
|
|
67
|
+
|
|
68
|
+
- `PLAYWRIGHT_BYOB_CHROME_PATH`
|
|
69
|
+
- `PLAYWRIGHT_BYOB_USER_DATA_DIR`
|
|
70
|
+
- `PLAYWRIGHT_BYOB_PROFILE_DIRECTORY`
|
|
71
|
+
|
|
72
|
+
## Privacy note
|
|
73
|
+
|
|
74
|
+
A real Chrome profile can contain cookies, local storage, saved sessions,
|
|
75
|
+
and other sensitive state, **so use this intentionally**.
|
|
76
|
+
Tests in this project never read or launch a real user profile.
|
|
77
|
+
They use temporary directories and fake Playwright objects.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "playwright-byob"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Playwright but bring your own browser"
|
|
5
|
+
authors = [{ name = "Nan Xiao", email = "me@nanx.me" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
dependencies = ["playwright>=1.60.0"]
|
|
8
|
+
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 3 - Alpha",
|
|
11
|
+
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
|
|
14
|
+
"Topic :: Software Development :: Testing",
|
|
15
|
+
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
|
16
|
+
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
requires-python = ">=3.10"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://nanx.me/playwright-byob/"
|
|
33
|
+
Documentation = "https://nanx.me/playwright-byob/"
|
|
34
|
+
Repository = "https://github.com/nanxstats/playwright-byob"
|
|
35
|
+
Issues = "https://github.com/nanxstats/playwright-byob/issues"
|
|
36
|
+
Changelog = "https://github.com/nanxstats/playwright-byob/blob/main/CHANGELOG.md"
|
|
37
|
+
|
|
38
|
+
[build-system]
|
|
39
|
+
requires = ["hatchling"]
|
|
40
|
+
build-backend = "hatchling.build"
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.sdist]
|
|
43
|
+
exclude = ["/docs", "/scripts", "/AGENTS.md", "/uv.lock", "/zensical.toml"]
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
exclude = ["/docs", "/scripts", "/AGENTS.md", "/uv.lock", "/zensical.toml"]
|
|
47
|
+
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = [
|
|
50
|
+
"isort>=8.0.1",
|
|
51
|
+
"mkdocstrings-python>=2.0.5",
|
|
52
|
+
"mypy>=2.1.0",
|
|
53
|
+
"pytest>=9.1.1",
|
|
54
|
+
"ruff>=0.15.19",
|
|
55
|
+
"zensical>=0.0.46",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
exclude = ["vendor", ".venv"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint]
|
|
62
|
+
select = [
|
|
63
|
+
# pycodestyle
|
|
64
|
+
"E",
|
|
65
|
+
# Pyflakes
|
|
66
|
+
"F",
|
|
67
|
+
# pyupgrade
|
|
68
|
+
"UP",
|
|
69
|
+
# flake8-bugbear
|
|
70
|
+
"B",
|
|
71
|
+
# flake8-simplify
|
|
72
|
+
"SIM",
|
|
73
|
+
# isort
|
|
74
|
+
"I",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[tool.isort]
|
|
78
|
+
profile = "black"
|
|
79
|
+
skip = ["vendor", ".venv"]
|
|
80
|
+
|
|
81
|
+
[tool.mypy]
|
|
82
|
+
exclude = ["vendor/"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Bring your own browser to Playwright."""
|
|
2
|
+
|
|
3
|
+
from ._chrome import (
|
|
4
|
+
CHROME_PATH_ENV,
|
|
5
|
+
DEFAULT_CHANNEL,
|
|
6
|
+
DEFAULT_CHROME_ARGS,
|
|
7
|
+
DEFAULT_IGNORE_DEFAULT_ARGS,
|
|
8
|
+
DEFAULT_PROFILE_DIRECTORY,
|
|
9
|
+
PROFILE_DIRECTORY_ENV,
|
|
10
|
+
USER_DATA_DIR_ENV,
|
|
11
|
+
ChromeLaunchConfig,
|
|
12
|
+
ChromeNotFoundError,
|
|
13
|
+
ChromeProfileNotFoundError,
|
|
14
|
+
ConfigurationError,
|
|
15
|
+
IgnoreDefaultArgs,
|
|
16
|
+
PathLike,
|
|
17
|
+
PathSetting,
|
|
18
|
+
PlaywrightByobError,
|
|
19
|
+
ProfileSetting,
|
|
20
|
+
async_launch_chrome,
|
|
21
|
+
build_chrome_launch_config,
|
|
22
|
+
chrome_executable_candidates,
|
|
23
|
+
chrome_user_data_dir_candidates,
|
|
24
|
+
detect_chrome_executable,
|
|
25
|
+
detect_chrome_user_data_dir,
|
|
26
|
+
launch_chrome,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"CHROME_PATH_ENV",
|
|
31
|
+
"DEFAULT_CHANNEL",
|
|
32
|
+
"DEFAULT_CHROME_ARGS",
|
|
33
|
+
"DEFAULT_IGNORE_DEFAULT_ARGS",
|
|
34
|
+
"DEFAULT_PROFILE_DIRECTORY",
|
|
35
|
+
"PROFILE_DIRECTORY_ENV",
|
|
36
|
+
"USER_DATA_DIR_ENV",
|
|
37
|
+
"ChromeLaunchConfig",
|
|
38
|
+
"ChromeNotFoundError",
|
|
39
|
+
"ChromeProfileNotFoundError",
|
|
40
|
+
"ConfigurationError",
|
|
41
|
+
"IgnoreDefaultArgs",
|
|
42
|
+
"PathLike",
|
|
43
|
+
"PathSetting",
|
|
44
|
+
"ProfileSetting",
|
|
45
|
+
"PlaywrightByobError",
|
|
46
|
+
"async_launch_chrome",
|
|
47
|
+
"build_chrome_launch_config",
|
|
48
|
+
"chrome_executable_candidates",
|
|
49
|
+
"chrome_user_data_dir_candidates",
|
|
50
|
+
"detect_chrome_executable",
|
|
51
|
+
"detect_chrome_user_data_dir",
|
|
52
|
+
"launch_chrome",
|
|
53
|
+
]
|
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"""Utilities for launching Playwright with an installed Google Chrome profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal, TypeAlias
|
|
12
|
+
|
|
13
|
+
from playwright.async_api import BrowserContext as AsyncBrowserContext
|
|
14
|
+
from playwright.async_api import Playwright as AsyncPlaywright
|
|
15
|
+
from playwright.sync_api import BrowserContext as SyncBrowserContext
|
|
16
|
+
from playwright.sync_api import Playwright as SyncPlaywright
|
|
17
|
+
|
|
18
|
+
Auto: TypeAlias = Literal["auto"]
|
|
19
|
+
PathLike: TypeAlias = str | os.PathLike[str]
|
|
20
|
+
PathSetting: TypeAlias = PathLike | Auto | None
|
|
21
|
+
ProfileSetting: TypeAlias = str | Auto | None
|
|
22
|
+
IgnoreDefaultArgs: TypeAlias = bool | Sequence[str] | None
|
|
23
|
+
|
|
24
|
+
CHROME_PATH_ENV = "PLAYWRIGHT_BYOB_CHROME_PATH"
|
|
25
|
+
USER_DATA_DIR_ENV = "PLAYWRIGHT_BYOB_USER_DATA_DIR"
|
|
26
|
+
PROFILE_DIRECTORY_ENV = "PLAYWRIGHT_BYOB_PROFILE_DIRECTORY"
|
|
27
|
+
DEFAULT_CHANNEL = "chrome"
|
|
28
|
+
DEFAULT_PROFILE_DIRECTORY = "Default"
|
|
29
|
+
DEFAULT_IGNORE_DEFAULT_ARGS: tuple[str, ...] = ("--enable-automation",)
|
|
30
|
+
DEFAULT_CHROME_ARGS: tuple[str, ...] = (
|
|
31
|
+
"--disable-blink-features=AutomationControlled",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class PlaywrightByobError(RuntimeError):
|
|
36
|
+
"""Base exception for playwright-byob failures."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ChromeNotFoundError(PlaywrightByobError):
|
|
40
|
+
"""Raised when an explicitly requested Chrome executable cannot be found."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ChromeProfileNotFoundError(PlaywrightByobError):
|
|
44
|
+
"""Raised when the default Chrome user data directory cannot be found."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ConfigurationError(PlaywrightByobError, ValueError):
|
|
48
|
+
"""Raised when launch configuration is invalid."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ChromeLaunchConfig:
|
|
53
|
+
"""Resolved arguments for ``chromium.launch_persistent_context``.
|
|
54
|
+
|
|
55
|
+
``user_data_dir`` is passed as the first positional argument. ``options`` is
|
|
56
|
+
expanded as keyword arguments.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
user_data_dir: Path
|
|
60
|
+
options: Mapping[str, Any]
|
|
61
|
+
|
|
62
|
+
def to_playwright_kwargs(self) -> dict[str, Any]:
|
|
63
|
+
"""Return a mutable copy of the keyword arguments for Playwright."""
|
|
64
|
+
return dict(self.options)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def chrome_executable_candidates(
|
|
68
|
+
*,
|
|
69
|
+
sys_platform: str | None = None,
|
|
70
|
+
env: Mapping[str, str] | None = None,
|
|
71
|
+
) -> tuple[Path, ...]:
|
|
72
|
+
"""Return plausible Google Chrome executable paths for the current platform.
|
|
73
|
+
|
|
74
|
+
The function only builds candidates; it does not read profile data or launch
|
|
75
|
+
Chrome. The ``PLAYWRIGHT_BYOB_CHROME_PATH`` environment variable, when set,
|
|
76
|
+
is returned first.
|
|
77
|
+
"""
|
|
78
|
+
platform = sys_platform or sys.platform
|
|
79
|
+
environ = os.environ if env is None else env
|
|
80
|
+
candidates: list[Path] = []
|
|
81
|
+
|
|
82
|
+
env_path = environ.get(CHROME_PATH_ENV)
|
|
83
|
+
if env_path:
|
|
84
|
+
candidates.append(Path(env_path).expanduser())
|
|
85
|
+
|
|
86
|
+
if platform == "darwin":
|
|
87
|
+
candidates.append(
|
|
88
|
+
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
|
|
89
|
+
)
|
|
90
|
+
home = _home_path(environ)
|
|
91
|
+
if home is not None:
|
|
92
|
+
candidates.append(
|
|
93
|
+
home
|
|
94
|
+
/ "Applications"
|
|
95
|
+
/ "Google Chrome.app"
|
|
96
|
+
/ "Contents"
|
|
97
|
+
/ "MacOS"
|
|
98
|
+
/ "Google Chrome"
|
|
99
|
+
)
|
|
100
|
+
elif platform.startswith("win"):
|
|
101
|
+
for key in ("LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)"):
|
|
102
|
+
root = environ.get(key)
|
|
103
|
+
if root:
|
|
104
|
+
candidates.append(
|
|
105
|
+
Path(root) / "Google" / "Chrome" / "Application" / "chrome.exe"
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
for command in (
|
|
109
|
+
"google-chrome",
|
|
110
|
+
"google-chrome-stable",
|
|
111
|
+
"chrome",
|
|
112
|
+
):
|
|
113
|
+
found = shutil.which(
|
|
114
|
+
command,
|
|
115
|
+
path=environ.get("PATH") if env is None else environ.get("PATH", ""),
|
|
116
|
+
)
|
|
117
|
+
if found:
|
|
118
|
+
candidates.append(Path(found))
|
|
119
|
+
candidates.extend(
|
|
120
|
+
[
|
|
121
|
+
Path("/usr/bin/google-chrome"),
|
|
122
|
+
Path("/usr/bin/google-chrome-stable"),
|
|
123
|
+
Path("/opt/google/chrome/chrome"),
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return _dedupe_paths(candidates)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def detect_chrome_executable(
|
|
131
|
+
browser_path: PathSetting = "auto",
|
|
132
|
+
*,
|
|
133
|
+
sys_platform: str | None = None,
|
|
134
|
+
env: Mapping[str, str] | None = None,
|
|
135
|
+
) -> Path | None:
|
|
136
|
+
"""Return an existing Google Chrome executable, or ``None`` if not found.
|
|
137
|
+
|
|
138
|
+
Pass ``browser_path`` to check one explicit path. With the default
|
|
139
|
+
``"auto"``, common platform locations and ``PLAYWRIGHT_BYOB_CHROME_PATH``
|
|
140
|
+
are checked.
|
|
141
|
+
"""
|
|
142
|
+
explicit = _coerce_optional_path(browser_path)
|
|
143
|
+
if explicit is not None:
|
|
144
|
+
return explicit if explicit.exists() else None
|
|
145
|
+
|
|
146
|
+
for candidate in chrome_executable_candidates(
|
|
147
|
+
sys_platform=sys_platform,
|
|
148
|
+
env=env,
|
|
149
|
+
):
|
|
150
|
+
if candidate.exists():
|
|
151
|
+
return candidate
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def chrome_user_data_dir_candidates(
|
|
156
|
+
*,
|
|
157
|
+
sys_platform: str | None = None,
|
|
158
|
+
env: Mapping[str, str] | None = None,
|
|
159
|
+
) -> tuple[Path, ...]:
|
|
160
|
+
"""Return plausible Google Chrome user data directories.
|
|
161
|
+
|
|
162
|
+
These are profile roots such as ``.../Google/Chrome`` on macOS or
|
|
163
|
+
``.../Google/Chrome/User Data`` on Windows. They may contain profile folders
|
|
164
|
+
named ``Default``, ``Profile 1``, and so on.
|
|
165
|
+
"""
|
|
166
|
+
platform = sys_platform or sys.platform
|
|
167
|
+
environ = os.environ if env is None else env
|
|
168
|
+
candidates: list[Path] = []
|
|
169
|
+
|
|
170
|
+
env_path = environ.get(USER_DATA_DIR_ENV)
|
|
171
|
+
if env_path:
|
|
172
|
+
candidates.append(Path(env_path).expanduser())
|
|
173
|
+
|
|
174
|
+
if platform == "darwin":
|
|
175
|
+
home = _home_path(environ)
|
|
176
|
+
if home is not None:
|
|
177
|
+
candidates.append(
|
|
178
|
+
home / "Library" / "Application Support" / "Google" / "Chrome"
|
|
179
|
+
)
|
|
180
|
+
elif platform.startswith("win"):
|
|
181
|
+
local_app_data = environ.get("LOCALAPPDATA")
|
|
182
|
+
if local_app_data:
|
|
183
|
+
candidates.append(Path(local_app_data) / "Google" / "Chrome" / "User Data")
|
|
184
|
+
else:
|
|
185
|
+
home = _home_path(environ)
|
|
186
|
+
if home is not None:
|
|
187
|
+
candidates.append(home / ".config" / "google-chrome")
|
|
188
|
+
|
|
189
|
+
return _dedupe_paths(candidates)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def detect_chrome_user_data_dir(
|
|
193
|
+
user_data_dir: PathSetting = "auto",
|
|
194
|
+
*,
|
|
195
|
+
sys_platform: str | None = None,
|
|
196
|
+
env: Mapping[str, str] | None = None,
|
|
197
|
+
) -> Path | None:
|
|
198
|
+
"""Return an existing Google Chrome user data directory, or ``None``."""
|
|
199
|
+
explicit = _coerce_optional_path(user_data_dir)
|
|
200
|
+
if explicit is not None:
|
|
201
|
+
return explicit if explicit.exists() else None
|
|
202
|
+
|
|
203
|
+
for candidate in chrome_user_data_dir_candidates(
|
|
204
|
+
sys_platform=sys_platform,
|
|
205
|
+
env=env,
|
|
206
|
+
):
|
|
207
|
+
if candidate.exists():
|
|
208
|
+
return candidate
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def build_chrome_launch_config(
|
|
213
|
+
*,
|
|
214
|
+
browser_path: PathSetting = "auto",
|
|
215
|
+
user_data_dir: PathSetting = "auto",
|
|
216
|
+
profile_directory: ProfileSetting = "auto",
|
|
217
|
+
channel: str | None = DEFAULT_CHANNEL,
|
|
218
|
+
headless: bool | None = False,
|
|
219
|
+
args: Sequence[str] | None = None,
|
|
220
|
+
default_args: bool = True,
|
|
221
|
+
ignore_default_args: IgnoreDefaultArgs = DEFAULT_IGNORE_DEFAULT_ARGS,
|
|
222
|
+
no_viewport: bool | None = True,
|
|
223
|
+
sys_platform: str | None = None,
|
|
224
|
+
env: Mapping[str, str] | None = None,
|
|
225
|
+
**launch_options: Any,
|
|
226
|
+
) -> ChromeLaunchConfig:
|
|
227
|
+
"""Build resolved launch parameters for Playwright's persistent context.
|
|
228
|
+
|
|
229
|
+
Defaults are intentionally tuned for using installed Google Chrome in headed
|
|
230
|
+
mode with a persistent profile:
|
|
231
|
+
|
|
232
|
+
- use the installed Chrome executable when it can be found;
|
|
233
|
+
- otherwise ask Playwright to use the branded ``chrome`` channel;
|
|
234
|
+
- use the existing platform Chrome user data directory;
|
|
235
|
+
- select the ``Default`` Chrome profile directory;
|
|
236
|
+
- run headed with Playwright's fixed viewport disabled;
|
|
237
|
+
- hide Playwright's ``--enable-automation`` default argument.
|
|
238
|
+
|
|
239
|
+
Extra ``launch_options`` are passed directly to Playwright and can override
|
|
240
|
+
most defaults. Use ``args`` for additional Chrome flags, or set
|
|
241
|
+
``default_args=False`` to opt out of this package's default Chrome flags.
|
|
242
|
+
"""
|
|
243
|
+
environ = os.environ if env is None else env
|
|
244
|
+
resolved_user_data_dir = _resolve_user_data_dir(
|
|
245
|
+
user_data_dir,
|
|
246
|
+
sys_platform=sys_platform,
|
|
247
|
+
env=environ,
|
|
248
|
+
)
|
|
249
|
+
resolved_profile_directory = _resolve_profile_directory(
|
|
250
|
+
profile_directory,
|
|
251
|
+
env=environ,
|
|
252
|
+
)
|
|
253
|
+
resolved_browser_path = _resolve_browser_path(
|
|
254
|
+
browser_path,
|
|
255
|
+
sys_platform=sys_platform,
|
|
256
|
+
env=environ,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
options: dict[str, Any] = {}
|
|
260
|
+
if resolved_browser_path is not None:
|
|
261
|
+
options["executable_path"] = resolved_browser_path
|
|
262
|
+
elif channel is not None:
|
|
263
|
+
options["channel"] = channel
|
|
264
|
+
|
|
265
|
+
if headless is not None:
|
|
266
|
+
options["headless"] = headless
|
|
267
|
+
|
|
268
|
+
launch_args = _build_chrome_args(
|
|
269
|
+
profile_directory=resolved_profile_directory,
|
|
270
|
+
headless=headless,
|
|
271
|
+
args=args,
|
|
272
|
+
default_args=default_args,
|
|
273
|
+
)
|
|
274
|
+
if launch_args:
|
|
275
|
+
options["args"] = launch_args
|
|
276
|
+
|
|
277
|
+
if ignore_default_args is not None:
|
|
278
|
+
options["ignore_default_args"] = ignore_default_args
|
|
279
|
+
|
|
280
|
+
if no_viewport is not None and "viewport" not in launch_options:
|
|
281
|
+
options["no_viewport"] = no_viewport
|
|
282
|
+
|
|
283
|
+
options.update(launch_options)
|
|
284
|
+
return ChromeLaunchConfig(
|
|
285
|
+
user_data_dir=resolved_user_data_dir,
|
|
286
|
+
options=options,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def launch_chrome(
|
|
291
|
+
playwright: SyncPlaywright,
|
|
292
|
+
*,
|
|
293
|
+
browser_path: PathSetting = "auto",
|
|
294
|
+
user_data_dir: PathSetting = "auto",
|
|
295
|
+
profile_directory: ProfileSetting = "auto",
|
|
296
|
+
channel: str | None = DEFAULT_CHANNEL,
|
|
297
|
+
headless: bool | None = False,
|
|
298
|
+
args: Sequence[str] | None = None,
|
|
299
|
+
default_args: bool = True,
|
|
300
|
+
ignore_default_args: IgnoreDefaultArgs = DEFAULT_IGNORE_DEFAULT_ARGS,
|
|
301
|
+
no_viewport: bool | None = True,
|
|
302
|
+
**launch_options: Any,
|
|
303
|
+
) -> SyncBrowserContext:
|
|
304
|
+
"""Launch a sync Playwright persistent context with installed Chrome.
|
|
305
|
+
|
|
306
|
+
Example:
|
|
307
|
+
```python
|
|
308
|
+
from playwright.sync_api import sync_playwright
|
|
309
|
+
from playwright_byob import launch_chrome
|
|
310
|
+
|
|
311
|
+
with sync_playwright() as p:
|
|
312
|
+
context = launch_chrome(p)
|
|
313
|
+
page = context.new_page()
|
|
314
|
+
page.goto("https://example.com")
|
|
315
|
+
context.close()
|
|
316
|
+
```
|
|
317
|
+
"""
|
|
318
|
+
config = build_chrome_launch_config(
|
|
319
|
+
browser_path=browser_path,
|
|
320
|
+
user_data_dir=user_data_dir,
|
|
321
|
+
profile_directory=profile_directory,
|
|
322
|
+
channel=channel,
|
|
323
|
+
headless=headless,
|
|
324
|
+
args=args,
|
|
325
|
+
default_args=default_args,
|
|
326
|
+
ignore_default_args=ignore_default_args,
|
|
327
|
+
no_viewport=no_viewport,
|
|
328
|
+
**launch_options,
|
|
329
|
+
)
|
|
330
|
+
return playwright.chromium.launch_persistent_context(
|
|
331
|
+
config.user_data_dir,
|
|
332
|
+
**config.to_playwright_kwargs(),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def async_launch_chrome(
|
|
337
|
+
playwright: AsyncPlaywright,
|
|
338
|
+
*,
|
|
339
|
+
browser_path: PathSetting = "auto",
|
|
340
|
+
user_data_dir: PathSetting = "auto",
|
|
341
|
+
profile_directory: ProfileSetting = "auto",
|
|
342
|
+
channel: str | None = DEFAULT_CHANNEL,
|
|
343
|
+
headless: bool | None = False,
|
|
344
|
+
args: Sequence[str] | None = None,
|
|
345
|
+
default_args: bool = True,
|
|
346
|
+
ignore_default_args: IgnoreDefaultArgs = DEFAULT_IGNORE_DEFAULT_ARGS,
|
|
347
|
+
no_viewport: bool | None = True,
|
|
348
|
+
**launch_options: Any,
|
|
349
|
+
) -> AsyncBrowserContext:
|
|
350
|
+
"""Launch an async Playwright persistent context with installed Chrome."""
|
|
351
|
+
config = build_chrome_launch_config(
|
|
352
|
+
browser_path=browser_path,
|
|
353
|
+
user_data_dir=user_data_dir,
|
|
354
|
+
profile_directory=profile_directory,
|
|
355
|
+
channel=channel,
|
|
356
|
+
headless=headless,
|
|
357
|
+
args=args,
|
|
358
|
+
default_args=default_args,
|
|
359
|
+
ignore_default_args=ignore_default_args,
|
|
360
|
+
no_viewport=no_viewport,
|
|
361
|
+
**launch_options,
|
|
362
|
+
)
|
|
363
|
+
return await playwright.chromium.launch_persistent_context(
|
|
364
|
+
config.user_data_dir,
|
|
365
|
+
**config.to_playwright_kwargs(),
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _resolve_user_data_dir(
|
|
370
|
+
user_data_dir: PathSetting,
|
|
371
|
+
*,
|
|
372
|
+
sys_platform: str | None,
|
|
373
|
+
env: Mapping[str, str],
|
|
374
|
+
) -> Path:
|
|
375
|
+
explicit = _coerce_optional_path(user_data_dir)
|
|
376
|
+
if explicit is not None:
|
|
377
|
+
return explicit
|
|
378
|
+
|
|
379
|
+
env_user_data_dir = env.get(USER_DATA_DIR_ENV)
|
|
380
|
+
if env_user_data_dir:
|
|
381
|
+
env_user_data_path = Path(env_user_data_dir).expanduser()
|
|
382
|
+
if env_user_data_path.exists():
|
|
383
|
+
return env_user_data_path
|
|
384
|
+
msg = (
|
|
385
|
+
f"Chrome user data directory from {USER_DATA_DIR_ENV} "
|
|
386
|
+
f"does not exist: {env_user_data_path}"
|
|
387
|
+
)
|
|
388
|
+
raise ChromeProfileNotFoundError(msg)
|
|
389
|
+
|
|
390
|
+
detected = detect_chrome_user_data_dir(
|
|
391
|
+
"auto",
|
|
392
|
+
sys_platform=sys_platform,
|
|
393
|
+
env=env,
|
|
394
|
+
)
|
|
395
|
+
if detected is not None:
|
|
396
|
+
return detected
|
|
397
|
+
|
|
398
|
+
candidates = ", ".join(
|
|
399
|
+
str(path)
|
|
400
|
+
for path in chrome_user_data_dir_candidates(sys_platform=sys_platform, env=env)
|
|
401
|
+
)
|
|
402
|
+
msg = (
|
|
403
|
+
"Could not find an existing Google Chrome user data directory. "
|
|
404
|
+
f"Set {USER_DATA_DIR_ENV} or pass user_data_dir=... explicitly. "
|
|
405
|
+
f"Checked: {candidates}."
|
|
406
|
+
)
|
|
407
|
+
raise ChromeProfileNotFoundError(msg)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _resolve_browser_path(
|
|
411
|
+
browser_path: PathSetting,
|
|
412
|
+
*,
|
|
413
|
+
sys_platform: str | None,
|
|
414
|
+
env: Mapping[str, str],
|
|
415
|
+
) -> Path | None:
|
|
416
|
+
if browser_path is None:
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
explicit = _coerce_optional_path(browser_path)
|
|
420
|
+
if explicit is not None:
|
|
421
|
+
if explicit.exists():
|
|
422
|
+
return explicit
|
|
423
|
+
msg = f"Chrome executable does not exist: {explicit}"
|
|
424
|
+
raise ChromeNotFoundError(msg)
|
|
425
|
+
|
|
426
|
+
env_browser_path = env.get(CHROME_PATH_ENV)
|
|
427
|
+
if env_browser_path:
|
|
428
|
+
env_browser = Path(env_browser_path).expanduser()
|
|
429
|
+
if env_browser.exists():
|
|
430
|
+
return env_browser
|
|
431
|
+
msg = f"Chrome executable from {CHROME_PATH_ENV} does not exist: {env_browser}"
|
|
432
|
+
raise ChromeNotFoundError(msg)
|
|
433
|
+
|
|
434
|
+
return detect_chrome_executable(
|
|
435
|
+
"auto",
|
|
436
|
+
sys_platform=sys_platform,
|
|
437
|
+
env=env,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _resolve_profile_directory(
|
|
442
|
+
profile_directory: ProfileSetting,
|
|
443
|
+
*,
|
|
444
|
+
env: Mapping[str, str],
|
|
445
|
+
) -> str | None:
|
|
446
|
+
resolved: str | None
|
|
447
|
+
if profile_directory == "auto":
|
|
448
|
+
resolved = env.get(PROFILE_DIRECTORY_ENV, DEFAULT_PROFILE_DIRECTORY)
|
|
449
|
+
else:
|
|
450
|
+
resolved = profile_directory
|
|
451
|
+
if resolved is None:
|
|
452
|
+
return None
|
|
453
|
+
if not resolved:
|
|
454
|
+
msg = "profile_directory must be a non-empty folder name or None"
|
|
455
|
+
raise ConfigurationError(msg)
|
|
456
|
+
if "/" in resolved or "\\" in resolved or resolved in {".", ".."}:
|
|
457
|
+
msg = "profile_directory must be a Chrome profile folder name, not a path"
|
|
458
|
+
raise ConfigurationError(msg)
|
|
459
|
+
return resolved
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _build_chrome_args(
|
|
463
|
+
*,
|
|
464
|
+
profile_directory: str | None,
|
|
465
|
+
headless: bool | None,
|
|
466
|
+
args: Sequence[str] | None,
|
|
467
|
+
default_args: bool,
|
|
468
|
+
) -> list[str]:
|
|
469
|
+
launch_args: list[str] = []
|
|
470
|
+
if default_args:
|
|
471
|
+
launch_args.extend(DEFAULT_CHROME_ARGS)
|
|
472
|
+
if headless is False:
|
|
473
|
+
launch_args.append("--start-maximized")
|
|
474
|
+
if profile_directory is not None:
|
|
475
|
+
launch_args.append(f"--profile-directory={profile_directory}")
|
|
476
|
+
if args:
|
|
477
|
+
launch_args.extend(args)
|
|
478
|
+
return launch_args
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _coerce_optional_path(value: PathSetting) -> Path | None:
|
|
482
|
+
if value is None or value == "auto":
|
|
483
|
+
return None
|
|
484
|
+
return Path(value).expanduser()
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _home_path(env: Mapping[str, str]) -> Path | None:
|
|
488
|
+
home = env.get("HOME") or env.get("USERPROFILE")
|
|
489
|
+
if home:
|
|
490
|
+
return Path(home).expanduser()
|
|
491
|
+
return None
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _dedupe_paths(paths: Sequence[Path]) -> tuple[Path, ...]:
|
|
495
|
+
seen: set[str] = set()
|
|
496
|
+
result: list[Path] = []
|
|
497
|
+
for path in paths:
|
|
498
|
+
key = os.fspath(path)
|
|
499
|
+
if key not in seen:
|
|
500
|
+
seen.add(key)
|
|
501
|
+
result.append(path)
|
|
502
|
+
return tuple(result)
|
|
File without changes
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
import playwright_byob._chrome as chrome_module
|
|
10
|
+
from playwright_byob import (
|
|
11
|
+
CHROME_PATH_ENV,
|
|
12
|
+
DEFAULT_IGNORE_DEFAULT_ARGS,
|
|
13
|
+
PROFILE_DIRECTORY_ENV,
|
|
14
|
+
USER_DATA_DIR_ENV,
|
|
15
|
+
ChromeNotFoundError,
|
|
16
|
+
ChromeProfileNotFoundError,
|
|
17
|
+
ConfigurationError,
|
|
18
|
+
async_launch_chrome,
|
|
19
|
+
build_chrome_launch_config,
|
|
20
|
+
chrome_executable_candidates,
|
|
21
|
+
chrome_user_data_dir_candidates,
|
|
22
|
+
detect_chrome_executable,
|
|
23
|
+
detect_chrome_user_data_dir,
|
|
24
|
+
launch_chrome,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FakeSyncChromium:
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self.calls: list[tuple[Path, dict[str, Any]]] = []
|
|
31
|
+
|
|
32
|
+
def launch_persistent_context(self, user_data_dir: Path, **kwargs: Any) -> str:
|
|
33
|
+
self.calls.append((user_data_dir, kwargs))
|
|
34
|
+
return "sync-context"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class FakeSyncPlaywright:
|
|
38
|
+
def __init__(self) -> None:
|
|
39
|
+
self.chromium = FakeSyncChromium()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FakeAsyncChromium:
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
self.calls: list[tuple[Path, dict[str, Any]]] = []
|
|
45
|
+
|
|
46
|
+
async def launch_persistent_context(
|
|
47
|
+
self, user_data_dir: Path, **kwargs: Any
|
|
48
|
+
) -> str:
|
|
49
|
+
self.calls.append((user_data_dir, kwargs))
|
|
50
|
+
return "async-context"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class FakeAsyncPlaywright:
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self.chromium = FakeAsyncChromium()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_chrome_executable_candidates_prefer_env_path(tmp_path: Path) -> None:
|
|
59
|
+
chrome = tmp_path / "chrome"
|
|
60
|
+
env = {CHROME_PATH_ENV: str(chrome), "HOME": str(tmp_path)}
|
|
61
|
+
|
|
62
|
+
candidates = chrome_executable_candidates(sys_platform="darwin", env=env)
|
|
63
|
+
|
|
64
|
+
assert candidates[0] == chrome
|
|
65
|
+
assert (
|
|
66
|
+
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
|
|
67
|
+
in candidates
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_chrome_user_data_dir_candidates_are_platform_specific(tmp_path: Path) -> None:
|
|
72
|
+
mac_candidates = chrome_user_data_dir_candidates(
|
|
73
|
+
sys_platform="darwin",
|
|
74
|
+
env={"HOME": str(tmp_path)},
|
|
75
|
+
)
|
|
76
|
+
linux_candidates = chrome_user_data_dir_candidates(
|
|
77
|
+
sys_platform="linux",
|
|
78
|
+
env={"HOME": str(tmp_path)},
|
|
79
|
+
)
|
|
80
|
+
windows_candidates = chrome_user_data_dir_candidates(
|
|
81
|
+
sys_platform="win32",
|
|
82
|
+
env={"LOCALAPPDATA": str(tmp_path)},
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
assert mac_candidates == (
|
|
86
|
+
tmp_path / "Library" / "Application Support" / "Google" / "Chrome",
|
|
87
|
+
)
|
|
88
|
+
assert linux_candidates == (tmp_path / ".config" / "google-chrome",)
|
|
89
|
+
assert windows_candidates == (tmp_path / "Google" / "Chrome" / "User Data",)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_empty_env_does_not_fall_back_to_process_home_or_path(
|
|
93
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
94
|
+
) -> None:
|
|
95
|
+
which_paths: list[str | None] = []
|
|
96
|
+
|
|
97
|
+
def fake_which(command: str, path: str | None = None) -> str | None:
|
|
98
|
+
del command
|
|
99
|
+
which_paths.append(path)
|
|
100
|
+
return "/real-path/chrome" if path is None else None
|
|
101
|
+
|
|
102
|
+
monkeypatch.setattr(chrome_module.shutil, "which", fake_which)
|
|
103
|
+
|
|
104
|
+
executable_candidates = chrome_executable_candidates(sys_platform="linux", env={})
|
|
105
|
+
user_data_candidates = chrome_user_data_dir_candidates(sys_platform="linux", env={})
|
|
106
|
+
|
|
107
|
+
assert which_paths == ["", "", ""]
|
|
108
|
+
assert Path("/real-path/chrome") not in executable_candidates
|
|
109
|
+
assert user_data_candidates == ()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_build_config_empty_env_does_not_use_real_user_profile() -> None:
|
|
113
|
+
with pytest.raises(ChromeProfileNotFoundError):
|
|
114
|
+
build_chrome_launch_config(browser_path=None, sys_platform="linux", env={})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_detect_chrome_executable_checks_explicit_existing_path(tmp_path: Path) -> None:
|
|
118
|
+
chrome = tmp_path / "chrome"
|
|
119
|
+
chrome.write_text("fake chrome", encoding="utf-8")
|
|
120
|
+
|
|
121
|
+
assert detect_chrome_executable(chrome) == chrome
|
|
122
|
+
assert detect_chrome_executable(tmp_path / "missing") is None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_detect_user_data_dir_checks_existing_path_only(tmp_path: Path) -> None:
|
|
126
|
+
user_data_dir = tmp_path / "Chrome User Data"
|
|
127
|
+
user_data_dir.mkdir()
|
|
128
|
+
|
|
129
|
+
assert detect_chrome_user_data_dir(user_data_dir) == user_data_dir
|
|
130
|
+
assert detect_chrome_user_data_dir(tmp_path / "missing") is None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_build_config_uses_explicit_browser_profile_and_sensible_defaults(
|
|
134
|
+
tmp_path: Path,
|
|
135
|
+
) -> None:
|
|
136
|
+
chrome = tmp_path / "chrome"
|
|
137
|
+
chrome.write_text("fake chrome", encoding="utf-8")
|
|
138
|
+
user_data_dir = tmp_path / "User Data"
|
|
139
|
+
user_data_dir.mkdir()
|
|
140
|
+
|
|
141
|
+
config = build_chrome_launch_config(
|
|
142
|
+
browser_path=chrome,
|
|
143
|
+
user_data_dir=user_data_dir,
|
|
144
|
+
profile_directory="Profile 1",
|
|
145
|
+
args=["--window-size=1440,1000"],
|
|
146
|
+
timeout=12_000,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
options = config.to_playwright_kwargs()
|
|
150
|
+
assert config.user_data_dir == user_data_dir
|
|
151
|
+
assert options["executable_path"] == chrome
|
|
152
|
+
assert "channel" not in options
|
|
153
|
+
assert options["headless"] is False
|
|
154
|
+
assert options["no_viewport"] is True
|
|
155
|
+
assert options["ignore_default_args"] == DEFAULT_IGNORE_DEFAULT_ARGS
|
|
156
|
+
assert options["timeout"] == 12_000
|
|
157
|
+
assert options["args"] == [
|
|
158
|
+
"--disable-blink-features=AutomationControlled",
|
|
159
|
+
"--start-maximized",
|
|
160
|
+
"--profile-directory=Profile 1",
|
|
161
|
+
"--window-size=1440,1000",
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_build_config_can_use_playwright_chrome_channel_without_detection(
|
|
166
|
+
tmp_path: Path,
|
|
167
|
+
) -> None:
|
|
168
|
+
user_data_dir = tmp_path / "User Data"
|
|
169
|
+
|
|
170
|
+
config = build_chrome_launch_config(
|
|
171
|
+
browser_path=None,
|
|
172
|
+
user_data_dir=user_data_dir,
|
|
173
|
+
profile_directory=None,
|
|
174
|
+
default_args=False,
|
|
175
|
+
ignore_default_args=None,
|
|
176
|
+
no_viewport=False,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
options = config.to_playwright_kwargs()
|
|
180
|
+
assert config.user_data_dir == user_data_dir
|
|
181
|
+
assert options == {"channel": "chrome", "headless": False, "no_viewport": False}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_build_config_honors_environment_overrides(tmp_path: Path) -> None:
|
|
185
|
+
chrome = tmp_path / "chrome"
|
|
186
|
+
chrome.write_text("fake chrome", encoding="utf-8")
|
|
187
|
+
user_data_dir = tmp_path / "Chrome User Data"
|
|
188
|
+
user_data_dir.mkdir()
|
|
189
|
+
env = {
|
|
190
|
+
CHROME_PATH_ENV: str(chrome),
|
|
191
|
+
USER_DATA_DIR_ENV: str(user_data_dir),
|
|
192
|
+
PROFILE_DIRECTORY_ENV: "Profile 2",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
config = build_chrome_launch_config(env=env)
|
|
196
|
+
options = config.to_playwright_kwargs()
|
|
197
|
+
|
|
198
|
+
assert config.user_data_dir == user_data_dir
|
|
199
|
+
assert options["executable_path"] == chrome
|
|
200
|
+
assert "--profile-directory=Profile 2" in options["args"]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_build_config_rejects_missing_environment_paths(tmp_path: Path) -> None:
|
|
204
|
+
with pytest.raises(ChromeNotFoundError):
|
|
205
|
+
build_chrome_launch_config(
|
|
206
|
+
user_data_dir=tmp_path,
|
|
207
|
+
env={CHROME_PATH_ENV: str(tmp_path / "missing-chrome")},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
with pytest.raises(ChromeProfileNotFoundError):
|
|
211
|
+
build_chrome_launch_config(
|
|
212
|
+
browser_path=None,
|
|
213
|
+
env={USER_DATA_DIR_ENV: str(tmp_path / "missing-profile")},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_build_config_does_not_create_or_use_real_profile_in_auto_mode(
|
|
218
|
+
tmp_path: Path,
|
|
219
|
+
) -> None:
|
|
220
|
+
home = tmp_path / "home"
|
|
221
|
+
home.mkdir()
|
|
222
|
+
|
|
223
|
+
with pytest.raises(ChromeProfileNotFoundError):
|
|
224
|
+
build_chrome_launch_config(
|
|
225
|
+
browser_path=None,
|
|
226
|
+
sys_platform="linux",
|
|
227
|
+
env={"HOME": str(home), "PATH": ""},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
assert not (home / ".config" / "google-chrome").exists()
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_build_config_rejects_missing_explicit_browser(tmp_path: Path) -> None:
|
|
234
|
+
with pytest.raises(ChromeNotFoundError):
|
|
235
|
+
build_chrome_launch_config(
|
|
236
|
+
browser_path=tmp_path / "missing-chrome",
|
|
237
|
+
user_data_dir=tmp_path,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_build_config_rejects_profile_paths(tmp_path: Path) -> None:
|
|
242
|
+
with pytest.raises(ConfigurationError):
|
|
243
|
+
build_chrome_launch_config(
|
|
244
|
+
browser_path=None,
|
|
245
|
+
user_data_dir=tmp_path,
|
|
246
|
+
profile_directory="Default/Nested",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_launch_chrome_passes_resolved_config_to_sync_playwright(
|
|
251
|
+
tmp_path: Path,
|
|
252
|
+
) -> None:
|
|
253
|
+
fake = FakeSyncPlaywright()
|
|
254
|
+
|
|
255
|
+
context = launch_chrome(
|
|
256
|
+
fake, # type: ignore[arg-type]
|
|
257
|
+
browser_path=None,
|
|
258
|
+
user_data_dir=tmp_path,
|
|
259
|
+
profile_directory=None,
|
|
260
|
+
args=["--foo"],
|
|
261
|
+
base_url="https://example.com",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
assert context == "sync-context"
|
|
265
|
+
assert fake.chromium.calls == [
|
|
266
|
+
(
|
|
267
|
+
tmp_path,
|
|
268
|
+
{
|
|
269
|
+
"channel": "chrome",
|
|
270
|
+
"headless": False,
|
|
271
|
+
"args": [
|
|
272
|
+
"--disable-blink-features=AutomationControlled",
|
|
273
|
+
"--start-maximized",
|
|
274
|
+
"--foo",
|
|
275
|
+
],
|
|
276
|
+
"ignore_default_args": DEFAULT_IGNORE_DEFAULT_ARGS,
|
|
277
|
+
"no_viewport": True,
|
|
278
|
+
"base_url": "https://example.com",
|
|
279
|
+
},
|
|
280
|
+
),
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_async_launch_chrome_passes_resolved_config_to_async_playwright(
|
|
285
|
+
tmp_path: Path,
|
|
286
|
+
) -> None:
|
|
287
|
+
async def run() -> None:
|
|
288
|
+
fake = FakeAsyncPlaywright()
|
|
289
|
+
context = await async_launch_chrome(
|
|
290
|
+
fake, # type: ignore[arg-type]
|
|
291
|
+
browser_path=None,
|
|
292
|
+
user_data_dir=tmp_path,
|
|
293
|
+
profile_directory="Default",
|
|
294
|
+
headless=True,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
assert context == "async-context"
|
|
298
|
+
assert fake.chromium.calls == [
|
|
299
|
+
(
|
|
300
|
+
tmp_path,
|
|
301
|
+
{
|
|
302
|
+
"channel": "chrome",
|
|
303
|
+
"headless": True,
|
|
304
|
+
"args": [
|
|
305
|
+
"--disable-blink-features=AutomationControlled",
|
|
306
|
+
"--profile-directory=Default",
|
|
307
|
+
],
|
|
308
|
+
"ignore_default_args": DEFAULT_IGNORE_DEFAULT_ARGS,
|
|
309
|
+
"no_viewport": True,
|
|
310
|
+
},
|
|
311
|
+
),
|
|
312
|
+
]
|
|
313
|
+
|
|
314
|
+
asyncio.run(run())
|