From f9d73d0cb8f1818a2d1be2b9566fd9bdde1bd83d Mon Sep 17 00:00:00 2001 From: CloakHQ Date: Wed, 5 Aug 2026 16:41:16 +0200 Subject: [PATCH] fix(license): guard wrapper no longer breaks humanize on Python 3.14 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The license guard stored each wrapped method as a functools.partial, relying on it not being a descriptor. Python 3.14 made partial a method descriptor, so when humanize reads the wrapped methods off a holder instance the partial re-binds and injects a spurious positional arg — every humanized page call raised TypeError on first use. Replace the partial with a non-descriptor callable class. Fixes #488. --- cloakbrowser/browser.py | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/cloakbrowser/browser.py b/cloakbrowser/browser.py index 4886a3b..ce5a16b 100644 --- a/cloakbrowser/browser.py +++ b/cloakbrowser/browser.py @@ -14,7 +14,6 @@ Usage: from __future__ import annotations -import functools import inspect import logging import os @@ -72,6 +71,28 @@ _GUARD_SKIP_METHODS = frozenset({ }) +class _Guarded: + """A non-descriptor callable that wraps the guard closure. + + The guard is stored back on the target as a plain attribute, and humanize + copies it into a ``type("Originals", ...)()`` holder and reads it off that + *instance*. Anything with ``__get__`` (a bare function, or ``functools.partial`` + on Python 3.14+, where partial became a method descriptor) would re-bind to the + holder and inject a spurious first positional arg. This class has no ``__get__``, + so it stays inert wherever it is stored — on any Python version. Works for both + the sync guard and the async guard: for the async case ``__call__`` returns the + coroutine the closure produces, which the caller awaits. (issue #488) + """ + + __slots__ = ("_fn",) + + def __init__(self, fn: Any) -> None: + self._fn = fn + + def __call__(self, *args: Any, **kwargs: Any) -> Any: + return self._fn(*args, **kwargs) + + def _denial_license_error(denial_path: str) -> CloakBrowserLicenseError | None: """Read the denial file and map it to a license error, or None.""" code = read_denial_file(denial_path) @@ -139,12 +160,13 @@ def _install_license_guard(target: Any, denial_path: str) -> None: return result return guarded - # Wrap in a partial so the guard is NOT a descriptor. Playwright's real - # methods are bound methods; humanize copies them into a class attribute - # (type("Originals", ...)) and reads them back off an instance. A plain - # function there would re-bind and inject a spurious ``self`` arg — a - # partial (like a bound method) does not. Keeps composition with humanize. - setattr(target, name, functools.partial(make(original, deep))) + # Wrap so the guard is NOT a descriptor. Playwright's real methods are + # bound methods; humanize copies them into a class attribute + # (type("Originals", ...)) and reads them back off an instance. A bare + # function — or a functools.partial on Python 3.14+, where partial became + # a method descriptor — would re-bind there and inject a spurious first + # positional arg. _Guarded has no __get__, so it stays inert. (issue #488) + setattr(target, name, _Guarded(make(original, deep))) def _install_license_guard_async(target: Any, denial_path: str) -> None: @@ -177,8 +199,8 @@ def _install_license_guard_async(target: Any, denial_path: str) -> None: return result return guarded - # Wrap in a partial so the guard is not a descriptor (see sync variant). - setattr(target, name, functools.partial(make(original, deep))) + # Wrap so the guard is not a descriptor (see sync variant, issue #488). + setattr(target, name, _Guarded(make(original, deep))) # Sentinel to distinguish "viewport not provided" from "viewport=None" (disable emulation)