diff --git a/examples/cdp_mode/playwright/raw_browserscan_nested.py b/examples/cdp_mode/playwright/raw_browserscan_nested.py new file mode 100644 index 00000000000..d1eeb10f164 --- /dev/null +++ b/examples/cdp_mode/playwright/raw_browserscan_nested.py @@ -0,0 +1,16 @@ +from playwright.sync_api import sync_playwright +from seleniumbase import SB + +with SB(uc=True, locale="en") as sb: + sb.activate_cdp_mode() + endpoint_url = sb.cdp.get_endpoint_url() + + with sync_playwright() as p: + browser = p.chromium.connect_over_cdp(endpoint_url) + page = browser.contexts[0].pages[0] + page.goto("https://www.browserscan.net/bot-detection") + page.wait_for_timeout(1000) + sb.cdp.flash("Test Results", duration=4) + page.wait_for_timeout(1000) + sb.assert_element('strong:contains("Normal")') + sb.cdp.flash('strong:contains("Normal")', duration=4, pause=4) diff --git a/examples/cdp_mode/playwright/raw_browserscan_sync.py b/examples/cdp_mode/playwright/raw_browserscan_sync.py new file mode 100644 index 00000000000..fc9f00f20c2 --- /dev/null +++ b/examples/cdp_mode/playwright/raw_browserscan_sync.py @@ -0,0 +1,15 @@ +from playwright.sync_api import sync_playwright +from seleniumbase import sb_cdp + +sb = sb_cdp.Chrome(locale="en") +endpoint_url = sb.get_endpoint_url() + +with sync_playwright() as p: + browser = p.chromium.connect_over_cdp(endpoint_url) + page = browser.contexts[0].pages[0] + page.goto("https://www.browserscan.net/bot-detection") + page.wait_for_timeout(1000) + sb.flash("Test Results", duration=4) + page.wait_for_timeout(1000) + sb.assert_element('strong:contains("Normal")') + sb.flash('strong:contains("Normal")', duration=4, pause=4) diff --git a/examples/cdp_mode/raw_muse.py b/examples/cdp_mode/raw_muse.py new file mode 100644 index 00000000000..e182b6baa5a --- /dev/null +++ b/examples/cdp_mode/raw_muse.py @@ -0,0 +1,11 @@ +"""(Bypasses Friendly Captcha)""" +from seleniumbase import SB + +with SB(uc=True, test=True, guest=True) as sb: + url = "https://muse.jhu.edu/verify" + sb.activate_cdp_mode(url) + sb.sleep(1.5) + sb.solve_captcha() + sb.sleep(4) + sb.assert_element('#search_input') + sb.sleep(3) diff --git a/examples/cdp_mode/raw_muse_async.py b/examples/cdp_mode/raw_muse_async.py new file mode 100644 index 00000000000..03cd0919b7e --- /dev/null +++ b/examples/cdp_mode/raw_muse_async.py @@ -0,0 +1,18 @@ +"""(Bypasses Friendly Captcha)""" +import asyncio +from seleniumbase import cdp_driver + + +async def main(): + url = "https://muse.jhu.edu/verify" + driver = await cdp_driver.start_async(guest=True) + page = await driver.get(url) + await page.sleep(2.5) + await page.solve_captcha() + await page.sleep(4) + await page.find('#search_input') + await page.sleep(3) + +if __name__ == "__main__": + loop = asyncio.new_event_loop() + loop.run_until_complete(main()) diff --git a/mkdocs_build/requirements.txt b/mkdocs_build/requirements.txt index 4a954d203c0..0516eb91f49 100644 --- a/mkdocs_build/requirements.txt +++ b/mkdocs_build/requirements.txt @@ -3,7 +3,7 @@ regex>=2026.3.32 pymdown-extensions>=10.21.2 -pipdeptree>=2.34.0 +pipdeptree>=2.35.1 python-dateutil>=2.8.2 Markdown==3.10.2 click==8.3.1 diff --git a/requirements.txt b/requirements.txt index d202ff5f7be..28ebad49a39 100755 --- a/requirements.txt +++ b/requirements.txt @@ -14,7 +14,7 @@ fasteners>=0.20 mycdp>=1.3.7 pynose>=1.5.5 platformdirs~=4.4.0;python_version<"3.10" -platformdirs>=4.9.4;python_version>="3.10" +platformdirs>=4.9.6;python_version>="3.10" typing-extensions>=4.15.0 sbvirtualdisplay>=1.4.0 MarkupSafe>=3.0.3 @@ -29,7 +29,7 @@ pyreadline3>=3.5.4;platform_system=="Windows" tabcompleter>=1.4.0 pdbp>=1.8.2 idna>=3.11 -charset-normalizer>=3.4.6,<4 +charset-normalizer>=3.4.7,<4 urllib3>=1.26.20,<2;python_version<"3.10" urllib3>=1.26.20,<3;python_version>="3.10" requests~=2.32.5;python_version<"3.10" @@ -44,10 +44,9 @@ wsproto==1.2.0;python_version<"3.10" wsproto~=1.3.2;python_version>="3.10" websocket-client~=1.9.0 selenium==4.32.0;python_version<"3.10" -selenium==4.41.0;python_version>="3.10" +selenium==4.43.0;python_version>="3.10" cssselect==1.3.0;python_version<"3.10" cssselect>=1.4.0,<2;python_version>="3.10" -nest-asyncio==1.6.0 sortedcontainers==2.4.0 execnet==2.1.1;python_version<"3.10" execnet==2.1.2;python_version>="3.10" @@ -55,7 +54,7 @@ iniconfig==2.1.0;python_version<"3.10" iniconfig==2.3.0;python_version>="3.10" pluggy==1.6.0 pytest==8.4.2;python_version<"3.11" -pytest==9.0.2;python_version>="3.11" +pytest==9.0.3;python_version>="3.11" pytest-html==4.0.2 pytest-metadata==3.1.1 pytest-ordering==0.6 diff --git a/seleniumbase/__version__.py b/seleniumbase/__version__.py index daa131f04b6..bf9cf5e5b2a 100755 --- a/seleniumbase/__version__.py +++ b/seleniumbase/__version__.py @@ -1,2 +1,2 @@ # seleniumbase package -__version__ = "4.47.9" +__version__ = "4.48.0" diff --git a/seleniumbase/core/nest_asyncio.py b/seleniumbase/core/nest_asyncio.py new file mode 100644 index 00000000000..ce248c236e0 --- /dev/null +++ b/seleniumbase/core/nest_asyncio.py @@ -0,0 +1,282 @@ +"""Patch asyncio to allow nested event loops.""" +import asyncio +import asyncio.events as events +import os +import sys +import threading +from contextlib import contextmanager, suppress +from heapq import heappop + +_run_close_loop = True + + +class PatchedNestAsyncio: + pass + + +def apply(loop=None, *, run_close_loop=False, error_on_mispatched=False): + global _run_close_loop + _patch_asyncio(error_on_mispatched=error_on_mispatched) + _patch_policy() + _patch_tornado() + loop = loop or _get_event_loop() + if loop is not None: + _patch_loop(loop) + _run_close_loop &= run_close_loop + + +if sys.version_info < (3, 14, 0): + def _get_event_loop(): + return asyncio.get_event_loop() +else: + def _get_event_loop(): + try: + return asyncio.get_event_loop() + except RuntimeError: + return None + + +if sys.version_info < (3, 12, 0): + def run(main, *, debug=False): + loop = asyncio.get_event_loop() + loop.set_debug(debug) + task = asyncio.ensure_future(main) + try: + return loop.run_until_complete(task) + finally: + if not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + loop.run_until_complete(task) +else: + def run(main, *, debug=False, loop_factory=None): + new_event_loop = False + set_event_loop = None + try: + loop = asyncio.get_running_loop() + except RuntimeError: + if not _run_close_loop: + loop = _get_event_loop() + if loop is None: + if loop_factory is None: + loop_factory = asyncio.new_event_loop + loop = loop_factory() + asyncio.set_event_loop(loop) + else: + if loop_factory is None: + loop = asyncio.new_event_loop() + set_event_loop = _get_event_loop() + asyncio.set_event_loop(loop) + else: + loop = loop_factory() + new_event_loop = True + _patch_loop(loop) + + loop.set_debug(debug) + task = asyncio.ensure_future(main, loop=loop) + try: + return loop.run_until_complete(task) + finally: + if not task.done(): + task.cancel() + with suppress(asyncio.CancelledError): + loop.run_until_complete(task) + if set_event_loop: + asyncio.set_event_loop(set_event_loop) + if new_event_loop: + # Avoid ResourceWarning: unclosed event loop + loop.close() + + +def _patch_asyncio(*, error_on_mispatched=False): + """Patch asyncio module to use pure Python tasks and futures.""" + + def _get_event_loop(stacklevel=3): + loop = events._get_running_loop() + if loop is None: + if sys.version_info < (3, 14, 0): + policy = events.get_event_loop_policy() + else: + policy = events._get_event_loop_policy() + loop = policy.get_event_loop() + return loop + + if hasattr(asyncio, "_nest_patched"): + if not hasattr(asyncio, "_nest_asyncio"): + if error_on_mispatched: + raise RuntimeError("asyncio was already patched!") + elif sys.version_info >= (3, 12, 0): + import warnings + warnings.warn("asyncio was already patched!") + return + + asyncio.tasks.Task = asyncio.tasks._PyTask + asyncio.Task = asyncio.tasks._CTask = asyncio.tasks.Task + asyncio.Future = asyncio.futures._CFuture = asyncio.futures.Future = ( + asyncio.futures._PyFuture + ) + asyncio.get_event_loop = _get_event_loop + events._get_event_loop = events.get_event_loop = asyncio.get_event_loop + asyncio.run = run + asyncio._nest_patched = True + asyncio._nest_asyncio = PatchedNestAsyncio() + + +def _patch_policy(): + """Patch the policy to always return a patched loop.""" + + def get_event_loop(self): + if self._local._loop is None: + loop = self.new_event_loop() + _patch_loop(loop) + self.set_event_loop(loop) + return self._local._loop + + if sys.version_info < (3, 14, 0): + policy = events.get_event_loop_policy() + else: + policy = events._get_event_loop_policy() + policy.__class__.get_event_loop = get_event_loop + + +def _patch_loop(loop): + """Patch loop to make it reentrant.""" + + def run_forever(self): + with manage_run(self), manage_asyncgens(self): + while True: + self._run_once() + if self._stopping: + break + self._stopping = False + + def run_until_complete(self, future): + with manage_run(self): + f = asyncio.ensure_future(future, loop=self) + if f is not future: + f._log_destroy_pending = False + while not f.done(): + self._run_once() + if self._stopping: + break + if not f.done(): + raise RuntimeError("Loop stopped before Future completed!") + return f.result() + + def _run_once(self): + """Simplified re-implementation of asyncio's _run_once.""" + ready = self._ready + scheduled = self._scheduled + while scheduled and scheduled[0]._cancelled: + heappop(scheduled) + timeout = ( + 0 + if ready or self._stopping + else min(max(scheduled[0]._when - self.time(), 0), 86400) + if scheduled + else None + ) + event_list = self._selector.select(timeout) + self._process_events(event_list) + end_time = self.time() + self._clock_resolution + while scheduled and scheduled[0]._when < end_time: + handle = heappop(scheduled) + ready.append(handle) + for _ in range(len(ready)): + if not ready: + break + handle = ready.popleft() + if not handle._cancelled: + if sys.version_info < (3, 14, 0): + curr_task = curr_tasks.pop(self, None) + else: + try: + curr_task = asyncio.tasks._swap_current_task( + self, None + ) + except KeyError: + curr_task = None + try: + handle._run() + finally: + if curr_task is not None: + if sys.version_info < (3, 14, 0): + curr_tasks[self] = curr_task + else: + asyncio.tasks._swap_current_task(self, curr_task) + handle = None + + @contextmanager + def manage_run(self): + self._check_closed() + old_thread_id = self._thread_id + old_running_loop = events._get_running_loop() + try: + self._thread_id = threading.get_ident() + events._set_running_loop(self) + self._num_runs_pending += 1 + if self._is_proactorloop: + if self._self_reading_future is None: + self.call_soon(self._loop_self_reading) + yield + finally: + self._thread_id = old_thread_id + events._set_running_loop(old_running_loop) + self._num_runs_pending -= 1 + if self._is_proactorloop: + if ( + self._num_runs_pending == 0 + and self._self_reading_future is not None + ): + ov = self._self_reading_future._ov + self._self_reading_future.cancel() + if ov is not None: + self._proactor._unregister(ov) + self._self_reading_future = None + + @contextmanager + def manage_asyncgens(self): + old_agen_hooks = sys.get_asyncgen_hooks() + try: + self._set_coroutine_origin_tracking(self._debug) + if self._asyncgens is not None: + sys.set_asyncgen_hooks( + firstiter=self._asyncgen_firstiter_hook, + finalizer=self._asyncgen_finalizer_hook, + ) + yield + finally: + self._set_coroutine_origin_tracking(False) + if self._asyncgens is not None: + sys.set_asyncgen_hooks(*old_agen_hooks) + + def _check_running(self): + """Do not throw exception if loop is already running.""" + pass + + if hasattr(loop, "_nest_patched"): + return + if not isinstance(loop, asyncio.BaseEventLoop): + raise ValueError("Can't patch loop of type %s" % type(loop)) + cls = loop.__class__ + cls.run_forever = run_forever + cls.run_until_complete = run_until_complete + cls._run_once = _run_once + cls._check_running = _check_running + cls._num_runs_pending = 1 if loop.is_running() else 0 + cls._is_proactorloop = os.name == "nt" and issubclass( + cls, asyncio.ProactorEventLoop + ) + curr_tasks = asyncio.tasks._current_tasks + cls._nest_patched = True + cls._nest_asyncio = PatchedNestAsyncio() + + +def _patch_tornado(): + """If tornado is imported before nest_asyncio, + make tornado aware of the pure-Python asyncio Future.""" + if "tornado" in sys.modules: + import tornado.concurrent as tc # type: ignore + tc.Future = asyncio.Future + if asyncio.Future not in tc.FUTURES: + tc.FUTURES += (asyncio.Future,) diff --git a/seleniumbase/core/sb_cdp.py b/seleniumbase/core/sb_cdp.py index 18798c1d97d..60f3079c4ac 100644 --- a/seleniumbase/core/sb_cdp.py +++ b/seleniumbase/core/sb_cdp.py @@ -193,7 +193,7 @@ def get_rd_url(self): Also sets an environment variable to hide this warning: Deprecation: "url.parse() behavior is not standardized". (github.com/microsoft/playwright-python/issues/3016)""" - import nest_asyncio + from seleniumbase.core import nest_asyncio nest_asyncio.apply() os.environ["NODE_NO_WARNINGS"] = "1" driver = self.driver @@ -400,7 +400,7 @@ def select_all(self, selector, timeout=None): self.__add_light_pause() selector = self.__convert_to_css_if_xpath(selector) if not self.is_element_present(selector): - self.sleep(1) + time.sleep(1) timeout = timeout - 1 if timeout < 1: timeout = 1 @@ -2061,7 +2061,7 @@ def _on_an_incapsula_hcaptcha_page(self, *args, **kwargs): return False def _on_a_datadome_slider_page(self, *args, **kwargs): - self.loop.run_until_complete(self.page.wait(0.1)) + self.loop.run_until_complete(self.page.wait(0.05)) if ( self.is_element_visible( 'body > iframe[src*="/geo.captcha-delivery.com/captcha/"]' @@ -2070,6 +2070,12 @@ def _on_a_datadome_slider_page(self, *args, **kwargs): return True return False + def _on_a_friendly_captcha_page(self, *args, **kwargs): + self.loop.run_until_complete(self.page.wait(0.05)) + if self.is_element_visible('iframe[data--frc-frame-id]'): + return True + return False + def _on_a_g_recaptcha_page(self, *args, **kwargs): time.sleep(0.4) # reCAPTCHA may need a moment to appear self.loop.run_until_complete(self.page.wait(0.1)) @@ -2131,7 +2137,7 @@ def __gui_click_recaptcha(self, use_cdp=False): sb_config._saved_cf_x_y = (x, y) time.sleep(0.08) if use_cdp: - self.sleep(0.03) + time.sleep(0.03) gui_lock = FileLock(constants.MultiBrowser.PYAUTOGUILOCK) with gui_lock: # Prevent issues with multiple processes self.bring_active_window_to_front() @@ -2163,7 +2169,7 @@ def __gui_slide_datadome_captcha(self): time.sleep(0.15) return True - def __cdp_click_incapsula_hcaptcha(self): + def __cdp_click_incapsula_hcaptcha(self, use_cdp=True): selector = "iframe[data-hcaptcha-widget-id]" if self.is_element_visible('iframe[src*="_Incapsula_Resource?"]'): outer_selector = 'iframe[src*="_Incapsula_Resource?"]' @@ -2212,6 +2218,56 @@ def __cdp_click_incapsula_hcaptcha(self): print(" hCaptcha was NOT clicked!") return False + def __gui_click_friendly_captcha(self, use_cdp=False): + selector = 'iframe[data--frc-frame-id]' + if self.is_element_visible('iframe[data--frc-frame-id]'): + element = self.select('iframe[data--frc-frame-id]') + else: + return False + time.sleep(0.05) + self.loop.run_until_complete(self.page.wait(0.1)) + time.sleep(0.05) + with suppress(Exception): + element_rect = self.get_element_rect(selector, timeout=0.1) + e_x = element_rect["x"] + e_y = element_rect["y"] + window_rect = self.get_window_rect() + win_width = window_rect["innerWidth"] + win_height = window_rect["innerHeight"] + if ( + e_x > 1040 + and e_y > 640 + and abs(win_width - e_x) < 110 + and abs(win_height - e_y) < 110 + ): + return False + gui_element_rect = self.get_gui_element_rect(selector, timeout=1) + gui_e_x = gui_element_rect["x"] + gui_e_y = gui_element_rect["y"] + x_offset = 27 + y_offset = 34 + x = gui_e_x + x_offset + y = gui_e_y + y_offset + sb_config._saved_cf_x_y = (x, y) + time.sleep(0.08) + if use_cdp: + time.sleep(0.03) + gui_lock = FileLock(constants.MultiBrowser.PYAUTOGUILOCK) + with gui_lock: # Prevent issues with multiple processes + self.bring_active_window_to_front() + time.sleep(0.06) + element.mouse_move() + time.sleep(0.08) + self.click_with_offset(selector, x_offset, y_offset) + time.sleep(0.08) + element.mouse_move() + time.sleep(0.25) + return True + else: + self.gui_click_x_y(x, y) + return True + return False + def solve_captcha(self): self.__click_captcha(use_cdp=True) @@ -2225,9 +2281,9 @@ def gui_click_captcha(self): def __click_captcha(self, use_cdp=False): """Uses PyAutoGUI unless use_cdp == True""" - self.sleep(0.075) + time.sleep(0.075) self.loop.run_until_complete(self.page.wait(0.1)) - self.sleep(0.025) + time.sleep(0.025) source = self.get_page_source() if self._on_a_cf_turnstile_page(source): pass @@ -2240,6 +2296,9 @@ def __click_captcha(self, use_cdp=False): elif self._on_a_datadome_slider_page(): result = self.__gui_slide_datadome_captcha() return result + elif self._on_a_friendly_captcha_page(): + result = self.__gui_click_friendly_captcha(use_cdp) + return result else: return False selector = None @@ -2395,7 +2454,7 @@ def __click_captcha(self, use_cdp=False): if hasattr(sb_config, "_cdp_proxy") and sb_config._cdp_proxy: time.sleep(0.22) # CAPTCHA may load slower with proxy if use_cdp: - self.sleep(0.03) + time.sleep(0.03) gui_lock = FileLock(constants.MultiBrowser.PYAUTOGUILOCK) with gui_lock: # Prevent issues with multiple processes self.bring_active_window_to_front() @@ -2595,9 +2654,9 @@ def hover_element(self, selector, timeframe=0.25): gui_lock = FileLock(constants.MultiBrowser.PYAUTOGUILOCK) with gui_lock: self.bring_active_window_to_front() - self.sleep(0.02) + time.sleep(0.02) element.mouse_move() - self.sleep(timeframe) + time.sleep(timeframe) def hover_and_click(self, hover_selector, click_selector): if getattr(sb_config, "_cdp_mobile_mode", None): @@ -2607,9 +2666,9 @@ def hover_and_click(self, hover_selector, click_selector): gui_lock = FileLock(constants.MultiBrowser.PYAUTOGUILOCK) with gui_lock: self.bring_active_window_to_front() - self.sleep(0.02) + time.sleep(0.02) hover_element.mouse_move() - self.sleep(0.25) + time.sleep(0.25) try: self.click(click_selector, timeout=0.5) except Exception: diff --git a/seleniumbase/undetected/cdp_driver/tab.py b/seleniumbase/undetected/cdp_driver/tab.py index bd60df7656c..c46eb8d7a04 100644 --- a/seleniumbase/undetected/cdp_driver/tab.py +++ b/seleniumbase/undetected/cdp_driver/tab.py @@ -1406,6 +1406,11 @@ async def __on_an_incapsula_hcaptcha_page(self, *args, **kwargs): return True return False + async def __on_a_friendly_captcha_page(self, *args, **kwargs): + if await self.is_element_visible('iframe[data--frc-frame-id]'): + return True + return False + async def __on_a_g_recaptcha_page(self, *args, **kwargs): await self.sleep(0.4) # reCAPTCHA may need a moment to appear source = await self.get_html() @@ -1514,6 +1519,46 @@ async def __cdp_click_incapsula_hcaptcha(self): print(" hCaptcha was NOT clicked!") return False + async def __gui_click_friendly_captcha(self): + selector = 'iframe[data--frc-frame-id]' + if await self.is_element_visible('iframe[data--frc-frame-id]'): + element = await self.find_element_by_text(selector) + else: + return False + await self.sleep(0.55) + x_offset = 27 + y_offset = 34 + was_clicked = False + gui_lock = AsyncFileLock(constants.MultiBrowser.PYAUTOGUILOCK) + async with gui_lock: + await self.bring_to_front() + await self.sleep(0.056) + if "--debug" in sys.argv: + displayed_selector = "`%s`" % selector + if '"' not in selector: + displayed_selector = '"%s"' % selector + elif "'" not in selector: + displayed_selector = "'%s'" % selector + print( + " click_with_offset(%s, %s, %s)" + % (displayed_selector, x_offset, y_offset) + ) + with suppress(Exception): + await element.mouse_click_with_offset_async( + x=x_offset, y=y_offset, center=False + ) + was_clicked = True + await self.sleep(0.075) + if was_clicked: + # Wait a moment for the click to succeed + await self.sleep(0.75) + if "--debug" in sys.argv: + print(" Friendly Captcha was clicked!") + return True + if "--debug" in sys.argv: + print(" Friendly Captcha was NOT clicked!") + return False + async def get_element_rect(self, selector, timeout=5): element = await self.select(selector, timeout=timeout) coordinates = None @@ -1649,6 +1694,9 @@ async def solve_captcha(self): elif await self.__on_an_incapsula_hcaptcha_page(): result = await self.__cdp_click_incapsula_hcaptcha() return result + elif await self.__on_a_friendly_captcha_page(): + result = await self.__gui_click_friendly_captcha() + return result else: return False selector = None diff --git a/setup.py b/setup.py index bd2fddb0e19..50b5cebfd5d 100755 --- a/setup.py +++ b/setup.py @@ -162,7 +162,7 @@ 'mycdp>=1.3.7', 'pynose>=1.5.5', 'platformdirs~=4.4.0;python_version<"3.10"', - 'platformdirs>=4.9.4;python_version>="3.10"', + 'platformdirs>=4.9.6;python_version>="3.10"', 'typing-extensions>=4.15.0', 'sbvirtualdisplay>=1.4.0', 'MarkupSafe>=3.0.3', @@ -177,7 +177,7 @@ 'tabcompleter>=1.4.0', 'pdbp>=1.8.2', 'idna>=3.11', - 'charset-normalizer>=3.4.6,<4', + 'charset-normalizer>=3.4.7,<4', 'urllib3>=1.26.20,<2;python_version<"3.10"', 'urllib3>=1.26.20,<3;python_version>="3.10"', 'requests~=2.32.5;python_version<"3.10"', @@ -192,10 +192,9 @@ 'wsproto~=1.3.2;python_version>="3.10"', 'websocket-client~=1.9.0', 'selenium==4.32.0;python_version<"3.10"', - 'selenium==4.41.0;python_version>="3.10"', + 'selenium==4.43.0;python_version>="3.10"', 'cssselect==1.3.0;python_version<"3.10"', 'cssselect>=1.4.0,<2;python_version>="3.10"', - 'nest-asyncio==1.6.0', 'sortedcontainers==2.4.0', 'execnet==2.1.1;python_version<"3.10"', 'execnet==2.1.2;python_version>="3.10"', @@ -203,7 +202,7 @@ 'iniconfig==2.3.0;python_version>="3.10"', 'pluggy==1.6.0', 'pytest==8.4.2;python_version<"3.11"', - 'pytest==9.0.2;python_version>="3.11"', + 'pytest==9.0.3;python_version>="3.11"', 'pytest-html==4.0.2', # Newer ones had issues 'pytest-metadata==3.1.1', 'pytest-ordering==0.6', @@ -262,7 +261,7 @@ "pdfminer": [ 'pdfminer.six==20251107;python_version<"3.10"', 'pdfminer.six==20260107;python_version>="3.10"', - 'cryptography==46.0.6', + 'cryptography==46.0.7', 'cffi==2.0.0', 'pycparser==2.23;python_version<"3.10"', 'pycparser==3.0;python_version>="3.10"', @@ -271,7 +270,7 @@ # (An optional library for image-processing.) "pillow": [ 'Pillow>=11.3.0;python_version<"3.10"', - 'Pillow>=12.1.1;python_version>="3.10"', + 'Pillow>=12.2.0;python_version>="3.10"', ], # pip install -e .[pip-system-certs] # (If you see [SSL: CERTIFICATE_VERIFY_FAILED], then get this.)