playwrightauthor 1.0.2__py3-none-any.whl

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.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env -S uv run -s
2
+ # /// script
3
+ # dependencies = []
4
+ # ///
5
+ # this_file: playwrightauthor/__init__.py
6
+
7
+ """Public re-exports for library consumers."""
8
+
9
+ from .author import Browser, AsyncBrowser
10
+
11
+ __all__ = ["Browser", "AsyncBrowser"]
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env -S uv run -s
2
+ # /// script
3
+ # dependencies = ["fire"]
4
+ # ///
5
+ # this_file: playwrightauthor/__main__.py
6
+
7
+ """`python -m playwrightauthor` entry-point."""
8
+
9
+ from .cli import main
10
+
11
+ if __name__ == "__main__":
12
+ main()
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env -S uv run -s
2
+ # /// script
3
+ # dependencies = ["playwright"]
4
+ # ///
5
+ # this_file: playwrightauthor/author.py
6
+
7
+ """The core Browser and AsyncBrowser classes."""
8
+
9
+ from playwright.sync_api import (
10
+ sync_playwright,
11
+ Playwright,
12
+ Browser as PlaywrightBrowser,
13
+ )
14
+ from playwright.async_api import (
15
+ async_playwright,
16
+ Playwright as AsyncPlaywright,
17
+ Browser as AsyncPlaywrightBrowser,
18
+ )
19
+ from .browser_manager import ensure_browser, _DEBUGGING_PORT
20
+ from .utils.logger import configure as configure_logger
21
+
22
+
23
+ class Browser:
24
+ """
25
+ A sync context manager for an authenticated Playwright Browser.
26
+
27
+ This class handles the setup and teardown of a Playwright browser instance that is connected
28
+ to a persistent browser session. This allows you to maintain your login sessions and other
29
+ browser data between runs.
30
+
31
+ Args:
32
+ verbose (bool, optional): Whether to enable verbose logging. Defaults to False.
33
+ """
34
+ def __init__(self, verbose: bool = False):
35
+ self.verbose = verbose
36
+ self.logger = configure_logger(verbose)
37
+ self.playwright: Playwright | None = None
38
+ self.browser: PlaywrightBrowser | None = None
39
+
40
+ def __enter__(self) -> PlaywrightBrowser:
41
+ self.logger.info("Starting sync browser session...")
42
+ ensure_browser(self.verbose)
43
+ self.playwright = sync_playwright().start()
44
+ self.browser = self.playwright.chromium.connect_over_cdp(f"http://localhost:{_DEBUGGING_PORT}")
45
+ self.logger.info("Sync browser session started.")
46
+ return self.browser
47
+
48
+ def __exit__(self, exc_type, exc_val, exc_tb):
49
+ if self.browser:
50
+ self.browser.close()
51
+ if self.playwright:
52
+ self.playwright.stop()
53
+ self.logger.info("Sync browser session closed.")
54
+
55
+ class AsyncBrowser:
56
+ """
57
+ An async context manager for an authenticated Playwright Browser.
58
+
59
+ This class is the asynchronous version of the Browser class. It handles the setup and teardown
60
+ of a Playwright browser instance that is connected to a persistent browser session. This allows
61
+ you to maintain your login sessions and other browser data between runs.
62
+
63
+ Args:
64
+ verbose (bool, optional): Whether to enable verbose logging. Defaults to False.
65
+ """
66
+ def __init__(self, verbose: bool = False):
67
+ self.verbose = verbose
68
+ self.logger = configure_logger(verbose)
69
+ self.playwright: AsyncPlaywright | None = None
70
+ self.browser: AsyncPlaywrightBrowser | None = None
71
+
72
+ async def __aenter__(self) -> AsyncPlaywrightBrowser:
73
+ self.logger.info("Starting async browser session...")
74
+ ensure_browser(self.verbose)
75
+ self.playwright = await async_playwright().start()
76
+ self.browser = await self.playwright.chromium.connect_over_cdp(f"http://localhost:{_DEBUGGING_PORT}")
77
+ self.logger.info("Async browser session started.")
78
+ return self.browser
79
+
80
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
81
+ if self.browser:
82
+ await self.browser.close()
83
+ if self.playwright:
84
+ await self.playwright.stop()
85
+ self.logger.info("Async browser session closed.")
@@ -0,0 +1 @@
1
+ # this_file: playwrightauthor/browser/__init__.py
@@ -0,0 +1,45 @@
1
+ # this_file: playwrightauthor/browser/finder.py
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from ..utils.paths import install_dir
7
+
8
+ def find_chrome_executable() -> Path | None:
9
+ """Find the Chrome for Testing executable in the install directory or common system locations."""
10
+ install_path = install_dir()
11
+ if sys.platform == "darwin":
12
+ exec_paths = [
13
+ install_path
14
+ / "chrome-mac-arm64"
15
+ / "Google Chrome for Testing.app"
16
+ / "Contents"
17
+ / "MacOS"
18
+ / "Google Chrome for Testing",
19
+ install_path
20
+ / "chrome-mac-x64"
21
+ / "Google Chrome for Testing.app"
22
+ / "Contents"
23
+ / "MacOS"
24
+ / "Google Chrome for Testing",
25
+ Path("/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
26
+ Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
27
+ ]
28
+ elif sys.platform == "win32":
29
+ exec_paths = [
30
+ install_path / "chrome-win64" / "chrome.exe",
31
+ Path(os.environ.get("ProgramFiles(x86)", "")) / "Google" / "Chrome" / "Application" / "chrome.exe",
32
+ Path(os.environ.get("ProgramFiles", "")) / "Google" / "Chrome" / "Application" / "chrome.exe",
33
+ ]
34
+ elif sys.platform.startswith("linux"):
35
+ exec_paths = [
36
+ install_path / "chrome-linux64" / "chrome",
37
+ Path("/usr/bin/google-chrome"),
38
+ ]
39
+ else:
40
+ return None
41
+
42
+ for path in exec_paths:
43
+ if path.exists():
44
+ return path
45
+ return None
@@ -0,0 +1,64 @@
1
+ # this_file: playwrightauthor/browser/installer.py
2
+
3
+ import os
4
+ import platform
5
+ import shutil
6
+ import time
7
+ import requests
8
+ from ..utils.paths import install_dir
9
+ from ..exceptions import BrowserManagerError
10
+
11
+ _LKGV_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"
12
+
13
+ def install_from_lkgv(logger, retries=3, delay=5):
14
+ """Download and extract Chrome for Testing from the LKGV JSON."""
15
+ for attempt in range(retries):
16
+ try:
17
+ logger.info(f"Fetching latest versions from {_LKGV_URL} (attempt {attempt + 1}/{retries})...")
18
+ response = requests.get(_LKGV_URL, timeout=30)
19
+ response.raise_for_status()
20
+ data = response.json()
21
+
22
+ if platform.system() == "Darwin":
23
+ arch = platform.machine()
24
+ platform_key = "mac-arm64" if arch == "arm64" else "mac-x64"
25
+ elif platform.system() == "Windows":
26
+ platform_key = "win64"
27
+ elif platform.system() == "Linux":
28
+ platform_key = "linux64"
29
+ else:
30
+ raise BrowserManagerError("Unsupported operating system")
31
+
32
+ logger.info(f"Detected platform: {platform_key}")
33
+ downloads = data.get("channels", {}).get("Stable", {}).get("downloads", {}).get("chrome")
34
+ if not downloads:
35
+ raise BrowserManagerError("Could not find 'chrome' downloads in LKGV JSON.")
36
+
37
+ url = next((item["url"] for item in downloads if item["platform"] == platform_key), None)
38
+ if not url:
39
+ raise BrowserManagerError(f"Could not find a download URL for platform {platform_key}")
40
+
41
+ logger.info(f"Downloading from: {url}")
42
+ install_path = install_dir()
43
+ install_path.mkdir(parents=True, exist_ok=True)
44
+ zip_path = install_path / "chrome.zip"
45
+
46
+ with requests.get(url, stream=True, timeout=300) as r:
47
+ r.raise_for_status()
48
+ with open(zip_path, "wb") as f:
49
+ for chunk in r.iter_content(chunk_size=8192):
50
+ f.write(chunk)
51
+
52
+ logger.info("Download complete. Extracting...")
53
+ shutil.unpack_archive(zip_path, install_path)
54
+ logger.info("Extraction complete.")
55
+ os.remove(zip_path)
56
+ return
57
+
58
+ except requests.RequestException as e:
59
+ logger.warning(f"Download failed: {e}. Retrying in {delay} seconds...")
60
+ time.sleep(delay)
61
+ except (OSError, ValueError, shutil.ReadError, BrowserManagerError) as e:
62
+ raise BrowserManagerError(f"Failed to install Chrome: {e}") from e
63
+
64
+ raise BrowserManagerError(f"Failed to download Chrome after {retries} attempts.")
@@ -0,0 +1,23 @@
1
+ # this_file: playwrightauthor/browser/launcher.py
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from ..exceptions import BrowserManagerError
6
+
7
+
8
+ def launch_chrome(browser_path: Path, data_dir: Path, port: int, logger):
9
+ """Launch the Chrome executable as a detached process."""
10
+ logger.info(f"Launching Chrome from: {browser_path}")
11
+ command = [
12
+ str(browser_path),
13
+ f"--remote-debugging-port={port}",
14
+ f"--user-data-dir={data_dir}",
15
+ ]
16
+ try:
17
+ process = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
18
+ try:
19
+ process.wait(timeout=30)
20
+ except subprocess.TimeoutExpired:
21
+ logger.info("Chrome launched successfully as a detached process.")
22
+ except (OSError, subprocess.SubprocessError) as e:
23
+ raise BrowserManagerError(f"Failed to launch Chrome: {e}") from e
@@ -0,0 +1,19 @@
1
+ # this_file: playwrightauthor/browser/process.py
2
+
3
+ import psutil
4
+
5
+ def get_chrome_process(port: int | None = None) -> psutil.Process | None:
6
+ """Find a running Chrome process."""
7
+ for proc in psutil.process_iter(["name", "cmdline"]):
8
+ try:
9
+ if "chrome" in proc.info["name"].lower():
10
+ if port is None:
11
+ return proc
12
+ if any(
13
+ f"--remote-debugging-port={port}" in arg
14
+ for arg in proc.info["cmdline"]
15
+ ):
16
+ return proc
17
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
18
+ pass
19
+ return None
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env -S uv run -s
2
+ # /// script
3
+ # dependencies = ["requests", "platformdirs", "rich", "psutil"]
4
+ # ///
5
+ # this_file: playwrightauthor/browser_manager.py
6
+
7
+ """
8
+ Ensure a Chrome for Testing build is present & running in debug mode.
9
+ """
10
+
11
+ import sys
12
+ import time
13
+ import psutil
14
+ from rich.console import Console
15
+ from .utils.paths import install_dir
16
+ from .utils.logger import configure as configure_logger
17
+ from .exceptions import BrowserManagerError
18
+ from .browser.finder import find_chrome_executable
19
+ from .browser.installer import install_from_lkgv
20
+ from .browser.launcher import launch_chrome
21
+ from .browser.process import get_chrome_process
22
+
23
+ _DEBUGGING_PORT = 9222
24
+
25
+ def ensure_browser(verbose: bool = False) -> tuple[str, str]:
26
+ """
27
+ Ensures a Chrome for Testing instance is running with remote debugging.
28
+ Returns the path to the browser executable and the user data directory.
29
+ """
30
+ console = Console()
31
+ logger = configure_logger(verbose)
32
+ start_time = time.time()
33
+
34
+ if get_chrome_process(_DEBUGGING_PORT):
35
+ logger.info(f"Chrome is already running in debug mode on port {_DEBUGGING_PORT}.")
36
+ browser_path = find_chrome_executable()
37
+ data_dir = install_dir()
38
+ if not browser_path:
39
+ raise BrowserManagerError("Could not find Chrome executable even though process is running.")
40
+ logger.info(f"ensure_browser took {time.time() - start_time:.2f}s")
41
+ return str(browser_path), str(data_dir)
42
+
43
+ proc = get_chrome_process()
44
+ if proc:
45
+ logger.warning("Found a running Chrome instance without the debugging port. Killing it...")
46
+ kill_start_time = time.time()
47
+ try:
48
+ proc.kill()
49
+ for _ in range(5):
50
+ if not proc.is_running():
51
+ break
52
+ time.sleep(1)
53
+ else:
54
+ raise BrowserManagerError("Failed to kill existing Chrome process.")
55
+ logger.info(f"Chrome process killed in {time.time() - kill_start_time:.2f}s")
56
+ except psutil.NoSuchProcess:
57
+ pass
58
+
59
+ find_start_time = time.time()
60
+ browser_path = find_chrome_executable()
61
+ logger.info(f"Finding executable took {time.time() - find_start_time:.2f}s")
62
+ if not browser_path:
63
+ logger.warning("Chrome for Testing not found. Attempting to install...")
64
+ install_start_time = time.time()
65
+ try:
66
+ install_from_lkgv(logger)
67
+ except BrowserManagerError as e:
68
+ console.print(f"[red]{e}[/red]")
69
+ sys.exit(1)
70
+ logger.info(f"Installation took {time.time() - install_start_time:.2f}s")
71
+
72
+ browser_path = find_chrome_executable()
73
+ if not browser_path:
74
+ raise BrowserManagerError("Failed to find Chrome for Testing after installation.")
75
+ logger.info(f"Chrome for Testing installed at: {browser_path}")
76
+
77
+ data_dir = install_dir()
78
+ launch_start_time = time.time()
79
+ try:
80
+ launch_chrome(browser_path, data_dir, _DEBUGGING_PORT, logger)
81
+ except BrowserManagerError as e:
82
+ console.print(f"[red]{e}[/red]")
83
+ sys.exit(1)
84
+ logger.info(f"Chrome launch took {time.time() - launch_start_time:.2f}s")
85
+
86
+ logger.info("Chrome for Testing launched successfully.")
87
+ logger.info(f"ensure_browser took {time.time() - start_time:.2f}s")
88
+ return str(browser_path), str(data_dir)
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env -S uv run -s
2
+ # /// script
3
+ # dependencies = ["fire", "rich"]
4
+ # ///
5
+ # this_file: playwrightauthor/cli.py
6
+
7
+ """Fire-powered command-line interface for utility tasks."""
8
+
9
+ import fire
10
+ from rich.console import Console
11
+ import shutil
12
+ from .browser_manager import ensure_browser
13
+ from .exceptions import BrowserManagerError
14
+ from .utils.logger import configure as configure_logger
15
+ from .utils.paths import install_dir
16
+
17
+
18
+ class Cli:
19
+ """CLI for PlaywrightAuthor."""
20
+
21
+ def status(self, verbose: bool = False):
22
+ """Checks browser status and launches it if not running."""
23
+ console = Console()
24
+ logger = configure_logger(verbose)
25
+ logger.info("Checking browser status...")
26
+ try:
27
+ browser_path, data_dir = ensure_browser(verbose=verbose)
28
+ console.print("[green]Browser is ready.[/green]")
29
+ console.print(f" - Path: {browser_path}")
30
+ console.print(f" - User Data: {data_dir}")
31
+ except BrowserManagerError as e:
32
+ console.print(f"[red]Error: {e}[/red]")
33
+ except SystemExit as e:
34
+ if e.code != 0:
35
+ console.print(f"[red]CLI command failed with exit code {e.code}.[/red]")
36
+
37
+ def clear_cache(self):
38
+ """
39
+ Removes the browser installation directory, including the browser itself and user data.
40
+ """
41
+ console = Console()
42
+ install_path = install_dir()
43
+ if install_path.exists():
44
+ console.print(f"Removing {install_path}...")
45
+ shutil.rmtree(install_path)
46
+ console.print("[green]Cache cleared.[/green]")
47
+ else:
48
+ console.print("[yellow]Cache directory not found.[/yellow]")
49
+
50
+
51
+ def main() -> None:
52
+ fire.Fire(Cli)
@@ -0,0 +1,11 @@
1
+ # this_file: playwrightauthor/exceptions.py
2
+
3
+ """Custom exceptions for PlaywrightAuthor."""
4
+
5
+ class PlaywrightAuthorError(Exception):
6
+ """Base exception for all PlaywrightAuthor errors."""
7
+ pass
8
+
9
+ class BrowserManagerError(PlaywrightAuthorError):
10
+ """Raised for errors related to browser management."""
11
+ pass
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env -S uv run -s
2
+ # /// script
3
+ # dependencies = ["playwright"]
4
+ # ///
5
+ # this_file: playwrightauthor/onboarding.py
6
+
7
+ """Serve a local HTML page instructing the user to log in."""
8
+
9
+ from pathlib import Path
10
+ from playwright.async_api import Browser as AsyncBrowser
11
+
12
+
13
+ async def show(browser: AsyncBrowser, logger) -> None:
14
+ """Shows the onboarding page in a new tab and waits for the user to navigate away."""
15
+ html_path = Path(__file__).parent / "templates" / "onboarding.html"
16
+ page = await browser.new_page()
17
+ await page.set_content(html_path.read_text("utf-8"), wait_until="domcontentloaded")
18
+ logger.info("Onboarding page shown. Waiting for user to navigate to a different page...")
19
+ try:
20
+ await page.wait_for_navigation(timeout=300_000) # 5 minutes
21
+ logger.info("User navigated away from the onboarding page.")
22
+ except Exception:
23
+ logger.warning("Timed out waiting for user to navigate away from the onboarding page.")
24
+ finally:
25
+ await page.close()
@@ -0,0 +1,34 @@
1
+ <!-- this_file: playwrightauthor/templates/onboarding.html -->
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>PlaywrightAuthor Onboarding</title>
8
+ <style>
9
+ body {
10
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
11
+ line-height: 1.6;
12
+ color: #333;
13
+ max-width: 600px;
14
+ margin: 40px auto;
15
+ padding: 20px;
16
+ border: 1px solid #ddd;
17
+ border-radius: 8px;
18
+ }
19
+ h1 {
20
+ font-size: 24px;
21
+ color: #111;
22
+ }
23
+ strong {
24
+ color: #007aff;
25
+ }
26
+ </style>
27
+ </head>
28
+ <body>
29
+ <h1>One small step…</h1>
30
+ <p>Welcome to PlaywrightAuthor! To get started, please <strong>log into any websites you need</strong> in this browser window.</p>
31
+ <p>This browser session will be saved, so you won't have to log in again next time.</p>
32
+ <p>When you're done, simply <strong>navigate to any other website</strong> (e.g., by typing a URL in the address bar) or close this tab to continue.</p>
33
+ </body>
34
+ </html>
@@ -0,0 +1,3 @@
1
+ # this_file: playwrightauthor/typing.py
2
+
3
+ """Shared type hints for the project."""
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env -S uv run -s
2
+ # /// script
3
+ # dependencies = ["loguru"]
4
+ # ///
5
+ # this_file: playwrightauthor/utils/logger.py
6
+
7
+ """Project-wide Loguru configuration."""
8
+
9
+ from loguru import logger
10
+
11
+
12
+ def configure(verbose: bool = False):
13
+ logger.remove()
14
+ level = "DEBUG" if verbose else "INFO"
15
+ logger.add(lambda m: print(m, end=""), level=level)
16
+ return logger
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env -S uv run -s
2
+ # /// script
3
+ # dependencies = ["platformdirs"]
4
+ # ///
5
+ # this_file: playwrightauthor/utils/paths.py
6
+
7
+ """Cross-platform install locations."""
8
+
9
+ from platformdirs import user_cache_dir
10
+ from pathlib import Path
11
+
12
+
13
+ def install_dir() -> Path:
14
+ return Path(user_cache_dir("playwrightauthor")) / "browser"
@@ -0,0 +1,498 @@
1
+ Metadata-Version: 2.4
2
+ Name: playwrightauthor
3
+ Version: 1.0.2
4
+ Summary: Your personal, authenticated browser for Playwright, ready in one line of code.
5
+ Project-URL: Homepage, https://github.com/twardoch/playwrightauthor
6
+ Project-URL: Bug Tracker, https://github.com/twardoch/playwrightauthor/issues
7
+ Author-email: Adam Twardoch <adam+github@twardoch.com>
8
+ License-File: LICENSE
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: fire
14
+ Requires-Dist: loguru
15
+ Requires-Dist: platformdirs
16
+ Requires-Dist: playwright
17
+ Requires-Dist: psutil
18
+ Requires-Dist: requests
19
+ Requires-Dist: rich
20
+ Description-Content-Type: text/markdown
21
+
22
+ # PlaywrightAuthor
23
+
24
+ *Your personal, authenticated browser for Playwright, ready in one line of code.*
25
+
26
+ PlaywrightAuthor is a convenience package for **Microsoft Playwright**. It handles the tedious parts of browser automation: finding and launching a **Chrome for Testing** instance, keeping it authenticated with your user profile, and connecting Playwright to it. All you need to do is instantiate a class, and you get a ready-to-use `Browser` object. This lets you focus on writing your automation script, not on the boilerplate.
27
+
28
+ The core idea is to let you do this:
29
+
30
+ ```python
31
+ from playwrightauthor import Browser
32
+
33
+ with Browser() as browser:
34
+ # you get a standard Playwright browser object
35
+ # that is already connected to a logged-in browser
36
+ page = browser.new_page()
37
+ page.goto("https://github.com/me")
38
+ print(f"Welcome, {page.locator('.user-profile-name').inner_text()}!")
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Contents
44
+
45
+ * [Features](#features)
46
+ * [Quick‑start](#quick-start)
47
+ * [Developer workflow](#developer-workflow)
48
+ * [Package layout](#package-layout) – **file tree, code snippet, explanation & rationale for every file**
49
+ * [Contributing](#contributing)
50
+ * [License](#license)
51
+
52
+ ---
53
+
54
+ ## Features
55
+
56
+ | ✔︎ | Capability |
57
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
58
+ | Automatically discovers or installs a suitable **Chrome for Testing** build via the *last‑known‑good‑versions* JSON, falling back to `npx puppeteer` when possible. | |
59
+ | Launches Chrome with `--remote-debugging-port` (killing any non‑debug instance first) and shows a friendly onboarding HTML if the user still needs to log in. | |
60
+ | Provides simple `Browser()` and `AsyncBrowser()` classes that return a ready-to-use, authenticated Playwright browser object. | |
61
+ | Fire‑powered CLI (`python -m playwrightauthor ...`) with rich‐colour output and `--verbose` flag wiring straight into **loguru**. | |
62
+ | 100 % type‑hinted, PEP 8‑compliant, flat, self‑documenting codebase; every source has a `this_file:` tracker line. | |
63
+ | Batteries included: pytest suite, Ruff/pyupgrade/autoflake hooks, uv‑based reproducible environments. | |
64
+
65
+ ---
66
+
67
+ ## Quick start
68
+
69
+ ```bash
70
+ # ➀ create & sync env
71
+ curl -LsSf https://astral.sh/uv/install.sh | sh
72
+ uv venv --python 3.12
73
+ uv init
74
+ # Add playwrightauthor and its dependencies
75
+ uv add playwright rich fire loguru platformdirs requests psutil
76
+
77
+ # ➁ run your script
78
+ # (create a file like 'myscript.py' with the code below)
79
+ python myscript.py
80
+ ```
81
+
82
+ Example `myscript.py`:
83
+ ```python
84
+ from playwrightauthor import Browser, AsyncBrowser
85
+ import asyncio
86
+
87
+ # Synchronous API
88
+ print("--- Running Sync Example ---")
89
+ with Browser(verbose=True) as browser:
90
+ page = browser.new_page()
91
+ page.goto("https://github.com")
92
+ print(f"Page title: {page.title()}")
93
+
94
+ # Asynchronous API
95
+ async def main():
96
+ print("\n--- Running Async Example ---")
97
+ async with AsyncBrowser(verbose=True) as browser:
98
+ page = await browser.new_page()
99
+ await page.goto("https://duckduckgo.com")
100
+ print(f"Page title: {await page.title()}")
101
+
102
+ if __name__ == "__main__":
103
+ asyncio.run(main())
104
+ ```
105
+
106
+ ---
107
+
108
+ ## Command-Line Interface
109
+
110
+ PlaywrightAuthor comes with a command-line interface for managing the browser installation.
111
+
112
+ ### `status`
113
+
114
+ Checks the status of the browser and launches it if it's not running.
115
+
116
+ ```bash
117
+ python -m playwrightauthor status
118
+ ```
119
+
120
+ ### `clear-cache`
121
+
122
+ Removes the browser installation directory, including the browser itself and user data.
123
+
124
+ ```bash
125
+ python -m playwrightauthor clear-cache
126
+ ```
127
+
128
+ ---
129
+
130
+ ## Developer workflow
131
+
132
+ 1. **Read** `WORK.md` & `PLAN.md` before touching code.
133
+
134
+ 2. **Iterate** in minimal, self‑contained commits.
135
+
136
+ 3. After Python changes run:
137
+
138
+ ```bash
139
+ fd -e py -x uvx autoflake -i {}; \
140
+ fd -e py -x uvx pyupgrade --py312-plus {}; \
141
+ fd -e py -x uvx ruff check --output-format=github --fix --unsafe-fixes {}; \
142
+ fd -e py -x uvx ruff format --respect-gitignore --target-version py312 {}; \
143
+ python -m pytest
144
+ ```
145
+
146
+ 4. Update `CHANGELOG.md`, tick items in `TODO.md`, push.
147
+
148
+ 5. Always finish a work session with **“Wait, but”** → reflect → refine → push again.
149
+
150
+ ---
151
+
152
+ ## Package layout
153
+
154
+ > Below is the *envisioned* file tree.
155
+ > Each entry shows (a) **code snippet** – only the essential lines,
156
+ > (b) **explanation** – what it does,
157
+ > (c) **rationale** – why it belongs.
158
+
159
+ ```
160
+ .
161
+ ├── playwrightauthor/
162
+ │ ├── __init__.py
163
+ │ ├── __main__.py
164
+ │ ├── cli.py
165
+ │ ├── author.py
166
+ │ ├── browser_manager.py
167
+ │ ├── onboarding.py
168
+ │ ├── templates/
169
+ │ │ └── onboarding.html
170
+ │ ├── utils/
171
+ │ │ ├── logger.py
172
+ │ │ └── paths.py
173
+ │ └── typing.py
174
+ ├── tests/
175
+ │ └── test_author.py
176
+ ├── README.md ← *← this file*
177
+ ├── PLAN.md
178
+ ├── TODO.md
179
+ ├── WORK.md
180
+ └── CHANGELOG.md
181
+ ```
182
+
183
+ ### `playwrightauthor/__init__.py`
184
+
185
+ ```python
186
+ #!/usr/bin/env -S uv run -s
187
+ # /// script
188
+ # dependencies = []
189
+ # ///
190
+ # this_file: playwrightauthor/__init__.py
191
+
192
+ """Public re‑exports for library consumers."""
193
+ from .author import Browser, AsyncBrowser
194
+
195
+ __all__ = ["Browser", "AsyncBrowser"]
196
+ ```
197
+
198
+ *Explanation* – Presents a tiny, stable API surface.
199
+ *Rationale* – Hides internal churn; semver‑compatible.
200
+
201
+ ---
202
+
203
+
204
+ ### `playwrightauthor/__main__.py`
205
+
206
+ ```python
207
+ #!/usr/bin/env -S uv run -s
208
+ # /// script
209
+ # dependencies = ["fire"]
210
+ # ///
211
+ # this_file: playwrightauthor/__main__.py
212
+
213
+ ""`python -m playwrightauthor` entry‑point."""
214
+ from .cli import main
215
+
216
+ if __name__ == "__main__":
217
+ main()
218
+ ```
219
+
220
+ *Explanation* – Delegates to the Fire CLI.
221
+ *Rationale* – Keeps `__init__` import‑only; avoids side‑effects.
222
+
223
+ ---
224
+
225
+
226
+ ### `playwrightauthor/cli.py`
227
+
228
+ ```python
229
+ #!/usr/bin/env -S uv run -s
230
+ # /// script
231
+ # dependencies = ["fire", "rich"]
232
+ # ///
233
+ # this_file: playwrightauthor/cli.py
234
+
235
+ """Fire‑powered command‑line interface for utility tasks."""
236
+ from rich.console import Console
237
+ from .browser_manager import ensure_browser
238
+
239
+ def status(verbose: bool = False) -> None:
240
+ """Checks browser status and launches it if not running."""
241
+ console = Console()
242
+ console.print("Checking browser status...")
243
+ browser_path, data_dir = ensure_browser(verbose=verbose)
244
+ console.print(f"[green]Browser is ready.[/green]")
245
+ console.print(f" - Path: {browser_path}")
246
+ console.print(f" - User Data: {data_dir}")
247
+
248
+ def main() -> None:
249
+ import fire
250
+ fire.Fire({"status": status})
251
+ ```
252
+
253
+ *Explanation* – Offers utility commands like `status`.
254
+ *Rationale* – Provides a simple way to manage the browser without writing a script.
255
+
256
+ ---
257
+
258
+
259
+ ### `playwrightauthor/author.py`
260
+
261
+ ```python
262
+ #!/usr/bin/env -S uv run -s
263
+ # /// script
264
+ # dependencies = ["playwright"]
265
+ # ///
266
+ # this_file: playwrightauthor/author.py
267
+
268
+ """The core Browser and AsyncBrowser classes."""
269
+ from playwright.sync_api import sync_playwright, Playwright, Browser as PlaywrightBrowser
270
+ from playwright.async_api import async_playwright, AsyncPlaywright, Browser as AsyncPlaywrightBrowser
271
+ from .browser_manager import ensure_browser
272
+
273
+ class Browser:
274
+ """A sync context manager for an authenticated Playwright Browser."""
275
+ def __init__(self, verbose: bool = False):
276
+ self.verbose = verbose
277
+
278
+ def __enter__(self) -> PlaywrightBrowser:
279
+ # 1. Ensure browser is running and get debug port
280
+ # 2. playwright.chromium.connect_over_cdp()
281
+ # 3. Return browser object
282
+ ...
283
+
284
+ def __exit__(self, exc_type, exc_val, exc_tb):
285
+ # Disconnect
286
+ ...
287
+
288
+ class AsyncBrowser:
289
+ """An async context manager for an authenticated Playwright Browser."""
290
+ def __init__(self, verbose: bool = False):
291
+ self.verbose = verbose
292
+
293
+ async def __aenter__(self) -> AsyncPlaywrightBrowser:
294
+ ...
295
+
296
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
297
+ ...
298
+ ```
299
+
300
+ *Explanation* – The main entry point for the library.
301
+ *Rationale* – Provides a simple, Pythonic `with` statement syntax for browser management.
302
+
303
+ ---
304
+
305
+
306
+ ### `playwrightauthor/browser_manager.py`
307
+
308
+ ```python
309
+ #!/usr/bin/env -S uv run -s
310
+ # /// script
311
+ # dependencies = ["requests", "platformdirs", "rich", "psutil"]
312
+ # ///
313
+ # this_file: playwrightauthor/browser_manager.py
314
+
315
+ """
316
+ Ensure a Chrome for Testing build is present & running in debug mode.
317
+
318
+ Algorithm:
319
+ 1. Try to connect to localhost:9222.
320
+ 2. If Chrome is running *without* --remote-debugging-port ⇒ kill & restart.
321
+ 3. If Chrome isn't installed:
322
+ 3a. Prefer `npx puppeteer browsers install`.
323
+ 3b. Else download the matching archive from LKGV JSON.
324
+ 4. Launch Chrome with a persistent user‑data‑dir.
325
+ """
326
+ import os, subprocess, json, sys, platform, shutil, tempfile
327
+ from pathlib import Path
328
+ from rich.console import Console
329
+ from .utils.paths import install_dir
330
+
331
+ _LKGV_URL = "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json"
332
+
333
+ def ensure_browser(verbose: bool = False):
334
+ console = Console()
335
+ ...
336
+ ```
337
+
338
+ *Explanation* – Central authority for “is Chrome available?”.
339
+ *Rationale* – Encapsulates the complex, platform-specific logic of managing the browser binary and its process.
340
+
341
+ ---
342
+
343
+
344
+ ### `playwrightauthor/onboarding.py`
345
+
346
+ ```python
347
+ #!/usr/bin/env -S uv run -s
348
+ # /// script
349
+ # dependencies = ["playwright"]
350
+ # ///
351
+ # this_file: playwrightauthor/onboarding.py
352
+
353
+ """Serve a local HTML page instructing the user to log in."""
354
+ from pathlib import Path
355
+ from playwright.async_api import Browser
356
+
357
+ def show(browser: Browser) -> None:
358
+ html = Path(__file__).parent.parent / "templates" / "onboarding.html"
359
+ page = browser.new_page()
360
+ page.set_content(html.read_text("utf-8"), wait_until="domcontentloaded")
361
+ ```
362
+
363
+ *Explanation* – Visual cue when manual steps are needed.
364
+ *Rationale* – Human‑friendly recovery path builds trust.
365
+
366
+ ---
367
+
368
+
369
+ ### `playwrightauthor/templates/onboarding.html`
370
+
371
+ ```html
372
+ <!-- this_file: playwrightauthor/templates/onboarding.html -->
373
+ <!DOCTYPE html>
374
+ <html>
375
+ <body>
376
+ <h1>One small step…</h1>
377
+ <p>Please <strong>log into any websites you need</strong> in this browser window.<br>
378
+ This session will be saved for future runs.<br><br>
379
+ You can close this tab and return to the terminal when you’re done!</p>
380
+ </body>
381
+ </html>
382
+ ```
383
+
384
+ *Explanation* – Tiny static asset for user guidance.
385
+ *Rationale* – Kept under `templates/` to avoid clutter.
386
+
387
+ ---
388
+
389
+
390
+ ### `playwrightauthor/utils/logger.py`
391
+
392
+ ```python
393
+ #!/usr/bin/env -S uv run -s
394
+ # /// script
395
+ # dependencies = ["loguru"]
396
+ # ///
397
+ # this_file: playwrightauthor/utils/logger.py
398
+
399
+ """Project‑wide Loguru configuration."""
400
+ from loguru import logger
401
+
402
+ def configure(verbose: bool = False):
403
+ logger.remove()
404
+ level = "DEBUG" if verbose else "INFO"
405
+ logger.add(lambda m: print(m, end=""), level=level)
406
+ return logger
407
+ ```
408
+
409
+ *Explanation* – Single point of logging policy.
410
+ *Rationale* – Avoids duplicating “verbose” handling everywhere.
411
+
412
+ ---
413
+
414
+
415
+ ### `playwrightauthor/utils/paths.py`
416
+
417
+ ```python
418
+ #!/usr/bin/env -S uv run -s
419
+ # /// script
420
+ # dependencies = ["platformdirs"]
421
+ # ///
422
+ # this_file: playwrightauthor/utils/paths.py
423
+
424
+ """Cross‑platform install locations."""
425
+ from platformdirs import user_cache_dir
426
+ from pathlib import Path
427
+
428
+ def install_dir() -> Path:
429
+ return Path(user_cache_dir("playwrightauthor", roaming=True)) / "browser"
430
+ ```
431
+
432
+ *Explanation* – Abstracts OS differences.
433
+ *Rationale* – Ensures browser data is stored in a conventional, user-specific location.
434
+
435
+ ---
436
+
437
+
438
+ ### `tests/test_author.py`
439
+
440
+ ```python
441
+ # this_file: tests/test_author.py
442
+ import pytest
443
+ from playwrightauthor import Browser
444
+
445
+ @pytest.mark.skip("requires live Chrome and user interaction")
446
+ def test_browser_smoke():
447
+ """A basic smoke test to ensure the Browser class can be instantiated."""
448
+ try:
449
+ with Browser() as browser:
450
+ assert browser is not None
451
+ assert len(browser.contexts) > 0
452
+ except Exception as e:
453
+ # This test is expected to fail in a headless CI environment
454
+ # without a display server or a running Chrome instance.
455
+ # We just check that it doesn't raise an unexpected error.
456
+ pass
457
+ ```
458
+
459
+ *Explanation* – Smoke test for the core `Browser` class.
460
+ *Rationale* – Keeps CI fast but ensures the main library entry point is importable and structurally sound.
461
+
462
+ ---
463
+
464
+ ## Troubleshooting
465
+
466
+ ### `BrowserManagerError: Could not find Chrome executable...`
467
+
468
+ This error means that `playwrightauthor` could not find a Chrome executable on your system. You can either install Chrome for Testing using the `npx puppeteer browsers install chrome` command, or install Google Chrome and ensure it is in a common system location.
469
+
470
+ ### `playwright._impl._api_types.Error: Target page, context or browser has been closed`
471
+
472
+ This error usually means that the browser was closed while your script was running. This can happen if you manually close the browser window, or if the browser crashes. If you are running into this issue, you can try running your script with the `--verbose` flag to get more information.
473
+
474
+ ---
475
+
476
+ ## Contributing
477
+
478
+ Pull‑requests are welcome! Please follow the **General Coding Principles** in the main `README.md`, keep every file’s `this_file` header accurate, and end each session with a short *“Wait, but”* reflection in your PR description.
479
+
480
+ ---
481
+
482
+ ## License
483
+
484
+ MIT – *see* `LICENSE`.
485
+
486
+ ---
487
+
488
+ Wait, but…
489
+
490
+ **Reflection & tiny refinements**
491
+
492
+ * Refocused the entire project from a specific scraper to a general-purpose Playwright convenience library.
493
+ * The core API is now class-based (`Browser`, `AsyncBrowser`) for a more Pythonic feel.
494
+ * Updated the file layout (`author.py`) and CLI (`status` command) to match the new scope.
495
+ * Generalized the onboarding HTML to be site-agnostic.
496
+ * Ensured all snippets and explanations align with the new vision of providing a zero-setup, authenticated browser.
497
+
498
+ (End of iteration – ready for review.)
@@ -0,0 +1,21 @@
1
+ playwrightauthor/__init__.py,sha256=TbNVREWLCEupn7b6JnzjXlyd6f8ncxFwU6L0QaHqUA0,239
2
+ playwrightauthor/__main__.py,sha256=S0yG-x3IVqq2qzT-lkHV3d54qHPEzr7irESbyPwO7nk,226
3
+ playwrightauthor/author.py,sha256=9qtN3YBtM1dwEkeWRo9xYAjg1kh2LRVKT9UrKfqauvc,3185
4
+ playwrightauthor/browser_manager.py,sha256=tYQsaz_vAl5KzZxbXo1xdQK4rFohNmkwapZ10r-QXQQ,3354
5
+ playwrightauthor/cli.py,sha256=EQun2eQhmkejn4Ezo2NEiKDVg492W1BjVPdH_o2ztgg,1733
6
+ playwrightauthor/exceptions.py,sha256=c9h1awC5y273V--pT28CmQlpJSZmhExWDgBo5v2Kl9U,318
7
+ playwrightauthor/onboarding.py,sha256=bpYQNnhwny04ZNMPVBFJnRspVIBCEEVtH9lBX_L7eag,1011
8
+ playwrightauthor/typing.py,sha256=5WRtQY2BtnXeYTTV3kDoFwXFJ05Kiys_lf_PzRmvh3o,82
9
+ playwrightauthor/browser/__init__.py,sha256=VeEX7QUTODa6Q6jQYZxaCgdxbhmrD1mmxTGy6Bh4xhI,50
10
+ playwrightauthor/browser/finder.py,sha256=_mgPhgl7M261dcDAAJeNgdoZOfko6RoKnH-L_QmSTmM,1599
11
+ playwrightauthor/browser/installer.py,sha256=Rde1xnjawSKnQ5pSbBwLEurF-FtGkh5LKLGVJF6D-ao,2772
12
+ playwrightauthor/browser/launcher.py,sha256=gpCymvVc15SZ-T-dOn4FAPHDn-0bARRln4Dku2S7A6Q,873
13
+ playwrightauthor/browser/process.py,sha256=t7sjeSU7jqZlm2QKfHC0JVpufQ8CqBXPz7hzdSDcoUQ,673
14
+ playwrightauthor/templates/onboarding.html,sha256=KsxWCMJHaNP2SpgTkmBuFCtEqoKgWd0Kelh_C0pJii8,1200
15
+ playwrightauthor/utils/logger.py,sha256=gTLfcWs1Rxsxa8v9-tQU7vcPQS525JEqmnw2gXjSWR0,367
16
+ playwrightauthor/utils/paths.py,sha256=9c47252Z6EXQahptRW3G13HeAJdzwIvMfYGJC6fg3VI,326
17
+ playwrightauthor-1.0.2.dist-info/METADATA,sha256=2e-QEMC-k7Bz1R1yPdmDF7dQzLrZKQOgG343h_6-bpI,15194
18
+ playwrightauthor-1.0.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
+ playwrightauthor-1.0.2.dist-info/entry_points.txt,sha256=4tFt94NbqiN86XWVWg4ph12PVTNfCKERvrbvc-uNeMA,63
20
+ playwrightauthor-1.0.2.dist-info/licenses/LICENSE,sha256=ZMAJpSuarzr23IJGvncVH1ouB-SWSySQTQtSc3Pmrsg,1070
21
+ playwrightauthor-1.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ playwrightauthor = playwrightauthor.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Adam Twardoch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.