diff --git a/.env.e2e.example b/.env.e2e.example new file mode 100644 index 0000000..4eb3928 --- /dev/null +++ b/.env.e2e.example @@ -0,0 +1,14 @@ +# Copy to .env.e2e (gitignored) and fill in real values, then run: +# ./scripts/run_e2e_local.sh +# +# Values may also be 1Password references (e.g. op://vault/item/field); in that +# case run via `op run --env-file=.env.e2e -- ./scripts/run_e2e_local.sh` instead, +# so `op` resolves them before the script sees them. + +API_USER= +API_TOKEN= +PA_PASSWORD= +# Full push URL with embedded credentials, e.g.: +# DEMO_REPO_URL=https://x-access-token:@github.com/caktus/dsd-pythonanywhere-testproj.git +DEMO_REPO_URL= +DEMO_REPO_NAME=dsd-pythonanywhere-testproj diff --git a/.github/workflows/e2e_pythonanywhere.yaml b/.github/workflows/e2e_pythonanywhere.yaml new file mode 100644 index 0000000..cb8a547 --- /dev/null +++ b/.github/workflows/e2e_pythonanywhere.yaml @@ -0,0 +1,73 @@ +name: e2e-pythonanywhere + +on: + workflow_dispatch: + +concurrency: + group: pa-e2e-deploy + cancel-in-progress: false + +env: + DEMO_REPO_NAME: dsd-pythonanywhere-testproj + +jobs: + e2e: + runs-on: ubuntu-latest + environment: pa-integration + env: + API_USER: ${{ secrets.PA_API_USER }} + API_TOKEN: ${{ secrets.PA_API_TOKEN }} + PA_PASSWORD: ${{ secrets.PA_PASSWORD }} + # Full clone URL with embedded push credentials, e.g. + # https://x-access-token:@github.com/caktus/dsd-pythonanywhere-testproj.git + DEMO_REPO_URL: ${{ secrets.DEMO_REPO_URL }} + # Resolved by dsd_pythonanywhere.client._get_console_opener(); the plugin's + # tests/ dir is added to PYTHONPATH below so this dotted path resolves both + # in this pytest process and in the `manage.py deploy` child process. + DSD_PYTHONANYWHERE_CONSOLE_OPENER: "e2e_tests.pa_admin:open_console" + steps: + - name: Checkout dsd-pythonanywhere + uses: actions/checkout@v5 + with: + path: dsd-pythonanywhere + + - name: Checkout django-simple-deploy + uses: actions/checkout@v5 + with: + repository: django-simple-deploy/django-simple-deploy + path: django-simple-deploy + + - name: Install uv and set the Python version + uses: astral-sh/setup-uv@v7 + with: + python-version: "3.14" + enable-cache: true + + - name: Install dependencies + run: | + cd django-simple-deploy + uv venv + uv pip install -e ".[dev]" + uv pip install -e ../dsd-pythonanywhere + # playwright is a dev dependency-group, not an extra, so install it directly. + uv pip install playwright + uv run playwright install chromium --with-deps + + - name: Pre-test cleanup (webapp + filesystem) + working-directory: django-simple-deploy + env: + PYTHONPATH: ${{ github.workspace }}/dsd-pythonanywhere/tests + run: uv run python -m e2e_tests.pa_admin + + - name: Run e2e test + working-directory: django-simple-deploy + env: + PYTHONPATH: ${{ github.workspace }}/dsd-pythonanywhere/tests + run: uv run pytest tests/e2e_tests --plugin dsd_pythonanywhere --automate-all --skip-confirmations -s + + - name: Teardown (always runs, even on failure) + if: always() + working-directory: django-simple-deploy + env: + PYTHONPATH: ${{ github.workspace }}/dsd-pythonanywhere/tests + run: uv run python -m e2e_tests.pa_admin diff --git a/.gitignore b/.gitignore index 8ebb7a2..b94d7fc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *_env/ .envrc +.env.e2e* *.pyc __pycache__/ diff --git a/dsd_pythonanywhere/client.py b/dsd_pythonanywhere/client.py index 6c9673d..51bea6f 100644 --- a/dsd_pythonanywhere/client.py +++ b/dsd_pythonanywhere/client.py @@ -1,3 +1,4 @@ +import importlib import logging import os import re @@ -14,6 +15,21 @@ logger = logging.getLogger(__name__) +def _get_console_opener(): + """Resolve the callable used to open a console URL. + + Defaults to webbrowser.open. Override with DSD_PYTHONANYWHERE_CONSOLE_OPENER, + a "module.path:function" string, for headless environments like e2e CI. + """ + dotted_path = os.getenv("DSD_PYTHONANYWHERE_CONSOLE_OPENER") + if not dotted_path: + return webbrowser.open + + module_path, func_name = dotted_path.split(":", 1) + module = importlib.import_module(module_path) + return getattr(module, func_name) + + def log_message(message: str, level: int = logging.INFO, **kwargs) -> None: """Helper function to log messages to both logger and plugin_utils output. @@ -240,7 +256,7 @@ def wait_for_ready(self) -> None: # Console not started, open in browser if we haven't already if not browser_opened: log_message(" Console not started, opening browser...") - webbrowser.open(self.browser_url) + _get_console_opener()(self.browser_url) browser_opened = True # Wait for browser opening to trigger startup time.sleep(5) diff --git a/dsd_pythonanywhere/platform_deployer.py b/dsd_pythonanywhere/platform_deployer.py index 00b0080..5a88464 100644 --- a/dsd_pythonanywhere/platform_deployer.py +++ b/dsd_pythonanywhere/platform_deployer.py @@ -251,7 +251,9 @@ def _show_success_message(self): """ if dsd_config.automate_all: msg = platform_msgs.success_msg_automate_all(self.deployed_url) - webbrowser.open(self.deployed_url) + # Don't launch a real browser during automated e2e test runs. + if not dsd_config.e2e_testing: + webbrowser.open(self.deployed_url) else: msg = platform_msgs.success_msg(log_output=dsd_config.log_output) plugin_utils.write_output(msg) diff --git a/pyproject.toml b/pyproject.toml index 84ecc14..1b9463e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ exclude = [ [dependency-groups] dev = [ "jupyterlab>=4.4.7", + "playwright>=1.62.0", "pre-commit>=4.3.0", "pytest>=8.4.2", "pytest-mock>=3.15.1", diff --git a/scripts/run_e2e_local.sh b/scripts/run_e2e_local.sh new file mode 100755 index 0000000..7f9b633 --- /dev/null +++ b/scripts/run_e2e_local.sh @@ -0,0 +1,78 @@ +#!/bin/bash +set -e + +# Run the PythonAnywhere e2e deployment test locally, mirroring +# .github/workflows/e2e_pythonanywhere.yaml. +# +# Usage: +# cd dsd-pythonanywhere && git checkout +# cp .env.e2e.example .env.e2e # first time only; fill in real values +# ./scripts/run_e2e_local.sh +# +# .env.e2e values can also be 1Password references (e.g. "op://vault/item/field"); +# in that case, run via `op run` so they're resolved into the environment before +# this script's own (non-resolving) `source` step runs: +# op run --env-file=.env.e2e -- ./scripts/run_e2e_local.sh +# +# Required env vars (loaded from .env.e2e if present, in the plugin root): +# API_USER PythonAnywhere username +# API_TOKEN PythonAnywhere API token +# PA_PASSWORD PythonAnywhere login password (for the headless console opener) +# DEMO_REPO_URL Push URL for the scratch repo PA will clone, with embedded +# credentials, e.g. https://x-access-token:@github.com/owner/repo.git +# DEMO_REPO_NAME Repo name only, e.g. dsd-pythonanywhere-testproj +# +# Assumes django-simple-deploy is checked out as a sibling directory; override +# with DSD_ROOT=/path/to/django-simple-deploy. +# +# Set CLEAN_VENV=1 to force-recreate $DSD_ROOT/.venv instead of reusing it. + +PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DSD_ROOT="${DSD_ROOT:-$PLUGIN_ROOT/../django-simple-deploy}" + +# Skip if already provided by `op run` (which resolves op:// refs itself); +# a plain `source` here would otherwise clobber them with the literal op:// string. +if [ -z "${API_USER:-}" ] && [ -f "$PLUGIN_ROOT/.env.e2e" ]; then + echo "Loading env vars from .env.e2e..." + set -a + source "$PLUGIN_ROOT/.env.e2e" + set +a +fi + +for var in API_USER API_TOKEN PA_PASSWORD DEMO_REPO_URL DEMO_REPO_NAME; do + if [ -z "${!var:-}" ]; then + echo "Missing required env var: $var (set it, or add it to .env.e2e)" >&2 + exit 1 + fi +done + +if [ ! -d "$DSD_ROOT" ]; then + echo "django-simple-deploy not found at $DSD_ROOT (set DSD_ROOT to override)" >&2 + exit 1 +fi + +export DSD_PYTHONANYWHERE_CONSOLE_OPENER="e2e_tests.pa_admin:open_console" +export PYTHONPATH="$PLUGIN_ROOT/tests" + +cd "$DSD_ROOT" + +echo "Installing dependencies..." +if [ "${CLEAN_VENV:-}" = "1" ]; then + uv venv --clear +elif [ ! -d ".venv" ]; then + uv venv +fi +uv pip install -e ".[dev]" +uv pip install -e "$PLUGIN_ROOT" +# playwright is a dev dependency-group, not an extra, so install it directly. +uv pip install playwright +uv run playwright install chromium --with-deps + +echo "Pre-test cleanup (webapp + filesystem)..." +uv run python -m e2e_tests.pa_admin + +echo "Running e2e test..." +uv run pytest tests/e2e_tests --plugin dsd_pythonanywhere --automate-all --skip-confirmations -s + +echo "Teardown..." +uv run python -m e2e_tests.pa_admin diff --git a/tests/e2e_tests/pa_admin.py b/tests/e2e_tests/pa_admin.py new file mode 100644 index 0000000..27d3d73 --- /dev/null +++ b/tests/e2e_tests/pa_admin.py @@ -0,0 +1,73 @@ +"""PythonAnywhere-specific e2e test helpers: headless console login and account reset. + +Both entry points expect PYTHONPATH to include the plugin's tests/ dir, so +this module resolves as the top-level package "e2e_tests.pa_admin" -- see +.github/workflows/e2e_pythonanywhere.yaml and scripts/run_e2e_local.sh. +""" + +import logging +import os + +from playwright.sync_api import sync_playwright + +from . import utils as platform_utils + +LOGIN_URL = "https://www.pythonanywhere.com/login/" + + +def open_console(url: str) -> None: + """Log in and load a PA console URL headlessly to trigger console startup. + + Loading the console URL triggers PA's client-side JS to open a websocket + and spawn the actual bash console process server-side -- something the API + alone cannot do. Resolved via + DSD_PYTHONANYWHERE_CONSOLE_OPENER=e2e_tests.pa_admin:open_console + (see dsd_pythonanywhere.client._get_console_opener). + """ + username = os.environ["API_USER"] + password = os.environ["PA_PASSWORD"] + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + + page.goto(LOGIN_URL) + page.locator("#id_auth-username").fill(username) + page.locator("#id_auth-password").fill(password) + page.locator("#id_next").click() + page.wait_for_load_state("networkidle") + + if "/login/" in page.url: + raise RuntimeError("PythonAnywhere login failed; check PA_PASSWORD secret.") + + page.goto(url) + page.wait_for_load_state("networkidle") + page.wait_for_timeout(5000) + browser.close() + + +def reset_account() -> None: + """Wipe PythonAnywhere account state so each e2e run starts clean. + + setup.sh skips re-cloning/re-creating the venv if they already exist, so a + dirty account from a prior run won't self-clean; call this before (and + after, via `if: always()`) each e2e run. Requires API_USER, API_TOKEN, and + DEMO_REPO_NAME environment variables. + """ + repo_name = os.environ["DEMO_REPO_NAME"] + client = platform_utils.get_client() + + if client.webapp_exists(): + print("Deleting existing webapp...") + client.webapp.delete() + + print(f"Removing ~/{repo_name} and ~/venv...") + client.run_command(f"rm -rf ~/{repo_name} ~/venv") + + +if __name__ == "__main__": + # dsd_pythonanywhere.client.log_message() only prints via plugin_utils, which + # isn't configured outside a real `manage.py deploy` run; wire up basic + # logging here so console-polling progress is visible. + logging.basicConfig(level=logging.INFO, format="%(message)s") + reset_account() diff --git a/tests/e2e_tests/test_deployment.py b/tests/e2e_tests/test_deployment.py index 3e1eb10..a9948a2 100644 --- a/tests/e2e_tests/test_deployment.py +++ b/tests/e2e_tests/test_deployment.py @@ -1,3 +1,4 @@ +import os import time import pytest @@ -18,6 +19,25 @@ def test_dummy(tmp_project, request): pass +def _configure_demo_repo_remote(tmp_project): + """Point tmp_project at the real GitHub repo PythonAnywhere will clone from. + + The plugin pushes to `origin` and has PA `git clone` that same URL, so a + real, internet-reachable remote is required -- tmp_project only has a + local git history (see manage_sample_project.py), and it's brand new on + every run, so it's force-pushed onto the remote's `main` branch first. + This makes the plugin's later plain (non-force) `git push origin HEAD` a + fast-forward, without needing branch-deletion permission on the remote. + """ + demo_repo_url = os.environ["DEMO_REPO_URL"] + it_utils.make_sp_call(f"git -C {tmp_project} remote add origin {demo_repo_url}") + result = it_utils.make_sp_call( + f"git -C {tmp_project} push --force origin HEAD:main", capture_output=True + ) + if result.returncode != 0: + raise RuntimeError(f"Failed to reset demo repo's main branch: {result.stderr.decode()}") + + # Skip this test and enable test_dummy() to speed up testing of setup steps. # @pytest.mark.skip def test_deployment(tmp_project, cli_options, request): @@ -31,11 +51,14 @@ def test_deployment(tmp_project, cli_options, request): python_cmd = it_utils.get_python_exe(tmp_project) - # Note: If not using automate_all, take steps here that the end user would take. - # Create a new project on the remote host, if not testing --automate-all. - # if not cli_options.automate_all: - # app_name = platform_utils.create_project() - # request.config.cache.set("app_name", app_name) + # PythonAnywhere's webapp is tied to the account, so only automate_all is + # supported here; a non-automated flow doesn't have a project to create first. + if not cli_options.automate_all: + raise NotImplementedError( + "dsd-pythonanywhere e2e tests only support the --automate-all workflow." + ) + + _configure_demo_repo_remote(tmp_project) # Run simple_deploy against the test project. it_utils.run_simple_deploy(python_cmd, automate_all=cli_options.automate_all) @@ -44,37 +67,18 @@ def test_deployment(tmp_project, cli_options, request): if cli_options.pkg_manager == "pipenv": it_utils.make_sp_call(f"{python_cmd} -m pipenv lock") - # Note: This is an example of how you can stash information about the deployment, - # which can be used in the teardown phase. You do need to set project_url, - # in order to run functionality tests against the deployed project. - # - # Get the deployed project's URL, and ID so we can destroy it later. - # This also commits configuration changes and pushes the project - # when testing the configuration-only workflow. - # When testing automate_all, cache app_name for teardown work. - # if cli_options.automate_all: - # project_url, app_name = platform_utils.get_project_url_name() - # request.config.cache.set("app_name", app_name) - # else: - # it_utils.commit_configuration_changes() - # project_url = platform_utils.deploy_project(app_name) - - # Note: ***** Remove this line, or your test will always report as passed! ***** - remote_functionality_passed = True + # Get the deployed project's URL and app name, and cache app_name for teardown. + project_url, app_name = platform_utils.get_project_url_name() + request.config.cache.set("app_name", app_name) # Remote functionality test often fails if run too quickly after deployment. print("\nPausing 10s to let deployment finish...") time.sleep(10) - # Note: Uncomment this section once your deployment is successful, and - # project_url is set in the above section. - # # Test functionality of both deployed app, and local project. # We want to make sure the deployment works, but also make sure we haven't # affected functionality of the local project using the development server. - # remote_functionality_passed = it_utils.check_deployed_app_functionality( - # python_cmd, project_url - # ) + remote_functionality_passed = it_utils.check_deployed_app_functionality(python_cmd, project_url) local_functionality_passed = it_utils.check_local_app_functionality(python_cmd) log_check_passed = platform_utils.check_log(tmp_project) diff --git a/tests/e2e_tests/utils.py b/tests/e2e_tests/utils.py index d8f7bb6..01bb215 100644 --- a/tests/e2e_tests/utils.py +++ b/tests/e2e_tests/utils.py @@ -1,74 +1,29 @@ -"""Helper functions specific to {{PlatformmName}}. +"""Helper functions specific to PythonAnywhere e2e tests.""" -Some Fly.io functions are included as an example. -""" +import os -# import re, time -# import json +from dsd_pythonanywhere.client import PythonAnywhereClient -# from tests.e2e_tests.utils.it_helper_functions import make_sp_call +def get_client() -> PythonAnywhereClient: + """Return a client for the account under test.""" + return PythonAnywhereClient(username=os.environ["API_USER"]) -# def create_project(): -# """Create a project on Fly.io.""" -# print("\n\nCreating a project on Fly.io...") -# output = ( -# make_sp_call(f"fly apps create --generate-name", capture_output=True) -# .stdout.decode() -# .strip() -# ) -# print("create_project output:", output) -# re_app_name = r"New app created: (.*)" -# app_name = re.search(re_app_name, output).group(1) -# print(f" App name: {app_name}") +def get_project_url_name(): + """Get project URL and app name of a deployed project. -# return app_name - - -# def deploy_project(app_name): -# """Make a non-automated deployment.""" -# # Consider pausing before the deployment. Some platforms need a moment -# # for the newly-created resources to become fully available. -# # time.sleep(30) - -# print("Deploying to Fly.io...") -# make_sp_call("fly deploy") - -# # Open project and get URL. -# output = ( -# make_sp_call(f"fly apps open -a {app_name}", capture_output=True) -# .stdout.decode() -# .strip() -# ) -# print("fly open output:", output) - -# re_url = r"opening (http.*) \.\.\." -# project_url = re.search(re_url, output).group(1) -# if "https" not in project_url: -# project_url = project_url.replace("http", "https") - -# print(f" Project URL: {project_url}") - -# return project_url - - -# def get_project_url_name(): -# """Get project URL and app name of a deployed project. -# This is used when testing the automate_all workflow. -# """ -# output = ( -# make_sp_call("fly status --json", capture_output=True).stdout.decode().strip() -# ) -# status_json = json.loads(output) - -# app_name = status_json["Name"] -# project_url = f"https://{app_name}.fly.dev" + PythonAnywhere's webapp is tied to the account, so both are deterministic + once deployment is complete; this is used when testing the automate_all + workflow. + """ + app_name = os.environ["API_USER"] + project_url = f"https://{app_name}.pythonanywhere.com" -# print(f" Found app name: {app_name}") -# print(f" Project URL: {project_url}") + print(f" Found app name: {app_name}") + print(f" Project URL: {project_url}") -# return project_url, app_name + return project_url, app_name def check_log(tmp_proj_dir): @@ -91,17 +46,15 @@ def check_log(tmp_proj_dir): return True -# def destroy_project(request): -# """Destroy the deployed project, and all remote resources.""" -# print("\nCleaning up:") - -# app_name = request.config.cache.get("app_name", None) -# if not app_name: -# print(" No app name found; can't destroy any remote resources.") -# return None +def destroy_project(request): + """Destroy the deployed project, and all remote resources.""" + print("\nCleaning up:") -# print(" Destroying Fly.io project...") -# make_sp_call(f"fly apps destroy -y {app_name}") + app_name = request.config.cache.get("app_name", None) + if not app_name: + print(" No app name found; can't destroy any remote resources.") + return None -# print(" Destroying Fly.io database...") -# make_sp_call(f"fly apps destroy -y {app_name}-db") + print(" Destroying PythonAnywhere webapp...") + client = get_client() + client.webapp.delete() diff --git a/tests/unit_tests/test_client_console.py b/tests/unit_tests/test_client_console.py index 5f9be12..c114992 100644 --- a/tests/unit_tests/test_client_console.py +++ b/tests/unit_tests/test_client_console.py @@ -161,3 +161,19 @@ def test_wait_for_command_completion_handles_exceptions(console, mocker): result = console.wait_for_command_completion("echo test", max_retries=5) assert result.output == "output" + + +def test_wait_for_ready_uses_console_opener(console, mock_api_client, mocker): + """wait_for_ready opens the console via the resolved opener, not webbrowser directly.""" + not_started = mocker.MagicMock(ok=False, status_code=412) + ready = mocker.MagicMock(ok=True) + mocker.patch.object(console, "send_input", side_effect=[not_started, ready]) + mock_command_run = mocker.MagicMock() + mock_command_run.output = "hello" + mocker.patch.object(console, "wait_for_command_completion", return_value=mock_command_run) + mocker.patch("dsd_pythonanywhere.client.time.sleep") + mock_opener = mocker.patch("dsd_pythonanywhere.client._get_console_opener") + + console.wait_for_ready() + + mock_opener.return_value.assert_called_once_with(console.browser_url) diff --git a/tests/unit_tests/test_client_console_opener.py b/tests/unit_tests/test_client_console_opener.py new file mode 100644 index 0000000..d10a9db --- /dev/null +++ b/tests/unit_tests/test_client_console_opener.py @@ -0,0 +1,34 @@ +import webbrowser + +import pytest + +from dsd_pythonanywhere.client import _get_console_opener + + +def test_get_console_opener_defaults_to_webbrowser_open(monkeypatch): + """Without the override env var, the default opener is webbrowser.open.""" + monkeypatch.delenv("DSD_PYTHONANYWHERE_CONSOLE_OPENER", raising=False) + assert _get_console_opener() is webbrowser.open + + +def test_get_console_opener_resolves_dotted_path(monkeypatch): + """The env var resolves a "module:function" path to a callable.""" + monkeypatch.setenv( + "DSD_PYTHONANYWHERE_CONSOLE_OPENER", + "test_client_console_opener:fake_opener", + ) + assert _get_console_opener() is fake_opener + + +def test_get_console_opener_missing_function_raises(monkeypatch): + """An unresolvable function name raises AttributeError.""" + monkeypatch.setenv( + "DSD_PYTHONANYWHERE_CONSOLE_OPENER", + "test_client_console_opener:does_not_exist", + ) + with pytest.raises(AttributeError): + _get_console_opener() + + +def fake_opener(url: str) -> None: + """Used only as a resolution target in test_get_console_opener_resolves_dotted_path.""" diff --git a/tests/unit_tests/test_platform_deployer.py b/tests/unit_tests/test_platform_deployer.py index 0db4d57..def5680 100644 --- a/tests/unit_tests/test_platform_deployer.py +++ b/tests/unit_tests/test_platform_deployer.py @@ -135,3 +135,31 @@ def test_validate_platform_skipped_without_automate_all(monkeypatch): deployer = PlatformDeployer() # Should not raise any exception even without credentials deployer._validate_platform() + + +def test_show_success_message_opens_browser_outside_e2e(monkeypatch, mocker): + """_show_success_message opens the deployed URL when not running as an e2e test.""" + monkeypatch.setattr(dsd_config, "automate_all", True) + monkeypatch.setattr(dsd_config, "e2e_testing", False) + monkeypatch.setattr(dsd_config, "stdout", sys.stdout) + mock_open = mocker.patch("dsd_pythonanywhere.platform_deployer.webbrowser.open") + + deployer = PlatformDeployer() + deployer.deployed_url = "https://testuser.pythonanywhere.com" + deployer._show_success_message() + + mock_open.assert_called_once_with(deployer.deployed_url) + + +def test_show_success_message_skips_browser_during_e2e(monkeypatch, mocker): + """_show_success_message does not open a real browser during e2e test runs.""" + monkeypatch.setattr(dsd_config, "automate_all", True) + monkeypatch.setattr(dsd_config, "e2e_testing", True) + monkeypatch.setattr(dsd_config, "stdout", sys.stdout) + mock_open = mocker.patch("dsd_pythonanywhere.platform_deployer.webbrowser.open") + + deployer = PlatformDeployer() + deployer.deployed_url = "https://testuser.pythonanywhere.com" + deployer._show_success_message() + + mock_open.assert_not_called() diff --git a/uv.lock b/uv.lock index 809a5a4..086a161 100644 --- a/uv.lock +++ b/uv.lock @@ -524,6 +524,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "jupyterlab" }, + { name = "playwright" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-mock" }, @@ -544,6 +545,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "jupyterlab", specifier = ">=4.4.7" }, + { name = "playwright", specifier = ">=1.62.0" }, { name = "pre-commit", specifier = ">=4.3.0" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-mock", specifier = ">=3.15.1" }, @@ -598,6 +600,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, ] +[[package]] +name = "greenlet" +version = "3.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/67/07ecd6d85c0f253363cbc4ac9e7dd048ca571a267fbccfea084d6009ac3b/greenlet-3.5.5-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:816230f469381ad0a43abc9fa8dda5a699e32fb78958dde32ded93213b70a667", size = 292971, upload-time = "2026-08-10T13:28:09.837Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/426733bc24247ee17bb76df90f550c299ff5f8572b2bdd7cacb5c53fd994/greenlet-3.5.5-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5433cf291e0ef9114bd14d0d824db6e5e4a43033234bca48181a9597acca07b", size = 609290, upload-time = "2026-08-10T14:14:32.273Z" }, + { url = "https://files.pythonhosted.org/packages/9c/dc/17d3a5acceb2fd0bbc9682a228117b719cdfb68085e6dbb33fa325755f27/greenlet-3.5.5-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:19d59f068887d8c5907fc177f27683413ace3011b6ed646c0b309266e74a6502", size = 622650, upload-time = "2026-08-10T14:27:22.004Z" }, + { url = "https://files.pythonhosted.org/packages/74/3f/b31e6bd6adb8f80492efb6a47e8f9cd0e7335db5aac2795b1876d5afcfee/greenlet-3.5.5-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:86c5113d698cb8d927b2750bb1f1d59eefe3a37e0e0217491aee29a7f84ef52c", size = 629560, upload-time = "2026-08-10T14:30:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/51/91/00b3c0566316c6f383ea16ee05388ec4f57f6e0afa49e72ae471eda8c47f/greenlet-3.5.5-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff00e12102358292087274dfb1669132387ff6e7920ebf9d85f4826ce0d3a56", size = 622819, upload-time = "2026-08-10T13:40:46.562Z" }, + { url = "https://files.pythonhosted.org/packages/18/5e/58c561efde575c4ca070030e2e0513e8d1b07b76d8a2d84a9bcef1becd42/greenlet-3.5.5-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:c69bed34470abfcd456984fdadaa18e62169af4480335c45f3c32d1d9c12e638", size = 425489, upload-time = "2026-08-10T14:29:59.768Z" }, + { url = "https://files.pythonhosted.org/packages/3c/78/52de4f7ac9152ad1dbc8f437895c1e573d30ee1e1427e0f91a280e2417b6/greenlet-3.5.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:523bb8e27614d77101ea7a8cf59f8d91219b72d5c29f6a038c92b50828bfa8d0", size = 1582164, upload-time = "2026-08-10T14:15:02.645Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ff/c3855a00c2417e8f61c7b9f0bc4f8f599c6928f5c15681ad251e78a91e05/greenlet-3.5.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f1e2db190db51c17433eee424803818cf0670bf049d9cfe0dd07be111d1aa7c4", size = 1648807, upload-time = "2026-08-10T13:40:27.471Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/d29ff6a79dbd1ec1fe74c155d25d4c5a85e221d6b9ffc2cc7a709e7c8c39/greenlet-3.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:740e544169527b82695ce76af2f7ad6f030904658f2f3921a1d245771fb88cfc", size = 322832, upload-time = "2026-08-10T13:28:10.85Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, + { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/39/82/5c873dbb4fb001d22fbbd50e80d4c1b0181ddae106856132160f84b94e88/greenlet-3.5.5-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:abc8bc8d9f935cd685457545b6a53863a877fdc12c2c0f5ee9beee18d9db139c", size = 633329, upload-time = "2026-08-10T14:30:06.062Z" }, + { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/f7df98e72a8eb4a7edfec5a08d6d1a4ab53a52c95ba0b2ea6c10b8dd9bd0/greenlet-3.5.5-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:3134291427bb0f3526e9d90311988caf336eb43730e95244997a4fb15f45144f", size = 428145, upload-time = "2026-08-10T14:30:01.071Z" }, + { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, + { url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, + { url = "https://files.pythonhosted.org/packages/9b/30/87c212b5c684d0e72974f1063b7a9687631e8985902c06e1016542c874e7/greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f", size = 629504, upload-time = "2026-08-10T14:30:07.967Z" }, + { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/63/2c/eb487fafc9f50ffff2b1e0b697f70fb34bf150821c08ab225aacf5583a7e/greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759", size = 432462, upload-time = "2026-08-10T14:30:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, + { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, + { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, + { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/eaa476d6bf3816828d0d70e80dcc36bf30a058233bd889e707e693f6e860/greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42", size = 632726, upload-time = "2026-08-10T14:30:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f2/0cc2849ede68579291e9c59b3ab6ec1958f98681cca5b14d8fc75bf674a4/greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b", size = 434966, upload-time = "2026-08-10T14:30:03.729Z" }, + { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, + { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, + { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/52/f005d579acde46c3d1cc3cab1c9f3d5708c8a3006a4120e8cf5da801afe9/greenlet-3.5.5-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d98ef6f92e67c6dbf299dbfd8facc1b0d2d9cedf91e325e73b3d0373fe4309d8", size = 677863, upload-time = "2026-08-10T14:30:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8a/a75f8a2bdcef3c358a3147cdc9db3aa83755f0a038f766ab0bedb66f512c/greenlet-3.5.5-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:159df1942d88e8f784cbb38d6f18bdb365cd11319cfbb3e89623de2b97892d53", size = 480554, upload-time = "2026-08-10T14:30:05.171Z" }, + { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, + { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, + { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/0d7887aa4bbfc9eba075cc428244dfc96f623478454d5ec81180d0d6bd5a/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e9ec2e7c98e895fcea0c5cc57b2606cf86ece6d0a56578f3eb225e2af4f0387", size = 681587, upload-time = "2026-08-10T14:30:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, + { url = "https://files.pythonhosted.org/packages/4f/18/8d58ba1c429b0383e3219a3d0e0bba241d0444d8ed05b73349953c7d7c7b/greenlet-3.5.5-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:102817506f6090b5176c746a82603341a549b40e5c3d5b72a4c672228a918c41", size = 510175, upload-time = "2026-08-10T14:30:07.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, + { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, + { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, + { url = "https://files.pythonhosted.org/packages/65/53/4e13642efc4d7ad6554ecb2242a5be42666b2e1a067323e88dfc0124a04b/greenlet-3.5.5-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37faa97daccb6d9f4c2141ce3118d023c3c5506864a7d8bdf726f665018c1f76", size = 681436, upload-time = "2026-08-10T14:30:14.839Z" }, + { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, + { url = "https://files.pythonhosted.org/packages/cd/32/188447c9a468d6977d2989397226b0c6b65ab6f4cf943f931643328512fc/greenlet-3.5.5-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:b18007dc2473a7942fd157366b55f01da6fed7ce85318591005b419e0a439474", size = 487404, upload-time = "2026-08-10T14:30:08.903Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, + { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, + { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/e450a68a152f819491d8c7df6a8254e761d87e6a78759268961f8c5bd4dd/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2888a3a38bc5ee5bb6c438372197152e815837e4fab7ed7a1f86ef18ffd58ad1", size = 686022, upload-time = "2026-08-10T14:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/17e63d6bf3b9c9b9dbea981b7f643a71f79603bdfb4f1c3a9cf353e22aed/greenlet-3.5.5-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:1e8d9391fe77f15649589a907cef972dbbd6352ef7ff7dc0492f658c0c26495f", size = 516951, upload-time = "2026-08-10T14:30:10.907Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, + { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -1281,6 +1369,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] +[[package]] +name = "playwright" +version = "1.62.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/5b/ca2abcf3aa69f9fb510215e3064f30b57fe57657c8d04ede45bb966d5606/playwright-1.62.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:d8da938f3748841a8754f2e1f0216902c1c8f8ae3720de8b32ccf8e6913a7c4f", size = 43732091, upload-time = "2026-07-31T17:00:44.178Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/0bfbe9904350961f4dbb713f04342e40d548c5fc26c8157bd13617c81492/playwright-1.62.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:db755ab27db21a04186f1fe8169888e42356086e439b1059b923ef417f0b6034", size = 42510842, upload-time = "2026-07-31T17:00:48.596Z" }, + { url = "https://files.pythonhosted.org/packages/66/dc/c0486b407ad0699a250f6bbe3066fca95344009a99ca66e88ca175c69dc1/playwright-1.62.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:5108bd5b3e87169ddf269feee097da5893af7f8aea4634dfc840518d64c1f1da", size = 43732093, upload-time = "2026-07-31T17:00:52.218Z" }, + { url = "https://files.pythonhosted.org/packages/43/6b/b24aebc2b04bffcb342bccf96e287c78b363e1615bed5cea97500cc0393a/playwright-1.62.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ba33bae6a13b3d9d354c751cb618af357d20fe1d57767cbcce52079bbef17ad3", size = 47748926, upload-time = "2026-07-31T17:00:56.438Z" }, + { url = "https://files.pythonhosted.org/packages/36/43/b4b18bdc87e1949568fffdcde3ff9a0456266b2d0c6d4432cc34d89ea6eb/playwright-1.62.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:db2d76613a57ad844362ce42f7d0c2fa26b19a4f7a46d4f76b891c631e6e5aff", size = 47441423, upload-time = "2026-07-31T17:01:00.404Z" }, + { url = "https://files.pythonhosted.org/packages/81/22/af5d926fc2c32a339eec00a443644bc40ab9db1dd2dd9017873c59773c0c/playwright-1.62.0-py3-none-win32.whl", hash = "sha256:e5614fa89355d7081457680324bb219f79f69c423c5cb6fa250e30b0d8aebf1c", size = 38164450, upload-time = "2026-07-31T17:01:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/2b/a9/4160c1033c07af98bf841ad079457dd78408a5ee0dd56cbfe50b8b6a1c22/playwright-1.62.0-py3-none-win_amd64.whl", hash = "sha256:92c0d98ed04eb35af557b709875edba415b1f548bdb22ddb5bb3e1e6c835c2f1", size = 38164458, upload-time = "2026-07-31T17:01:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ec/06b55d619a7082a766aa04f2c6bb31435c87f02930087d8a0517119408fa/playwright-1.62.0-py3-none-win_arm64.whl", hash = "sha256:ea8d3055aa9d5a9f1832ac82517bd8b42c78fac7ebcbebb0107116735c8cb6a1", size = 34208868, upload-time = "2026-07-31T17:01:11.818Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1382,6 +1489,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, ] +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + [[package]] name = "pygments" version = "2.19.2"