Skip to content

Commit ef7f02c

Browse files
authored
feat: try colab credentials in get_user_credentials if client_id not specified (#76)
* feat: try colab credentials in `get_user_credentials` if `client_id` not specified * fix AttributeError: 'tuple' object has no attribute 'valid' * Update pydata_google_auth/auth.py * Update pydata_google_auth/auth.py * add unit tests * add unit tests, docs, and always try colab * update readthedocs config * fix indentation * try again * fix requirements
1 parent 64dc0ea commit ef7f02c

File tree

6 files changed

+144
-40
lines changed

6 files changed

+144
-40
lines changed

.readthedocs.yml

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1-
requirements_file: docs/requirements-docs.txt
1+
version: 2
2+
23
build:
3-
image: latest
4+
os: ubuntu-24.04
5+
tools:
6+
python: "3.10"
7+
48
python:
5-
pip_install: true
6-
version: 3.6
9+
install:
10+
- requirements: docs/requirements-docs.txt
11+
# Install our python package before building the docs
12+
- method: pip
13+
path: .
14+
15+
sphinx:
16+
configuration: docs/source/conf.py

docs/requirements-docs.txt

+12-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
ipython
2-
matplotlib
3-
numpydoc
4-
sphinx==4.0.2
5-
sphinx_rtd_theme
1+
# We need to pin to specific versions of the `sphinxcontrib-*` packages
2+
# which still support sphinx 4.x.
3+
# See https://github.com/googleapis/sphinx-docfx-yaml/issues/344
4+
# and https://github.com/googleapis/sphinx-docfx-yaml/issues/345.
5+
sphinxcontrib-applehelp==1.0.4
6+
sphinxcontrib-devhelp==1.0.2
7+
sphinxcontrib-htmlhelp==2.0.1
8+
sphinxcontrib-qthelp==1.0.3
9+
sphinxcontrib-serializinghtml==1.1.5
10+
sphinx==4.5.0
11+
alabaster
12+
recommonmark

docs/source/conf.py

+1-14
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,6 @@
3737
"sphinx.ext.extlinks",
3838
"sphinx.ext.todo",
3939
"sphinx.ext.napoleon",
40-
"IPython.sphinxext.ipython_console_highlighting",
41-
"IPython.sphinxext.ipython_directive",
4240
"sphinx.ext.intersphinx",
4341
"sphinx.ext.coverage",
4442
"sphinx.ext.ifconfig",
@@ -129,20 +127,9 @@
129127

130128
# -- Options for HTML output ----------------------------------------------
131129

132-
# Taken from docs.readthedocs.io:
133-
# on_rtd is whether we are on readthedocs.io
134-
on_rtd = os.environ.get("READTHEDOCS", None) == "True"
135-
136-
if not on_rtd: # only import and set the theme if we're building docs locally
137-
import sphinx_rtd_theme
138-
139-
html_theme = "sphinx_rtd_theme"
140-
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
141-
142130
# The theme to use for HTML and HTML Help pages. See the documentation for
143131
# a list of builtin themes.
144-
#
145-
# html_theme = 'alabaster'
132+
html_theme = "alabaster"
146133

147134
# Theme options are theme-specific and customize the look and feel of a theme
148135
# further. For a list of options available for each theme, see the

noxfile.py

+17-2
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
BLACK_VERSION = "black==22.12.0"
2828
BLACK_PATHS = ["docs", "pydata_google_auth", "tests", "noxfile.py", "setup.py"]
2929

30-
DEFAULT_PYTHON_VERSION = "3.8"
30+
SPHINX_VERSION = "sphinx==4.5.0"
31+
32+
DEFAULT_PYTHON_VERSION = "3.10"
3133
SYSTEM_TEST_PYTHON_VERSIONS = ["3.9", "3.13"]
3234
UNIT_TEST_PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12", "3.13"]
3335

@@ -191,8 +193,21 @@ def cover(session):
191193
def docs(session):
192194
"""Build the docs for this library."""
193195

194-
session.install("sphinx==4.0.2", "sphinx_rtd_theme", "ipython")
195196
session.install("-e", ".")
197+
session.install(
198+
# We need to pin to specific versions of the `sphinxcontrib-*` packages
199+
# which still support sphinx 4.x.
200+
# See https://github.com/googleapis/sphinx-docfx-yaml/issues/344
201+
# and https://github.com/googleapis/sphinx-docfx-yaml/issues/345.
202+
"sphinxcontrib-applehelp==1.0.4",
203+
"sphinxcontrib-devhelp==1.0.2",
204+
"sphinxcontrib-htmlhelp==2.0.1",
205+
"sphinxcontrib-qthelp==1.0.3",
206+
"sphinxcontrib-serializinghtml==1.1.5",
207+
SPHINX_VERSION,
208+
"alabaster",
209+
"recommonmark",
210+
)
196211

197212
shutil.rmtree(os.path.join("docs", "source", "_build"), ignore_errors=True)
198213
session.run(

pydata_google_auth/auth.py

+48-11
Original file line numberDiff line numberDiff line change
@@ -163,26 +163,48 @@ def default(
163163
return credentials, None
164164

165165

166-
def _ensure_application_default_credentials_in_colab_environment():
167-
# This is a special handling for google colab environment where we want to
168-
# use the colab specific authentication flow
169-
# https://github.com/googlecolab/colabtools/blob/3c8772efd332289e1c6d1204826b0915d22b5b95/google/colab/auth.py#L209
166+
def try_colab_auth_import():
170167
try:
171168
from google.colab import auth
172169

173-
auth.authenticate_user()
170+
return auth
174171
except Exception:
175172
# We are catching a broad exception class here because we want to be
176173
# agnostic to anything that could internally go wrong in the google
177174
# colab auth. Some of the known exception we want to pass on are:
178175
#
179176
# ModuleNotFoundError: No module named 'google.colab'
180177
# ImportError: cannot import name 'auth' from 'google.cloud'
178+
return None
179+
180+
181+
def get_colab_default_credentials(scopes):
182+
"""This is a special handling for google colab environment where we want to
183+
use the colab specific authentication flow.
184+
185+
See:
186+
https://github.com/googlecolab/colabtools/blob/3c8772efd332289e1c6d1204826b0915d22b5b95/google/colab/auth.py#L209
187+
"""
188+
auth = try_colab_auth_import()
189+
if auth is None:
190+
return None, None
191+
192+
try:
193+
auth.authenticate_user()
194+
195+
# authenticate_user() sets the default credentials, but we
196+
# still need to get the token from those default credentials.
197+
return get_application_default_credentials(scopes=scopes)
198+
except Exception:
199+
# We are catching a broad exception class here because we want to be
200+
# agnostic to anything that could internally go wrong in the google
201+
# colab auth. Some of the known exception we want to pass on are:
202+
#
181203
# MessageError: Error: credential propagation was unsuccessful
182204
#
183205
# The MessageError happens on Vertex Colab when it fails to resolve auth
184206
# from the Compute Engine Metadata server.
185-
pass
207+
return None, None
186208

187209

188210
def get_application_default_credentials(scopes):
@@ -205,9 +227,6 @@ def get_application_default_credentials(scopes):
205227
from the environment. Or, the retrieved credentials do not
206228
have access to the project (project_id) on BigQuery.
207229
"""
208-
209-
_ensure_application_default_credentials_in_colab_environment()
210-
211230
try:
212231
credentials, project = google.auth.default(scopes=scopes)
213232
except (google.auth.exceptions.DefaultCredentialsError, IOError) as exc:
@@ -240,8 +259,12 @@ def get_user_credentials(
240259
"""
241260
Gets user account credentials.
242261
243-
This function authenticates using user credentials, either loading saved
244-
credentials from the cache or by going through the OAuth 2.0 flow.
262+
This function authenticates using user credentials, by trying to
263+
264+
1. Authenticate using ``google.colab.authenticate_user()``
265+
2. Load saved credentials from the ``credentials_cache``
266+
3. Go through the OAuth 2.0 flow (with provided ``client_id`` and
267+
``client_secret``)
245268
246269
The default read-write cache attempts to read credentials from a file on
247270
disk. If these credentials are not found or are invalid, it begins an
@@ -311,6 +334,20 @@ def get_user_credentials(
311334
pydata_google_auth.exceptions.PyDataCredentialsError
312335
If unable to get valid user credentials.
313336
"""
337+
338+
# Try to authenticate the user with Colab-based credentials, if possible.
339+
# The default_project ignored for colab credentials. It's not usually set,
340+
# anyway.
341+
credentials, _ = get_colab_default_credentials(scopes)
342+
343+
# Break early to avoid trying to fetch any other kinds of credentials.
344+
# Prefer Colab credentials over any credentials based on the default
345+
# client ID.
346+
if credentials:
347+
# Make sure to exit early since we don't want to try to save these
348+
# credentials to a cache file.
349+
return credentials
350+
314351
if auth_local_webserver is not None:
315352
use_local_webserver = auth_local_webserver
316353

tests/unit/test_auth.py

+52-4
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,18 @@
1212

1313
from google.oauth2 import service_account
1414
from pydata_google_auth import exceptions
15+
import pydata_google_auth.cache
1516

1617

1718
TEST_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
1819

1920

21+
class FakeCredentials(object):
22+
@property
23+
def valid(self):
24+
return True
25+
26+
2027
@pytest.fixture
2128
def module_under_test():
2229
from pydata_google_auth import auth
@@ -57,10 +64,51 @@ def mock_default_credentials(scopes=None, request=None):
5764
assert credentials is mock_user_credentials
5865

5966

60-
class FakeCredentials(object):
61-
@property
62-
def valid(self):
63-
return True
67+
def test_get_user_credentials_tries_colab_first(monkeypatch, module_under_test):
68+
colab_auth_module = mock.Mock()
69+
try_colab_auth_import = mock.Mock(return_value=colab_auth_module)
70+
monkeypatch.setattr(
71+
module_under_test, "try_colab_auth_import", try_colab_auth_import
72+
)
73+
default_credentials = mock.create_autospec(google.auth.credentials.Credentials)
74+
default_call_times = 0
75+
76+
# Can't use a Mock because we want to check authenticate_user.
77+
def mock_default(scopes=None, request=None):
78+
nonlocal default_call_times
79+
default_call_times += 1
80+
81+
# Make sure colab auth is called first.
82+
colab_auth_module.authenticate_user.assert_called_once_with()
83+
84+
return (
85+
default_credentials,
86+
"colab-project", # In reality, often None.
87+
)
88+
89+
monkeypatch.setattr(google.auth, "default", mock_default)
90+
91+
credentials = module_under_test.get_user_credentials(TEST_SCOPES)
92+
93+
assert credentials is default_credentials
94+
assert default_call_times == 1
95+
96+
97+
def test_get_user_credentials_skips_colab_if_no_colab(monkeypatch, module_under_test):
98+
try_colab_auth_import = mock.Mock(return_value=None)
99+
monkeypatch.setattr(
100+
module_under_test, "try_colab_auth_import", try_colab_auth_import
101+
)
102+
credentials_cache = mock.create_autospec(pydata_google_auth.cache.CredentialsCache)
103+
loaded_credentials = mock.Mock()
104+
credentials_cache.load.return_value = loaded_credentials
105+
106+
credentials = module_under_test.get_user_credentials(
107+
TEST_SCOPES,
108+
credentials_cache=credentials_cache,
109+
)
110+
111+
assert credentials is loaded_credentials
64112

65113

66114
def test_load_service_account_credentials(monkeypatch, tmp_path, module_under_test):

0 commit comments

Comments
 (0)