fix(license): guard wrapper no longer breaks humanize on Python 3.14

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.
This commit is contained in:
CloakHQ committed 2026-08-05 16:41:16 +02:00
1 parent e4d4c680c9
commit f9d73d0cb8
1 file changed
+31 -9
+31 -9
View File
@@ -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)