Skip to content

Commit faccf35

Browse files
Nate SchmitzGoogle Earth Engine Authors
authored andcommitted
Read GCP project from an environment variable.
PiperOrigin-RevId: 707984421
1 parent 565f49b commit faccf35

7 files changed

Lines changed: 154 additions & 28 deletions

File tree

.github/workflows/ci-tests.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
# TODO(user): Fix or skip tests that fail on GitHub
21
name: ci-tests
32
on: [
43
push,

python/ee/__init__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,16 @@
6969
NO_PROJECT_EXCEPTION = ('ee.Initialize: no project found. Call with project='
7070
' or see http://goo.gle/ee-auth.')
7171

72+
# Environment variables used to set the project ID. GOOGLE_CLOUD_PROJECT so that
73+
# we interoperate with other Cloud libraries in the common case. EE_PROJECT_ID
74+
# is a more specific value so it should take precedence if both values are
75+
# present. See the following for more details:
76+
# https://google-auth.readthedocs.io/en/master/reference/google.auth.environment_vars.html#google.auth.environment_vars.PROJECT.
77+
_PROJECT_ENV_VARS = [
78+
'EE_PROJECT_ID',
79+
'GOOGLE_CLOUD_PROJECT',
80+
]
81+
7282

7383
class _AlgorithmsContainer(dict):
7484
"""A lightweight class that is used as a dictionary with dot notation."""
@@ -179,12 +189,17 @@ def Initialize(
179189
url: The base url for the EarthEngine REST API to connect to.
180190
cloud_api_key: An optional API key to use the Cloud API.
181191
http_transport: The http transport method to use when making requests.
182-
project: The client project ID or number to use when making API calls.
192+
project: The client project ID or number to use when making API calls. If
193+
None, project is inferred from credentials or environment variables.
183194
"""
184195
if credentials == 'persistent':
185196
credentials = data.get_persistent_credentials()
186197
if not project and credentials and hasattr(credentials, 'quota_project_id'):
187198
project = credentials.quota_project_id
199+
if not project:
200+
for env_var in _PROJECT_ENV_VARS:
201+
if project := _utils.get_environment_variable(env_var):
202+
break
188203
# SDK credentials are not authorized for EE so a project must be given.
189204
if not project and oauth.is_sdk_credentials(credentials):
190205
raise EEException(NO_PROJECT_EXCEPTION)

python/ee/_utils.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
"""General decorators and helper methods which should not import ee."""
22

33
import functools
4+
import os
45
from typing import Any, Callable
56

67

8+
# Optional imports used for specific shells.
9+
# pylint: disable=g-import-not-at-top
10+
try:
11+
import IPython
12+
except ImportError:
13+
pass
14+
15+
716
def accept_opt_prefix(*opt_args) -> Callable[..., Any]:
817
"""Decorator to maintain support for "opt_" prefixed kwargs.
918
@@ -40,3 +49,54 @@ def wrapper(*args, **kwargs):
4049
return wrapper
4150

4251
return opt_fixed
52+
53+
54+
def in_colab_shell() -> bool:
55+
"""Tests if the code is being executed within Google Colab."""
56+
try:
57+
import google.colab # pylint: disable=unused-import,redefined-outer-name
58+
59+
return True
60+
except ImportError:
61+
return False
62+
63+
64+
def in_jupyter_shell() -> bool:
65+
"""Tests if the code is being executed within Jupyter."""
66+
try:
67+
import ipykernel.zmqshell
68+
69+
return isinstance(
70+
IPython.get_ipython(), ipykernel.zmqshell.ZMQInteractiveShell
71+
)
72+
except ImportError:
73+
return False
74+
except NameError:
75+
return False
76+
77+
78+
def get_environment_variable(key: str) -> Any:
79+
"""Retrieves a Colab secret or environment variable for the given key.
80+
81+
Colab secrets have precedence over environment variables.
82+
83+
Args:
84+
key (str): The key that's used to fetch the environment variable.
85+
86+
Returns:
87+
Optional[str]: The retrieved key, or None if no environment variable was
88+
found.
89+
"""
90+
if in_colab_shell():
91+
from google.colab import userdata # pylint: disable=g-import-not-at-top
92+
93+
try:
94+
return userdata.get(key)
95+
except (
96+
userdata.SecretNotFoundError,
97+
userdata.NotebookAccessError,
98+
AttributeError,
99+
):
100+
pass
101+
102+
return os.environ.get(key)

python/ee/cli/commands.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ def run(
409409
if args.scopes:
410410
args_auth['scopes'] = args.scopes.split(',')
411411

412-
if ee.oauth.in_colab_shell():
412+
if ee._utils.in_colab_shell(): # pylint: disable=protected-access
413413
print(
414414
'Authenticate: Limited support in Colab. Use ee.Authenticate()'
415415
' or --auth_mode=notebook instead.'

python/ee/oauth.py

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from google.auth import _cloud_sdk
2828
import google.auth.transport.requests
2929

30+
from ee import _utils
3031
from ee import data as ee_data
3132
from ee import ee_exception
3233

@@ -205,27 +206,6 @@ def write_private_json(json_path: str, info_dict: Dict[str, Any]) -> None:
205206
f.write(file_content)
206207

207208

208-
def in_colab_shell() -> bool:
209-
"""Tests if the code is being executed within Google Colab."""
210-
try:
211-
import google.colab # pylint: disable=unused-import,redefined-outer-name
212-
return True
213-
except ImportError:
214-
return False
215-
216-
217-
def _in_jupyter_shell() -> bool:
218-
"""Tests if the code is being executed within Jupyter."""
219-
try:
220-
import ipykernel.zmqshell
221-
return isinstance(IPython.get_ipython(),
222-
ipykernel.zmqshell.ZMQInteractiveShell)
223-
except ImportError:
224-
return False
225-
except NameError:
226-
return False
227-
228-
229209
def _project_number_from_client_id(client_id: Optional[str]) -> Optional[str]:
230210
"""Returns the project number associated with the given OAuth client ID."""
231211
# Client IDs are of the form:
@@ -507,9 +487,9 @@ def authenticate(
507487
return True
508488

509489
if not auth_mode:
510-
if in_colab_shell():
490+
if _utils.in_colab_shell():
511491
auth_mode = 'colab'
512-
elif _in_jupyter_shell():
492+
elif _utils.in_jupyter_shell():
513493
auth_mode = 'notebook'
514494
elif _localhost_is_viable() and _no_gcloud():
515495
auth_mode = 'localhost'
@@ -596,9 +576,9 @@ def display_instructions(self, quiet: Optional[bool] = None) -> bool:
596576
return True
597577

598578
coda = WAITING_CODA if self.server else None
599-
if in_colab_shell():
579+
if _utils.in_colab_shell():
600580
_display_auth_instructions_with_print(self.auth_url, coda)
601-
elif _in_jupyter_shell():
581+
elif _utils.in_jupyter_shell():
602582
_display_auth_instructions_with_html(self.auth_url, coda)
603583
else:
604584
_display_auth_instructions_with_print(self.auth_url, coda)

python/ee/tests/_utils_test.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
#!/usr/bin/env python3
22
"""Tests for _utils decorators."""
33

4+
import sys
5+
from unittest import mock
6+
47
import unittest
58
from ee import _utils
69

@@ -84,6 +87,13 @@ def test_function(arg1=None, arg2_=None):
8487
# pylint: enable=unexpected-keyword-arg
8588
# pytype: enable=wrong-keyword-args
8689

90+
def test_in_colab_shell(self):
91+
with mock.patch.dict(sys.modules, {'google.colab': None}):
92+
self.assertFalse(_utils.in_colab_shell())
93+
94+
with mock.patch.dict(sys.modules, {'google.colab': mock.MagicMock()}):
95+
self.assertTrue(_utils.in_colab_shell())
96+
8797

8898
if __name__ == '__main__':
8999
unittest.main()

python/ee/tests/ee_test.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#!/usr/bin/env python3
22
"""Test for the ee.__init__ file."""
33

4+
import os
45
from unittest import mock
56

67
import google.auth
@@ -53,6 +54,67 @@ def MockAlgorithms():
5354
self.assertEqual(ee.ApiFunction._api, {})
5455
self.assertFalse(ee.Image._initialized)
5556

57+
def testProjectInitialization(self):
58+
"""Verifies that we can fetch the client project from many locations.
59+
60+
This also exercises the logic in data.get_persistent_credentials.
61+
"""
62+
63+
cred_args = dict(refresh_token='rt', quota_project_id='qp1')
64+
google_creds = credentials.Credentials(token=None, quota_project_id='qp2')
65+
expected_project = None
66+
67+
def CheckDataInit(**kwargs):
68+
self.assertEqual(expected_project, kwargs.get('project'))
69+
70+
moc = mock.patch.object
71+
with (moc(ee.oauth, 'get_credentials_arguments', new=lambda: cred_args),
72+
moc(ee.oauth, 'is_valid_credentials', new=lambda _: True),
73+
moc(google.auth, 'default', new=lambda: (google_creds, None)),
74+
moc(ee.data, 'initialize', side_effect=CheckDataInit) as inits):
75+
expected_project = 'qp0'
76+
ee.Initialize(project='qp0')
77+
78+
expected_project = 'qp1'
79+
ee.Initialize()
80+
81+
cred_args['refresh_token'] = None
82+
ee.Initialize()
83+
84+
cred_args['quota_project_id'] = None
85+
expected_project = 'qp2'
86+
ee.Initialize()
87+
88+
google_creds = google_creds.with_quota_project(None)
89+
expected_project = None
90+
ee.Initialize()
91+
92+
expected_project = 'qp3'
93+
with mock.patch.dict(
94+
os.environ,
95+
{'EE_PROJECT_ID': expected_project, 'GOOGLE_CLOUD_PROJECT': 'qp4'},
96+
):
97+
ee.Initialize()
98+
99+
expected_project = 'qp4'
100+
with mock.patch.dict(
101+
os.environ, {'GOOGLE_CLOUD_PROJECT': expected_project}
102+
):
103+
ee.Initialize()
104+
self.assertEqual(7, inits.call_count)
105+
106+
expected_project = None
107+
msg = 'Earth Engine API has not been used in project 764086051850 before'
108+
with moc(ee.ApiFunction, 'initialize', side_effect=ee.EEException(msg)):
109+
with self.assertRaisesRegex(ee.EEException, '.*no project found..*'):
110+
ee.Initialize()
111+
112+
cred_args['client_id'] = '764086051850-xxx' # dummy usable-auth client
113+
cred_args['refresh_token'] = 'rt'
114+
with self.assertRaisesRegex(ee.EEException, '.*no project found..*'):
115+
ee.Initialize()
116+
self.assertEqual(8, inits.call_count)
117+
56118
def testCallAndApply(self):
57119
"""Verifies library initialization."""
58120

0 commit comments

Comments
 (0)