Skip to content

Commit bb4a028

Browse files
committed
fix(auth): resolve LazyBasesMeta subclassing, thread safety, and recursion bugs
1 parent 1681884 commit bb4a028

5 files changed

Lines changed: 208 additions & 3 deletions

File tree

packages/google-auth/google/auth/_helpers.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import json
2323
import logging
2424
import sys
25+
import threading
2526
from typing import Any, Dict, Mapping, Optional, Union
2627
import urllib
2728

@@ -545,15 +546,49 @@ class LazyBasesMeta(type):
545546
class is first instantiated, subclassed, or has its attributes inspected.
546547
"""
547548

549+
_lock = threading.RLock()
550+
551+
def __new__(mcls, name, bases, attrs):
552+
# Automatically resolve lazy bases of any base classes at subclass definition time.
553+
for base in bases:
554+
if isinstance(base, LazyBasesMeta):
555+
type(base)._resolve_bases(base)
556+
557+
cls = super(LazyBasesMeta, mcls).__new__(mcls, name, bases, attrs)
558+
type.__setattr__(cls, "_lazy_bases_resolved", False)
559+
return cls
560+
548561
def __call__(cls, *args, **kwargs):
549562
type(cls)._resolve_bases(cls)
550563
return super(LazyBasesMeta, cls).__call__(*args, **kwargs)
551564

552565
def __getattribute__(cls, name):
553-
type(cls)._resolve_bases(cls)
566+
if name not in (
567+
"_lazy_bases_resolved",
568+
"_resolve_bases",
569+
"_perform_resolve_bases",
570+
):
571+
type(cls)._resolve_bases(cls)
554572
return super(LazyBasesMeta, cls).__getattribute__(name)
555573

556574
def _resolve_bases(cls):
575+
try:
576+
resolved = type.__getattribute__(cls, "_lazy_bases_resolved")
577+
except AttributeError:
578+
resolved = False
579+
580+
if not resolved:
581+
with type(cls)._lock:
582+
try:
583+
resolved = type.__getattribute__(cls, "_lazy_bases_resolved")
584+
except AttributeError:
585+
resolved = False
586+
587+
if not resolved:
588+
type(cls)._perform_resolve_bases(cls)
589+
type.__setattr__(cls, "_lazy_bases_resolved", True)
590+
591+
def _perform_resolve_bases(cls):
557592
pass
558593

559594

packages/google-auth/google/auth/transport/requests.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363

6464

6565
class _LazyBasesMeta(_helpers.LazyBasesMeta):
66-
def _resolve_bases(cls):
66+
def _perform_resolve_bases(cls):
6767
current_bases = type.__getattribute__(cls, "__bases__")
6868
if current_bases == (_helpers.HeapDummy,):
6969
cls_name = type.__getattribute__(cls, "__name__")

packages/google-auth/google/auth/transport/urllib3.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474

7575

7676
class _LazyBasesMeta(_helpers.LazyBasesMeta):
77-
def _resolve_bases(cls):
77+
def _perform_resolve_bases(cls):
7878
current_bases = type.__getattribute__(cls, "__bases__")
7979
if current_bases == (_helpers.HeapDummy,):
8080
if version.parse(urllib3.__version__) >= version.parse("2.0.0"):
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import threading
16+
import time
17+
from google.auth import _helpers
18+
19+
20+
def test_lazy_bases_subclassing():
21+
class RealBase:
22+
x = 42
23+
24+
class MyLazyBasesMeta(_helpers.LazyBasesMeta):
25+
def _perform_resolve_bases(cls):
26+
current_bases = type.__getattribute__(cls, "__bases__")
27+
if current_bases == (_helpers.HeapDummy,):
28+
cls.__bases__ = (RealBase,)
29+
30+
class LazyClass(_helpers.HeapDummy, metaclass=MyLazyBasesMeta):
31+
pass
32+
33+
# Subclass the lazy class
34+
class SubClass(LazyClass):
35+
pass
36+
37+
# The lazy bases of LazyClass should have been resolved when SubClass was defined.
38+
assert LazyClass.__bases__ == (RealBase,)
39+
assert SubClass.__bases__ == (LazyClass,)
40+
assert SubClass.x == 42
41+
assert SubClass().x == 42
42+
43+
44+
def test_lazy_bases_thread_safety():
45+
class RealBase:
46+
x = 42
47+
48+
class MyLazyBasesMeta(_helpers.LazyBasesMeta):
49+
def _perform_resolve_bases(cls):
50+
time.sleep(0.05) # Simulate slow resolution
51+
current_bases = type.__getattribute__(cls, "__bases__")
52+
if current_bases == (_helpers.HeapDummy,):
53+
cls.__bases__ = (RealBase,)
54+
55+
class LazyClass(_helpers.HeapDummy, metaclass=MyLazyBasesMeta):
56+
pass
57+
58+
# Test concurrent instantiation
59+
errors = []
60+
61+
def worker():
62+
try:
63+
obj = LazyClass()
64+
assert obj.x == 42
65+
except Exception as e:
66+
errors.append(e)
67+
68+
threads = [threading.Thread(target=worker) for _ in range(10)]
69+
for t in threads:
70+
t.start()
71+
for t in threads:
72+
t.join()
73+
74+
assert not errors
75+
assert LazyClass.__bases__ == (RealBase,)
76+
77+
78+
def test_lazy_bases_recursion_guard():
79+
class RealBase:
80+
x = 42
81+
82+
class MyLazyBasesMeta(_helpers.LazyBasesMeta):
83+
def _perform_resolve_bases(cls):
84+
current_bases = type.__getattribute__(cls, "__bases__")
85+
if current_bases == (_helpers.HeapDummy,):
86+
cls.__bases__ = (RealBase,)
87+
88+
class LazyClass(_helpers.HeapDummy, metaclass=MyLazyBasesMeta):
89+
pass
90+
91+
# Accessing internal metaclass/resolution attributes should not trigger recursion
92+
# and should be safely accessible.
93+
assert hasattr(LazyClass, "_lazy_bases_resolved")
94+
assert hasattr(LazyClass, "_resolve_bases")
95+
assert hasattr(LazyClass, "_perform_resolve_bases")
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import subprocess
16+
import sys
17+
18+
import pytest
19+
20+
SCRIPT_PYTHON_315 = """
21+
import sys
22+
import google.auth.transport.requests
23+
import google.auth.transport.urllib3
24+
25+
# The heavy underlying modules should NOT be loaded yet
26+
HEAVY_MODULES = ["requests", "urllib3"]
27+
28+
for mod in HEAVY_MODULES:
29+
if mod in sys.modules:
30+
print(f"FAILED: {mod} was eagerly loaded into sys.modules")
31+
sys.exit(1)
32+
33+
# Trigger reification of requests
34+
from google.auth.transport import requests
35+
_ = requests.__name__
36+
37+
if "requests" not in sys.modules:
38+
print("FAILED: requests was not lazily loaded upon access")
39+
sys.exit(2)
40+
41+
sys.exit(0)
42+
"""
43+
44+
SCRIPT_PRE_315 = """
45+
import sys
46+
import google.auth.transport.requests
47+
import google.auth.transport.urllib3
48+
49+
HEAVY_MODULES = ["requests", "urllib3"]
50+
51+
for mod in HEAVY_MODULES:
52+
if mod not in sys.modules:
53+
print(f"FAILED: {mod} was not eagerly loaded into sys.modules")
54+
sys.exit(1)
55+
56+
sys.exit(0)
57+
"""
58+
59+
60+
@pytest.mark.skipif(sys.version_info < (3, 15), reason="PEP 810 requires Python 3.15+")
61+
def test_lazy_imports_on_python_315():
62+
result = subprocess.run(
63+
[sys.executable, "-c", SCRIPT_PYTHON_315], capture_output=True, text=True
64+
)
65+
assert result.returncode == 0, f"Subprocess failed: {result.stdout}"
66+
67+
68+
@pytest.mark.skipif(
69+
sys.version_info >= (3, 15), reason="Testing fallback behavior on < 3.15"
70+
)
71+
def test_fallback_eager_imports_pre_315():
72+
result = subprocess.run(
73+
[sys.executable, "-c", SCRIPT_PRE_315], capture_output=True, text=True
74+
)
75+
assert result.returncode == 0, f"Subprocess failed: {result.stdout}"

0 commit comments

Comments
 (0)