Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow credentials files to be passed for channel creation #50

Merged
merged 16 commits into from Jun 18, 2020
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Expand Up @@ -57,4 +57,7 @@ system_tests/local_test_setup

# Make sure a generated file isn't accidentally committed.
pylintrc
pylintrc.test
pylintrc.test

# pytype
pytype_output
35 changes: 27 additions & 8 deletions google/api_core/grpc_helpers.py
Expand Up @@ -176,13 +176,16 @@ def wrap_errors(callable_):
return _wrap_unary_errors(callable_)


def _create_composite_credentials(credentials=None, scopes=None, ssl_credentials=None):
def _create_composite_credentials(credentials=None, credentials_file=None, scopes=None, ssl_credentials=None):
"""Create the composite credentials for secure channels.

Args:
credentials (google.auth.credentials.Credentials): The credentials. If
not specified, then this function will attempt to ascertain the
credentials from the environment using :func:`google.auth.default`.
credentials_file (str): A file with credentials that can be loaded with
:func:`google.auth.load_credentials_from_file`. This argument is
mutually exclusive with credentials.
scopes (Sequence[str]): A optional list of scopes needed for this
service. These are only used when credentials are not specified and
are passed to :func:`google.auth.default`.
Expand All @@ -191,13 +194,19 @@ def _create_composite_credentials(credentials=None, scopes=None, ssl_credentials

Returns:
grpc.ChannelCredentials: The composed channel credentials object.

Raises:
ValueError: If both a credentials object and credentials_file are passed.
"""
if credentials is None:
credentials, _ = google.auth.default(scopes=scopes)
if credentials and credentials_file:
raise ValueError("'credentials' and 'credentials_file' are mutually exclusive.")

if credentials_file:
credentials, _ = google.auth.load_credentials_from_file(credentials_file, scopes=scopes)
elif credentials:
credentials = google.auth.credentials.with_scopes_if_required(credentials, scopes)
else:
credentials = google.auth.credentials.with_scopes_if_required(
credentials, scopes
)
credentials, _ = google.auth.default(scopes=scopes)

request = google.auth.transport.requests.Request()

Expand All @@ -218,7 +227,7 @@ def _create_composite_credentials(credentials=None, scopes=None, ssl_credentials
)


def create_channel(target, credentials=None, scopes=None, ssl_credentials=None, **kwargs):
def create_channel(target, credentials=None, scopes=None, ssl_credentials=None, credentials_file=None, **kwargs):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added credentials_file to the end of the list to avoid breaking anyone who is using positional arguments.

"""Create a secure channel with credentials.

Args:
Expand All @@ -231,14 +240,24 @@ def create_channel(target, credentials=None, scopes=None, ssl_credentials=None,
are passed to :func:`google.auth.default`.
ssl_credentials (grpc.ChannelCredentials): Optional SSL channel
credentials. This can be used to specify different certificates.
credentials_file (str): A file with credentials that can be loaded with
:func:`google.auth.load_credentials_from_file`. This argument is
mutually exclusive with credentials.
kwargs: Additional key-word args passed to
:func:`grpc_gcp.secure_channel` or :func:`grpc.secure_channel`.

Returns:
grpc.Channel: The created channel.

Raises:
ValueError: If both a credentials object and credentials_file are passed.
"""

composite_credentials = _create_composite_credentials(
credentials, scopes, ssl_credentials
credentials=credentials,
credentials_file=credentials_file,
scopes=scopes,
ssl_credentials=ssl_credentials
)

if HAS_GRPC_GCP:
Expand Down
14 changes: 12 additions & 2 deletions google/api_core/grpc_helpers_async.py
Expand Up @@ -206,7 +206,7 @@ def wrap_errors(callable_):
return _wrap_stream_errors(callable_)


def create_channel(target, credentials=None, scopes=None, ssl_credentials=None, **kwargs):
def create_channel(target, credentials=None, scopes=None, ssl_credentials=None, credentials_file=None, **kwargs):
"""Create an AsyncIO secure channel with credentials.

Args:
Expand All @@ -219,13 +219,23 @@ def create_channel(target, credentials=None, scopes=None, ssl_credentials=None,
are passed to :func:`google.auth.default`.
ssl_credentials (grpc.ChannelCredentials): Optional SSL channel
credentials. This can be used to specify different certificates.
credentials_file (str): A file with credentials that can be loaded with
:func:`google.auth.load_credentials_from_file`. This argument is
mutually exclusive with credentials.
kwargs: Additional key-word args passed to :func:`aio.secure_channel`.

Returns:
aio.Channel: The created channel.

Raises:
ValueError: If both a credentials object and credentials_file are passed.
"""

composite_credentials = grpc_helpers._create_composite_credentials(
credentials, scopes, ssl_credentials
credentials=credentials,
credentials_file=credentials_file,
scopes=scopes,
ssl_credentials=ssl_credentials
)

return aio.secure_channel(target, composite_credentials, **kwargs)
Expand Down
3 changes: 3 additions & 0 deletions noxfile.py
Expand Up @@ -60,6 +60,9 @@ def default(session):
]
pytest_args.extend(session.posargs)

# TODO(busunkim): Remove once 'load_default_credentials_from_file' is available in google-auth
session.install("--force-reinstall", "git+https://github.com/googleapis/google-auth-library-python.git@support-scopes")

# Inject AsyncIO content, if version >= 3.6.
if _greater_or_equal_than_36(session.python):
session.install("asyncmock", "pytest-asyncio")
Expand Down
56 changes: 56 additions & 0 deletions tests/asyncio/test_grpc_helpers_async.py
Expand Up @@ -317,6 +317,19 @@ def test_create_channel_implicit_with_scopes(
grpc_secure_channel.assert_called_once_with(target, composite_creds)


def test_create_channel_explicit_with_duplicate_credentials():
target = "example:443"

with pytest.raises(ValueError) as excinfo:
grpc_helpers_async.create_channel(
target,
credentials_file="credentials.json",
credentials=mock.sentinel.credentials
)

assert "mutually exclusive" in str(excinfo.value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a huge fan of checking for explicit strings in exception messages. If it's easy and within the style guides of the repo, I like to make one-off exception types and check for the specific exception type within tests, something like

class DuplicateCredentialArgs(Exception):
   pass
...
with pytest.raises(DuplicateCredentialArgs) as exc:
   ...



@mock.patch("grpc.composite_channel_credentials")
@mock.patch("google.auth.credentials.with_scopes_if_required")
@mock.patch("grpc.experimental.aio.secure_channel")
Expand Down Expand Up @@ -350,6 +363,49 @@ def test_create_channel_explicit_scoped(grpc_secure_channel, composite_creds_cal
grpc_secure_channel.assert_called_once_with(target, composite_creds)


@mock.patch("grpc.composite_channel_credentials")
@mock.patch("grpc.experimental.aio.secure_channel")
@mock.patch(
"google.auth.load_credentials_from_file",
return_value=(mock.sentinel.credentials, mock.sentinel.project)
)
def test_create_channnel_with_credentials_file(load_credentials_from_file, grpc_secure_channel, composite_creds_call):
target = "example.com:443"

credentials_file = "/path/to/credentials/file.json"
composite_creds = composite_creds_call.return_value

channel = grpc_helpers_async.create_channel(
target, credentials_file=credentials_file
)

google.auth.load_credentials_from_file.assert_called_once_with(credentials_file, scopes=None)
assert channel is grpc_secure_channel.return_value
grpc_secure_channel.assert_called_once_with(target, composite_creds)


@mock.patch("grpc.composite_channel_credentials")
@mock.patch("grpc.experimental.aio.secure_channel")
@mock.patch(
"google.auth.load_credentials_from_file",
return_value=(mock.sentinel.credentials, mock.sentinel.project)
)
def test_create_channel_with_credentials_file_and_scopes(load_credentials_from_file, grpc_secure_channel, composite_creds_call):
target = "example.com:443"
scopes = ["1", "2"]

credentials_file = "/path/to/credentials/file.json"
composite_creds = composite_creds_call.return_value

channel = grpc_helpers_async.create_channel(
target, credentials_file=credentials_file, scopes=scopes
)

google.auth.load_credentials_from_file.assert_called_once_with(credentials_file, scopes=scopes)
assert channel is grpc_secure_channel.return_value
grpc_secure_channel.assert_called_once_with(target, composite_creds)


@pytest.mark.skipif(grpc_helpers_async.HAS_GRPC_GCP, reason="grpc_gcp module not available")
@mock.patch("grpc.experimental.aio.secure_channel")
def test_create_channel_without_grpc_gcp(grpc_secure_channel):
Expand Down
63 changes: 63 additions & 0 deletions tests/unit/test_grpc_helpers.py
Expand Up @@ -285,6 +285,19 @@ def test_create_channel_implicit_with_scopes(
grpc_secure_channel.assert_called_once_with(target, composite_creds)


def test_create_channel_explicit_with_duplicate_credentials():
target = "example.com:443"

with pytest.raises(ValueError) as excinfo:
grpc_helpers.create_channel(
target,
credentials_file="credentials.json",
credentials=mock.sentinel.credentials
)

assert "mutually exclusive" in str(excinfo.value)


@mock.patch("grpc.composite_channel_credentials")
@mock.patch("google.auth.credentials.with_scopes_if_required")
@mock.patch("grpc.secure_channel")
Expand Down Expand Up @@ -324,6 +337,56 @@ def test_create_channel_explicit_scoped(grpc_secure_channel, composite_creds_cal
grpc_secure_channel.assert_called_once_with(target, composite_creds)


@mock.patch("grpc.composite_channel_credentials")
@mock.patch("grpc.secure_channel")
@mock.patch(
"google.auth.load_credentials_from_file",
return_value=(mock.sentinel.credentials, mock.sentinel.project)
)
def test_create_channel_with_credentials_file(load_credentials_from_file, grpc_secure_channel, composite_creds_call):
target = "example.com:443"

credentials_file = "/path/to/credentials/file.json"
composite_creds = composite_creds_call.return_value

channel = grpc_helpers.create_channel(
target, credentials_file=credentials_file
)

google.auth.load_credentials_from_file.assert_called_once_with(credentials_file, scopes=None)

assert channel is grpc_secure_channel.return_value
if grpc_helpers.HAS_GRPC_GCP:
grpc_secure_channel.assert_called_once_with(target, composite_creds, None)
else:
grpc_secure_channel.assert_called_once_with(target, composite_creds)


@mock.patch("grpc.composite_channel_credentials")
@mock.patch("grpc.secure_channel")
@mock.patch(
"google.auth.load_credentials_from_file",
return_value=(mock.sentinel.credentials, mock.sentinel.project)
)
def test_create_channel_with_credentials_file_and_scopes(load_credentials_from_file, grpc_secure_channel, composite_creds_call):
target = "example.com:443"
scopes = ["1", "2"]

credentials_file = "/path/to/credentials/file.json"
composite_creds = composite_creds_call.return_value

channel = grpc_helpers.create_channel(
target, credentials_file=credentials_file, scopes=scopes
)

google.auth.load_credentials_from_file.assert_called_once_with(credentials_file, scopes=scopes)
assert channel is grpc_secure_channel.return_value
if grpc_helpers.HAS_GRPC_GCP:
grpc_secure_channel.assert_called_once_with(target, composite_creds, None)
else:
grpc_secure_channel.assert_called_once_with(target, composite_creds)


@pytest.mark.skipif(
not grpc_helpers.HAS_GRPC_GCP, reason="grpc_gcp module not available"
)
Expand Down