Skip to content

Commit 64e0c45

Browse files
authored
Implementing Python 3 Support (#7)
* Implementing Python3 compatibility; Added Python3 config for Tox; Implemented AppDefault credential support in initialize_app() * Declared six dependency explicitly (also included transitively via oauth2client) * Fixing a buggy test case; Updates to documentation
1 parent 9b679d0 commit 64e0c45

File tree

10 files changed

+149
-54
lines changed

10 files changed

+149
-54
lines changed

.github/CONTRIBUTING.md

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -136,18 +136,33 @@ to learn how to run a subset of all test cases.
136136

137137
### Testing in Different Environments
138138

139-
Sometimes we may want to run unit tests in multiple environments (e.g. different
140-
Python versions), and ensure that the SDK works as expected in each of them.
141-
We use [tox](https://tox.readthedocs.io/en/latest/) for this purpose.
142-
You can execute the following command from the root of the repository to
143-
launch tox:
139+
Sometimes we want to run unit tests in multiple environments (e.g. different Python versions), and
140+
ensure that the SDK works as expected in each of them. We use
141+
[tox](https://tox.readthedocs.io/en/latest/) for this purpose.
142+
143+
But before you can invoke tox, you must set up all the necessary target environments on your
144+
workstation. The easiest and cleanest way to achieve this is by using a tool like
145+
[pyenv](https://github.com/pyenv/pyenv). Refer to the
146+
[pyenv documentation](https://github.com/pyenv/pyenv#installation) for instructions on how to
147+
install it. This generally involves installing some binaries as well as modifying a system level
148+
configuration file such as `.bash_profile`. Once pyenv is installed, you can install multiple
149+
versions of Python as follows:
150+
151+
```
152+
pyenv install 2.7.6 # install Python 2.7.6
153+
pyenv install 3.3.0 # install Python 3.3.0
154+
```
155+
156+
Refer to the [`tox.ini`](../tox.ini) file for a list of target environments that we usually test.
157+
Use pyenv to install all the required Python versions on your workstation. When your system is
158+
fully set up, you can execute the following command from the root of the repository to launch tox:
144159

145160
```
146161
tox
147162
```
148163

149-
This command will read a list of target environments from the [`tox.ini`](../tox.ini)
150-
file in the Git repository, and execute test cases in each of those environments.
164+
This command will read the list of target environments from `tox.ini`, and execute tests in each of
165+
those environments.
151166

152167
### Repo Organization
153168

firebase_admin/__init__.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Firebase Admin SDK for Python."""
22
import threading
33

4+
import six
5+
46
from firebase_admin import credentials
57

68

@@ -32,6 +34,8 @@ def initialize_app(credential=None, options=None, name=_DEFAULT_APP_NAME):
3234
ValueError: If the app name is already in use, or any of the
3335
provided arguments are invalid.
3436
"""
37+
if credential is None:
38+
credential = credentials.ApplicationDefault()
3539
app = App(name, credential, options)
3640
with _apps_lock:
3741
if app.name not in _apps:
@@ -63,7 +67,7 @@ def delete_app(name):
6367
Raises:
6468
ValueError: If the name is not a string.
6569
"""
66-
if not isinstance(name, basestring):
70+
if not isinstance(name, six.string_types):
6771
raise ValueError('Illegal app name argument type: "{}". App name '
6872
'must be a string.'.format(type(name)))
6973
with _apps_lock:
@@ -94,7 +98,7 @@ def get_app(name=_DEFAULT_APP_NAME):
9498
ValueError: If the specified name is not a string, or if the specified
9599
app does not exist.
96100
"""
97-
if not isinstance(name, basestring):
101+
if not isinstance(name, six.string_types):
98102
raise ValueError('Illegal app name argument type: "{}". App name '
99103
'must be a string.'.format(type(name)))
100104
with _apps_lock:
@@ -142,7 +146,7 @@ def __init__(self, name, credential, options):
142146
Raises:
143147
ValueError: If an argument is None or invalid.
144148
"""
145-
if not name or not isinstance(name, basestring):
149+
if not name or not isinstance(name, six.string_types):
146150
raise ValueError('Illegal Firebase app name "{0}" provided. App name must be a '
147151
'non-empty string.'.format(name))
148152
self._name = name

firebase_admin/auth.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import time
1010

1111
from oauth2client import crypt
12+
import six
1213

1314
import firebase_admin
1415
from firebase_admin import credentials
@@ -166,9 +167,8 @@ def create_custom_token(self, uid, developer_claims=None):
166167
', '.join(disallowed_keys)))
167168
raise ValueError(error_message)
168169

169-
if not uid or not isinstance(uid, basestring) or len(uid) > 128:
170-
raise ValueError(
171-
'uid must be a string between 1 and 128 characters.')
170+
if not uid or not isinstance(uid, six.string_types) or len(uid) > 128:
171+
raise ValueError('uid must be a string between 1 and 128 characters.')
172172

173173
now = int(time.time())
174174
payload = {
@@ -202,9 +202,15 @@ def verify_id_token(self, id_token):
202202
AppIdenityError: The JWT was found to be invalid, the message will
203203
contain details.
204204
"""
205-
if not id_token or not isinstance(id_token, basestring):
206-
raise ValueError('Illegal ID token provided: {0}. ID token '
207-
'must be a non-empty string.'.format(id_token))
205+
if not id_token:
206+
raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty '
207+
'string.'.format(id_token))
208+
209+
if isinstance(id_token, six.text_type):
210+
id_token = id_token.encode('ascii')
211+
if not isinstance(id_token, six.binary_type):
212+
raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty '
213+
'string.'.format(id_token))
208214

209215
try:
210216
project_id = self._app.credential.project_id
@@ -256,7 +262,7 @@ def verify_id_token(self, id_token):
256262
.format(expected_issuer, issuer,
257263
project_id_match_msg,
258264
verify_id_token_msg))
259-
elif subject is None or not isinstance(subject, basestring):
265+
elif subject is None or not isinstance(subject, six.string_types):
260266
error_message = ('Firebase ID token has no "sub" (subject) '
261267
'claim. ') + verify_id_token_msg
262268
elif not subject:

firebase_admin/jwt.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,10 @@
1515

1616
from oauth2client import client
1717
from oauth2client import crypt
18+
from oauth2client import transport
1819

19-
try:
20-
# Newer versions of oauth2client (> v1.4)
21-
# pylint: disable=g-import-not-at-top
22-
from oauth2client import transport
23-
_cached_http = httplib2.Http(transport.MemoryCache())
24-
except ImportError:
25-
# Older versions of oauth2client (<= v1.4)
26-
_cached_http = httplib2.Http(client.MemoryCache())
20+
21+
_cached_http = httplib2.Http(transport.MemoryCache())
2722

2823

2924
def _to_bytes(value, encoding='ascii'):
@@ -139,8 +134,9 @@ def verify_id_token(id_token, cert_uri, audience=None, kid=None, http=None):
139134
raise client.VerifyJwtTokenError(
140135
('Failed to load public key certificates from URL "{0}". Received '
141136
'HTTP status code {1}.').format(cert_uri, resp.status))
142-
certs = json.loads(content.decode('utf-8'))
143-
if kid and not certs.has_key(kid):
137+
str_content = content.decode('utf-8') if isinstance(content, six.binary_type) else content
138+
certs = json.loads(str_content)
139+
if kid and kid not in certs:
144140
raise client.VerifyJwtTokenError(
145141
'Firebase ID token has "kid" claim which does'
146142
' not correspond to a known public key. Most'

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ pytest >= 3.0.6
33
tox >= 2.6.0
44

55
oauth2client >= 4.0.0
6+
six >= 1.6.1

setup.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@
5454
# your project is installed. For an analysis of "install_requires" vs pip's
5555
# requirements files see:
5656
# https://packaging.python.org/en/latest/requirements.html
57-
install_requires=['oauth2client'],
57+
install_requires=[
58+
'oauth2client>=4.0.0',
59+
'six>=1.6.1'
60+
],
5861

5962
# List additional groups of dependencies here (e.g. development
6063
# dependencies). You can install these using the following syntax,

tests/test_app.py

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
"""Tests for firebase_admin.App."""
2+
import os
3+
24
import pytest
35

46
import firebase_admin
@@ -9,6 +11,57 @@
911
CREDENTIAL = credentials.Certificate(
1012
testutils.resource_filename('service_account.json'))
1113

14+
class CredentialProvider(object):
15+
def init(self):
16+
pass
17+
18+
def get(self):
19+
pass
20+
21+
def cleanup(self):
22+
pass
23+
24+
25+
class Cert(CredentialProvider):
26+
def get(self):
27+
return CREDENTIAL
28+
29+
30+
class RefreshToken(CredentialProvider):
31+
def get(self):
32+
return credentials.RefreshToken(testutils.resource_filename('refresh_token.json'))
33+
34+
35+
class ExplicitAppDefault(CredentialProvider):
36+
VAR_NAME = 'GOOGLE_APPLICATION_CREDENTIALS'
37+
38+
def init(self):
39+
self.file_path = os.environ.get(self.VAR_NAME)
40+
os.environ[self.VAR_NAME] = testutils.resource_filename('service_account.json')
41+
42+
def get(self):
43+
return credentials.ApplicationDefault()
44+
45+
def cleanup(self):
46+
if self.file_path:
47+
os.environ[self.VAR_NAME] = self.file_path
48+
else:
49+
del os.environ[self.VAR_NAME]
50+
51+
52+
class ImplicitAppDefault(ExplicitAppDefault):
53+
def get(self):
54+
return None
55+
56+
57+
@pytest.fixture(params=[Cert(), RefreshToken(), ExplicitAppDefault(), ImplicitAppDefault()],
58+
ids=['cert', 'refreshtoken', 'explicit-appdefault', 'implicit-appdefault'])
59+
def app_credential(request):
60+
provider = request.param
61+
provider.init()
62+
yield provider.get()
63+
provider.cleanup()
64+
1265

1366
class TestFirebaseApp(object):
1467
"""Test cases for App initialization and life cycle."""
@@ -20,19 +73,25 @@ class TestFirebaseApp(object):
2073
def teardown_method(self):
2174
testutils.cleanup_apps()
2275

23-
def test_default_app_init(self):
24-
app = firebase_admin.initialize_app(CREDENTIAL)
76+
def test_default_app_init(self, app_credential):
77+
app = firebase_admin.initialize_app(app_credential)
2578
assert firebase_admin._DEFAULT_APP_NAME == app.name
26-
assert CREDENTIAL is app.credential
79+
if app_credential:
80+
assert app_credential is app.credential
81+
else:
82+
assert isinstance(app.credential, credentials.ApplicationDefault)
2783
with pytest.raises(ValueError):
28-
firebase_admin.initialize_app(CREDENTIAL)
84+
firebase_admin.initialize_app(app_credential)
2985

30-
def test_non_default_app_init(self):
31-
app = firebase_admin.initialize_app(CREDENTIAL, name='myApp')
86+
def test_non_default_app_init(self, app_credential):
87+
app = firebase_admin.initialize_app(app_credential, name='myApp')
3288
assert app.name == 'myApp'
33-
assert CREDENTIAL is app.credential
89+
if app_credential:
90+
assert app_credential is app.credential
91+
else:
92+
assert isinstance(app.credential, credentials.ApplicationDefault)
3493
with pytest.raises(ValueError):
35-
firebase_admin.initialize_app(CREDENTIAL, name='myApp')
94+
firebase_admin.initialize_app(app_credential, name='myApp')
3695

3796
@pytest.mark.parametrize('cred', invalid_credentials)
3897
def test_app_init_with_invalid_credential(self, cred):

tests/test_auth.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from oauth2client import client
66
from oauth2client import crypt
77
import pytest
8+
import six
89

910
import firebase_admin
1011
from firebase_admin import auth
@@ -75,7 +76,7 @@ def non_cert_app():
7576
firebase_admin.delete_app(app.name)
7677

7778
def verify_custom_token(custom_token, expected_claims):
78-
assert isinstance(custom_token, basestring)
79+
assert isinstance(custom_token, six.binary_type)
7980
token = client.verify_id_token(
8081
custom_token,
8182
FIREBASE_AUDIENCE,
@@ -117,6 +118,9 @@ def get_id_token(payload_overrides=None, header_overrides=None):
117118
return jwt.encode(payload, signer, headers=headers)
118119

119120

121+
TEST_ID_TOKEN = get_id_token()
122+
123+
120124
class TestCreateCustomToken(object):
121125

122126
valid_args = {
@@ -143,12 +147,12 @@ class TestCreateCustomToken(object):
143147
}
144148

145149
@pytest.mark.parametrize('user,claims', valid_args.values(),
146-
ids=valid_args.keys())
150+
ids=list(valid_args))
147151
def test_valid_params(self, authtest, user, claims):
148152
verify_custom_token(authtest.create_custom_token(user, claims), claims)
149153

150154
@pytest.mark.parametrize('user,claims,error', invalid_args.values(),
151-
ids=invalid_args.keys())
155+
ids=list(invalid_args))
152156
def test_invalid_params(self, authtest, user, claims, error):
153157
with pytest.raises(error):
154158
authtest.create_custom_token(user, claims)
@@ -160,6 +164,11 @@ def test_noncert_credential(self, non_cert_app):
160164

161165
class TestVerifyIdToken(object):
162166

167+
valid_tokens = {
168+
'BinaryToken': TEST_ID_TOKEN,
169+
'TextToken': TEST_ID_TOKEN.decode('utf-8'),
170+
}
171+
163172
invalid_tokens = {
164173
'NoKid': (get_id_token(header_overrides={'kid': None}),
165174
crypt.AppIdentityError),
@@ -197,23 +206,22 @@ class TestVerifyIdToken(object):
197206
def setup_method(self):
198207
auth._http = testutils.HttpMock(200, MOCK_PUBLIC_CERTS)
199208

200-
def test_valid_token(self, authtest):
201-
id_token = get_id_token()
209+
@pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens))
210+
def test_valid_token(self, authtest, id_token):
202211
claims = authtest.verify_id_token(id_token)
203212
assert claims['admin'] is True
204213

205214
@pytest.mark.parametrize('id_token,error', invalid_tokens.values(),
206-
ids=invalid_tokens.keys())
215+
ids=list(invalid_tokens))
207216
def test_invalid_token(self, authtest, id_token, error):
208217
with pytest.raises(error):
209218
authtest.verify_id_token(id_token)
210219

211220
def test_project_id_env_var(self, non_cert_app):
212-
id_token = get_id_token()
213221
gcloud_project = os.environ.get(auth.GCLOUD_PROJECT_ENV_VAR)
214222
try:
215223
os.environ[auth.GCLOUD_PROJECT_ENV_VAR] = MOCK_CREDENTIAL.project_id
216-
claims = auth.verify_id_token(id_token, non_cert_app)
224+
claims = auth.verify_id_token(TEST_ID_TOKEN, non_cert_app)
217225
assert claims['admin'] is True
218226
finally:
219227
if gcloud_project:
@@ -222,13 +230,12 @@ def test_project_id_env_var(self, non_cert_app):
222230
del os.environ[auth.GCLOUD_PROJECT_ENV_VAR]
223231

224232
def test_no_project_id(self, non_cert_app):
225-
id_token = get_id_token()
226233
gcloud_project = os.environ.get(auth.GCLOUD_PROJECT_ENV_VAR)
227234
if gcloud_project:
228235
del os.environ[auth.GCLOUD_PROJECT_ENV_VAR]
229236
try:
230237
with pytest.raises(ValueError):
231-
auth.verify_id_token(id_token, non_cert_app)
238+
auth.verify_id_token(TEST_ID_TOKEN, non_cert_app)
232239
finally:
233240
if gcloud_project:
234241
os.environ[auth.GCLOUD_PROJECT_ENV_VAR] = gcloud_project
@@ -239,7 +246,6 @@ def test_custom_token(self, authtest):
239246
authtest.verify_id_token(id_token)
240247

241248
def test_certificate_request_failure(self, authtest):
242-
id_token = get_id_token()
243249
auth._http = testutils.HttpMock(404, 'not found')
244250
with pytest.raises(client.VerifyJwtTokenError):
245-
authtest.verify_id_token(id_token)
251+
authtest.verify_id_token(TEST_ID_TOKEN)

0 commit comments

Comments
 (0)