From f5a844d2c2ca5302c45efe2f0fd719eb1cec69aa Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 7 Oct 2026 15:54:42 -0700 Subject: [PATCH 1/3] feat: make pytest-base-url an optional dependency pytest-base-url (MPL-2.0) and its transitive requests/certifi dependencies get flagged by license scanners. Move it to an optional extra (pytest-playwright[base-url]). When it is not installed, or disabled with -p no:base_url, the plugin provides --base-url (defaulting to $PYTEST_BASE_URL), the base_url ini option and the base_url fixture itself. Only --verify-base-url requires the extra. Fixes #311 --- local-requirements.txt | 1 + pytest-playwright-asyncio/pyproject.toml | 4 ++- .../pytest_playwright.py | 34 +++++++++++++++++++ pytest-playwright/pyproject.toml | 4 ++- .../pytest_playwright/pytest_playwright.py | 34 +++++++++++++++++++ tests/test_asyncio.py | 33 ++++++++++++++++++ tests/test_sync.py | 30 ++++++++++++++++ 7 files changed, 138 insertions(+), 2 deletions(-) diff --git a/local-requirements.txt b/local-requirements.txt index df13bb0..fd6c35d 100644 --- a/local-requirements.txt +++ b/local-requirements.txt @@ -11,5 +11,6 @@ pytest-xdist==3.8.0 # Only used by the test suite; the inner pytest runs block it unless a test opts in. pytest-rerunfailures==16.7 pytest-html==4.2.0 +pytest-base-url==2.1.0 pytest-asyncio==1.3.0 playwright>=1.60 diff --git a/pytest-playwright-asyncio/pyproject.toml b/pytest-playwright-asyncio/pyproject.toml index 35f7b2e..38f559d 100644 --- a/pytest-playwright-asyncio/pyproject.toml +++ b/pytest-playwright-asyncio/pyproject.toml @@ -25,11 +25,13 @@ dynamic = ["version"] dependencies = [ "playwright>=1.18", "pytest>=6.2.4,<10.0.0", - "pytest-base-url>=1.0.0,<3.0.0", "python-slugify>=6.0.0,<10.0.0", "pytest-asyncio>=0.24.0", ] +[project.optional-dependencies] +base-url = ["pytest-base-url>=1.0.0,<3.0.0"] + [project.urls] homepage = "https://github.com/microsoft/playwright-pytest" diff --git a/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py b/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py index 438ed81..0c6a8e3 100644 --- a/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py +++ b/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py @@ -14,6 +14,7 @@ import base64 import hashlib +import importlib.util import json import secrets import shutil @@ -157,6 +158,8 @@ def pytest_generate_tests(metafunc: Any) -> None: def pytest_configure(config: Any) -> None: + if not config.pluginmanager.has_plugin("base_url"): + config.pluginmanager.register(_BaseUrlFallback(), "playwright-base-url") config.addinivalue_line( "markers", "skip_browser(name): mark test to be skipped a specific browser" ) @@ -760,6 +763,37 @@ def pytest_addoption( choices=["cli"], help="Enable Playwright CLI debugging. Requires -s/--capture=no.", ) + if not _is_pytest_base_url_available(pluginmanager): + # Same options as the optional pytest-base-url plugin. + parser.addini("base_url", help="base url for the application under test.") + parser.addoption( + "--base-url", + metavar="url", + default=os.getenv("PYTEST_BASE_URL", None), + help="base url for the application under test.", + ) + + +def _is_pytest_base_url_available(pluginmanager: pytest.PytestPluginManager) -> bool: + # pytest-base-url is optional (pip install pytest-playwright[base-url]); when + # used it provides --base-url and the base_url fixture. + if importlib.util.find_spec("pytest_base_url") is None: + return False + return not pluginmanager.is_blocked("base_url") + + +class _BaseUrlFallback: + # Provides the base_url fixture when pytest-base-url is not used. + @pytest.fixture(scope="session") + def base_url(self, pytestconfig: Any) -> Optional[str]: + base_url = pytestconfig.getoption("--base-url", default=None) + if base_url: + return base_url + try: + return pytestconfig.getini("base_url") or None + except ValueError: + # --base-url and the ini value are not registered. + return None class ArtifactsRecorder: diff --git a/pytest-playwright/pyproject.toml b/pytest-playwright/pyproject.toml index aa5863d..cf78300 100644 --- a/pytest-playwright/pyproject.toml +++ b/pytest-playwright/pyproject.toml @@ -25,10 +25,12 @@ dynamic = ["version"] dependencies = [ "playwright>=1.18", "pytest>=6.2.4,<10.0.0", - "pytest-base-url>=1.0.0,<3.0.0", "python-slugify>=6.0.0,<10.0.0", ] +[project.optional-dependencies] +base-url = ["pytest-base-url>=1.0.0,<3.0.0"] + [project.urls] homepage = "https://github.com/microsoft/playwright-pytest" diff --git a/pytest-playwright/pytest_playwright/pytest_playwright.py b/pytest-playwright/pytest_playwright/pytest_playwright.py index b3673de..4f94ead 100644 --- a/pytest-playwright/pytest_playwright/pytest_playwright.py +++ b/pytest-playwright/pytest_playwright/pytest_playwright.py @@ -14,6 +14,7 @@ import base64 import hashlib +import importlib.util import json import secrets import shutil @@ -154,6 +155,8 @@ def pytest_generate_tests(metafunc: Any) -> None: def pytest_configure(config: Any) -> None: + if not config.pluginmanager.has_plugin("base_url"): + config.pluginmanager.register(_BaseUrlFallback(), "playwright-base-url") config.addinivalue_line( "markers", "skip_browser(name): mark test to be skipped a specific browser" ) @@ -757,6 +760,37 @@ def pytest_addoption( choices=["cli"], help="Enable Playwright CLI debugging. Requires -s/--capture=no.", ) + if not _is_pytest_base_url_available(pluginmanager): + # Same options as the optional pytest-base-url plugin. + parser.addini("base_url", help="base url for the application under test.") + parser.addoption( + "--base-url", + metavar="url", + default=os.getenv("PYTEST_BASE_URL", None), + help="base url for the application under test.", + ) + + +def _is_pytest_base_url_available(pluginmanager: pytest.PytestPluginManager) -> bool: + # pytest-base-url is optional (pip install pytest-playwright[base-url]); when + # used it provides --base-url and the base_url fixture. + if importlib.util.find_spec("pytest_base_url") is None: + return False + return not pluginmanager.is_blocked("base_url") + + +class _BaseUrlFallback: + # Provides the base_url fixture when pytest-base-url is not used. + @pytest.fixture(scope="session") + def base_url(self, pytestconfig: Any) -> Optional[str]: + base_url = pytestconfig.getoption("--base-url", default=None) + if base_url: + return base_url + try: + return pytestconfig.getini("base_url") or None + except ValueError: + # --base-url and the ini value are not registered. + return None class ArtifactsRecorder: diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index e04fa53..b8ad024 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -446,6 +446,39 @@ async def test_base_url(page, base_url): result.assert_outcomes(passed=1) +@pytest.mark.parametrize("source", ["option", "ini", "env", "none"]) +def test_base_url_without_pytest_base_url( + testdir: pytest.Testdir, + test_server: HTTPTestServer, + monkeypatch: pytest.MonkeyPatch, + source: str, +) -> None: + # https://github.com/microsoft/playwright-pytest/issues/311 + args = ["-p", "no:base_url"] + if source == "option": + args += ["--base-url", test_server.PREFIX] + elif source == "ini": + args += ["-o", f"base_url={test_server.PREFIX}"] + elif source == "env": + monkeypatch.setenv("PYTEST_BASE_URL", test_server.PREFIX) + expected = None if source == "none" else test_server.PREFIX + testdir.makepyfile( + f""" + import pytest + + @pytest.mark.asyncio + async def test_base_url(page, base_url): + assert base_url == {expected!r} + await page.goto("{test_server.PREFIX}/foobar") + if base_url: + await page.goto("/foobar") + assert page.url == "{test_server.PREFIX}/foobar" + """ + ) + result = testdir.runpytest(*args) + result.assert_outcomes(passed=1) + + def test_skip_browsers(testdir: pytest.Testdir) -> None: testdir.makepyfile( """ diff --git a/tests/test_sync.py b/tests/test_sync.py index c203964..3a0e1c0 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -462,6 +462,36 @@ def test_base_url(page, base_url): result.assert_outcomes(passed=1) +@pytest.mark.parametrize("source", ["option", "ini", "env", "none"]) +def test_base_url_without_pytest_base_url( + testdir: pytest.Testdir, + test_server: HTTPTestServer, + monkeypatch: pytest.MonkeyPatch, + source: str, +) -> None: + # https://github.com/microsoft/playwright-pytest/issues/311 + args = ["-p", "no:base_url"] + if source == "option": + args += ["--base-url", test_server.PREFIX] + elif source == "ini": + args += ["-o", f"base_url={test_server.PREFIX}"] + elif source == "env": + monkeypatch.setenv("PYTEST_BASE_URL", test_server.PREFIX) + expected = None if source == "none" else test_server.PREFIX + testdir.makepyfile( + f""" + def test_base_url(page, base_url): + assert base_url == {expected!r} + page.goto("{test_server.PREFIX}/foobar") + if base_url: + page.goto("/foobar") + assert page.url == "{test_server.PREFIX}/foobar" + """ + ) + result = testdir.runpytest(*args) + result.assert_outcomes(passed=1) + + def test_skip_browsers(testdir: pytest.Testdir) -> None: testdir.makepyfile( """ From 688cb5d3e963519c0be19292071666e9686dece1 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 7 Oct 2026 18:21:17 -0700 Subject: [PATCH 2/3] review: decide base-url ownership from the plugins actually loaded, test with plugin autoload disabled, run the base-url tests without pytest-base-url in CI --- .github/workflows/ci.yml | 20 ++++++ .../pytest_playwright.py | 61 +++++++++++-------- .../pytest_playwright/pytest_playwright.py | 61 +++++++++++-------- tests/test_asyncio.py | 39 ++++++++++++ tests/test_sync.py | 31 ++++++++++ 5 files changed, 158 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fcd1bf..013cafe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,3 +53,23 @@ jobs: - name: Test on Linux if: ${{ matrix.os == 'ubuntu-latest' }} run: xvfb-run pytest --cov=pytest_playwright --cov-report xml + test-without-base-url: + # pytest-base-url is an optional extra; run the base-url tests without it installed. + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: 3.12 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r local-requirements.txt + pip install -e pytest-playwright + pip install -e pytest-playwright-asyncio + pip uninstall -y pytest-base-url + playwright install chromium --with-deps + - name: Test + run: xvfb-run pytest -k base_url diff --git a/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py b/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py index 0c6a8e3..20e91a4 100644 --- a/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py +++ b/pytest-playwright-asyncio/pytest_playwright_asyncio/pytest_playwright.py @@ -14,7 +14,6 @@ import base64 import hashlib -import importlib.util import json import secrets import shutil @@ -158,8 +157,6 @@ def pytest_generate_tests(metafunc: Any) -> None: def pytest_configure(config: Any) -> None: - if not config.pluginmanager.has_plugin("base_url"): - config.pluginmanager.register(_BaseUrlFallback(), "playwright-base-url") config.addinivalue_line( "markers", "skip_browser(name): mark test to be skipped a specific browser" ) @@ -763,37 +760,47 @@ def pytest_addoption( choices=["cli"], help="Enable Playwright CLI debugging. Requires -s/--capture=no.", ) - if not _is_pytest_base_url_available(pluginmanager): - # Same options as the optional pytest-base-url plugin. - parser.addini("base_url", help="base url for the application under test.") - parser.addoption( - "--base-url", - metavar="url", - default=os.getenv("PYTEST_BASE_URL", None), - help="base url for the application under test.", - ) -def _is_pytest_base_url_available(pluginmanager: pytest.PytestPluginManager) -> bool: - # pytest-base-url is optional (pip install pytest-playwright[base-url]); when - # used it provides --base-url and the base_url fixture. - if importlib.util.find_spec("pytest_base_url") is None: - return False - return not pluginmanager.is_blocked("base_url") +@pytest.hookimpl(hookwrapper=True) +def pytest_load_initial_conftests( + early_config: Any, parser: pytest.Parser +) -> Generator[None, None, None]: + yield + # By now every plugin is registered: from -p, PYTEST_PLUGINS, the entry points + # (unless autoloading is disabled) and the initial conftests. So this is the + # first point where it is known whether pytest-base-url is in use, and options + # can still be added, as the command line is parsed afterwards. + if _is_pytest_base_url_loaded(early_config.pluginmanager): + return + # pytest-base-url is optional (pip install pytest-playwright[base-url]). When + # it is not in use, provide the same option, ini value and fixture. + parser.addini("base_url", help="base url for the application under test.") + parser.getgroup("playwright", "Playwright").addoption( + "--base-url", + metavar="url", + default=os.getenv("PYTEST_BASE_URL", None), + help="base url for the application under test.", + ) + early_config.pluginmanager.register(_BaseUrlFallback(), "playwright-base-url") + + +def _is_pytest_base_url_loaded(pluginmanager: pytest.PytestPluginManager) -> bool: + # Registered as "base_url" through its entry point, or under its module name + # with -p pytest_base_url.plugin; checking the module covers both. + module = sys.modules.get("pytest_base_url.plugin") + return module is not None and pluginmanager.is_registered(module) class _BaseUrlFallback: - # Provides the base_url fixture when pytest-base-url is not used. + # Provides the base_url fixture when pytest-base-url is not in use. @pytest.fixture(scope="session") def base_url(self, pytestconfig: Any) -> Optional[str]: - base_url = pytestconfig.getoption("--base-url", default=None) - if base_url: - return base_url - try: - return pytestconfig.getini("base_url") or None - except ValueError: - # --base-url and the ini value are not registered. - return None + return ( + pytestconfig.getoption("--base-url") + or pytestconfig.getini("base_url") + or None + ) class ArtifactsRecorder: diff --git a/pytest-playwright/pytest_playwright/pytest_playwright.py b/pytest-playwright/pytest_playwright/pytest_playwright.py index 4f94ead..5135eb7 100644 --- a/pytest-playwright/pytest_playwright/pytest_playwright.py +++ b/pytest-playwright/pytest_playwright/pytest_playwright.py @@ -14,7 +14,6 @@ import base64 import hashlib -import importlib.util import json import secrets import shutil @@ -155,8 +154,6 @@ def pytest_generate_tests(metafunc: Any) -> None: def pytest_configure(config: Any) -> None: - if not config.pluginmanager.has_plugin("base_url"): - config.pluginmanager.register(_BaseUrlFallback(), "playwright-base-url") config.addinivalue_line( "markers", "skip_browser(name): mark test to be skipped a specific browser" ) @@ -760,37 +757,47 @@ def pytest_addoption( choices=["cli"], help="Enable Playwright CLI debugging. Requires -s/--capture=no.", ) - if not _is_pytest_base_url_available(pluginmanager): - # Same options as the optional pytest-base-url plugin. - parser.addini("base_url", help="base url for the application under test.") - parser.addoption( - "--base-url", - metavar="url", - default=os.getenv("PYTEST_BASE_URL", None), - help="base url for the application under test.", - ) -def _is_pytest_base_url_available(pluginmanager: pytest.PytestPluginManager) -> bool: - # pytest-base-url is optional (pip install pytest-playwright[base-url]); when - # used it provides --base-url and the base_url fixture. - if importlib.util.find_spec("pytest_base_url") is None: - return False - return not pluginmanager.is_blocked("base_url") +@pytest.hookimpl(hookwrapper=True) +def pytest_load_initial_conftests( + early_config: Any, parser: pytest.Parser +) -> Generator[None, None, None]: + yield + # By now every plugin is registered: from -p, PYTEST_PLUGINS, the entry points + # (unless autoloading is disabled) and the initial conftests. So this is the + # first point where it is known whether pytest-base-url is in use, and options + # can still be added, as the command line is parsed afterwards. + if _is_pytest_base_url_loaded(early_config.pluginmanager): + return + # pytest-base-url is optional (pip install pytest-playwright[base-url]). When + # it is not in use, provide the same option, ini value and fixture. + parser.addini("base_url", help="base url for the application under test.") + parser.getgroup("playwright", "Playwright").addoption( + "--base-url", + metavar="url", + default=os.getenv("PYTEST_BASE_URL", None), + help="base url for the application under test.", + ) + early_config.pluginmanager.register(_BaseUrlFallback(), "playwright-base-url") + + +def _is_pytest_base_url_loaded(pluginmanager: pytest.PytestPluginManager) -> bool: + # Registered as "base_url" through its entry point, or under its module name + # with -p pytest_base_url.plugin; checking the module covers both. + module = sys.modules.get("pytest_base_url.plugin") + return module is not None and pluginmanager.is_registered(module) class _BaseUrlFallback: - # Provides the base_url fixture when pytest-base-url is not used. + # Provides the base_url fixture when pytest-base-url is not in use. @pytest.fixture(scope="session") def base_url(self, pytestconfig: Any) -> Optional[str]: - base_url = pytestconfig.getoption("--base-url", default=None) - if base_url: - return base_url - try: - return pytestconfig.getini("base_url") or None - except ValueError: - # --base-url and the ini value are not registered. - return None + return ( + pytestconfig.getoption("--base-url") + or pytestconfig.getini("base_url") + or None + ) class ArtifactsRecorder: diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index b8ad024..354ecdf 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import importlib.util import os import signal import subprocess @@ -479,6 +480,44 @@ async def test_base_url(page, base_url): result.assert_outcomes(passed=1) +def test_base_url_without_plugin_autoload( + testdir: pytest.Testdir, + test_server: HTTPTestServer, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Only plugins that are actually loaded count, not installed ones. + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + testdir.makepyfile( + f""" + import pytest + + @pytest.mark.asyncio + async def test_base_url(page, base_url): + assert base_url == "{test_server.PREFIX}" + await page.goto("/foobar") + assert page.url == "{test_server.PREFIX}/foobar" + """ + ) + playwright_plugin = [ + "-p", + "pytest_playwright_asyncio.pytest_playwright", + "-p", + "pytest_asyncio.plugin", + ] + plugin_sets = [playwright_plugin] + if importlib.util.find_spec("pytest_base_url") is not None: + # Loaded explicitly, in either order and under either name, pytest-base-url + # provides the option instead. + plugin_sets += [ + playwright_plugin + ["-p", "base_url"], + ["-p", "base_url"] + playwright_plugin, + playwright_plugin + ["-p", "pytest_base_url.plugin"], + ] + for plugins in plugin_sets: + result = testdir.runpytest(*plugins, "--base-url", test_server.PREFIX) + result.assert_outcomes(passed=1) + + def test_skip_browsers(testdir: pytest.Testdir) -> None: testdir.makepyfile( """ diff --git a/tests/test_sync.py b/tests/test_sync.py index 3a0e1c0..6ca84c5 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import importlib.util import os from pathlib import Path import signal @@ -492,6 +493,36 @@ def test_base_url(page, base_url): result.assert_outcomes(passed=1) +def test_base_url_without_plugin_autoload( + testdir: pytest.Testdir, + test_server: HTTPTestServer, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Only plugins that are actually loaded count, not installed ones. + monkeypatch.setenv("PYTEST_DISABLE_PLUGIN_AUTOLOAD", "1") + testdir.makepyfile( + f""" + def test_base_url(page, base_url): + assert base_url == "{test_server.PREFIX}" + page.goto("/foobar") + assert page.url == "{test_server.PREFIX}/foobar" + """ + ) + playwright_plugin = ["-p", "pytest_playwright.pytest_playwright"] + plugin_sets = [playwright_plugin] + if importlib.util.find_spec("pytest_base_url") is not None: + # Loaded explicitly, in either order and under either name, pytest-base-url + # provides the option instead. + plugin_sets += [ + playwright_plugin + ["-p", "base_url"], + ["-p", "base_url"] + playwright_plugin, + playwright_plugin + ["-p", "pytest_base_url.plugin"], + ] + for plugins in plugin_sets: + result = testdir.runpytest(*plugins, "--base-url", test_server.PREFIX) + result.assert_outcomes(passed=1) + + def test_skip_browsers(testdir: pytest.Testdir) -> None: testdir.makepyfile( """ From fc15d8a455287e63752769e5bc1d659e14862e55 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 7 Oct 2026 19:27:59 -0700 Subject: [PATCH 3/3] Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 013cafe..925922a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: branches: [ main ] pull_request: branches: [ main ] +permissions: + contents: read jobs: lint: runs-on: ubuntu-latest