Repository: googleapis/google-cloud-python

Issue:
Ensure that gRPC metrics headers are in the order requested by the spec.
The present order in `api.core` is:

```
core/0.27.1 gl-python/3.6.0 grpc/0.27.1 gapic/0.27.2
```

The order per spec should be:

```
gl-python/2.7.12 gccl/0.21.0 gapic/0.1.0 core/0.14.1 grpc/1.0.1
```


Discussion:
And `core` should be spelled `gax`.
Related: https://github.com/grpc/grpc/pull/12778/files
Part of me kind of wants to sit on this until intercepters are ready.

Resolving patch:
diff --git a/api_core/google/api_core/gapic_v1/client_info.py b/api_core/google/api_core/gapic_v1/client_info.py
new file mode 100644
--- /dev/null
+++ b/api_core/google/api_core/gapic_v1/client_info.py
@@ -0,0 +1,84 @@
+# Copyright 2017 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Client information
+
+This module is used by client libraries to send information about the calling
+client to services.
+"""
+
+import platform
+
+import pkg_resources
+
+_PY_VERSION = platform.python_version()
+_GRPC_VERSION = pkg_resources.get_distribution('grpcio').version
+_API_CORE_VERSION = pkg_resources.get_distribution('google-api-core').version
+METRICS_METADATA_KEY = 'x-goog-api-client'
+
+
+class ClientInfo(object):
+    """Client information used to generate a user-agent for API calls.
+
+    This user-agent information is sent along with API calls to allow the
+    receiving service to do analytics on which versions of Python and Google
+    libraries are being used.
+
+    Args:
+        python_version (str): The Python interpreter version, for example,
+            ``'2.7.13'``.
+        grpc_version (str): The gRPC library version.
+        api_core_version (str): The google-api-core library version.
+        gapic_version (Optional[str]): The sversion of gapic-generated client
+            library, if the library was generated by gapic.
+        client_library_version (Optional[str]): The version of the client
+            library, generally used if the client library was not generated
+            by gapic or if additional functionality was built on top of
+            a gapic client library.
+    """
+    def __init__(
+            self,
+            python_version=_PY_VERSION,
+            grpc_version=_GRPC_VERSION,
+            api_core_version=_API_CORE_VERSION,
+            gapic_version=None,
+            client_library_version=None):
+        self.python_version = python_version
+        self.grpc_version = grpc_version
+        self.api_core_version = api_core_version
+        self.gapic_version = gapic_version
+        self.client_library_version = client_library_version
+
+    def to_user_agent(self):
+        """Returns the user-agent string for this client info."""
+        # Note: the order here is important as the internal metrics system
+        # expects these items to be in specific locations.
+        ua = 'gl-python/{python_version} '
+
+        if self.client_library_version is not None:
+            ua += 'gccl/{client_library_version} '
+
+        if self.gapic_version is not None:
+            ua += 'gapic/{gapic_version} '
+
+        ua += 'gax/{api_core_version} grpc/{grpc_version}'
+
+        return ua.format(**self.__dict__)
+
+    def to_grpc_metadata(self):
+        """Returns the gRPC metadata for this client info."""
+        return (METRICS_METADATA_KEY, self.to_user_agent())
+
+
+DEFAULT_CLIENT_INFO = ClientInfo()
diff --git a/api_core/google/api_core/gapic_v1/method.py b/api_core/google/api_core/gapic_v1/method.py
--- a/api_core/google/api_core/gapic_v1/method.py
+++ b/api_core/google/api_core/gapic_v1/method.py
@@ -19,19 +19,15 @@
 """
 
 import functools
-import platform
 
-import pkg_resources
 import six
 
 from google.api_core import general_helpers
 from google.api_core import grpc_helpers
 from google.api_core import page_iterator
 from google.api_core import timeout
+from google.api_core.gapic_v1 import client_info
 
-_PY_VERSION = platform.python_version()
-_GRPC_VERSION = pkg_resources.get_distribution('grpcio').version
-_API_CORE_VERSION = pkg_resources.get_distribution('google-api-core').version
 METRICS_METADATA_KEY = 'x-goog-api-client'
 USE_DEFAULT_METADATA = object()
 DEFAULT = object()
@@ -57,28 +53,6 @@ def _apply_decorators(func, decorators):
     return func
 
 
-def _prepare_metadata(metadata):
-    """Transforms metadata to gRPC format and adds global metrics.
-
-    Args:
-        metadata (Mapping[str, str]): Any current metadata.
-
-    Returns:
-        Sequence[Tuple(str, str)]: The gRPC-friendly metadata keys and values.
-    """
-    client_metadata = 'api-core/{} gl-python/{} grpc/{}'.format(
-        _API_CORE_VERSION, _PY_VERSION, _GRPC_VERSION)
-
-    # Merge this with any existing metric metadata.
-    if METRICS_METADATA_KEY in metadata:
-        client_metadata = '{} {}'.format(
-            client_metadata, metadata[METRICS_METADATA_KEY])
-
-    metadata[METRICS_METADATA_KEY] = client_metadata
-
-    return list(metadata.items())
-
-
 def _determine_timeout(default_timeout, specified_timeout, retry):
     """Determines how timeout should be applied to a wrapped method.
 
@@ -125,16 +99,16 @@ class _GapicCallable(object):
         timeout (google.api_core.timeout.Timeout): The default timeout
             for the callable. If ``None``, this callable will not specify
             a timeout argument to the low-level RPC method by default.
-        metadata (Optional[Sequence[Tuple[str, str]]]): gRPC call metadata
-            that's passed to the low-level RPC method. If ``None``, no metadata
-            will be passed to the low-level RPC method.
+        user_agent_metadata (Tuple[str, str]): The user agent metadata key and
+            value to provide to the RPC method. If ``None``, no additional
+            metadata will be passed to the RPC method.
     """
 
-    def __init__(self, target, retry, timeout, metadata):
+    def __init__(self, target, retry, timeout, user_agent_metadata=None):
         self._target = target
         self._retry = retry
         self._timeout = timeout
-        self._metadata = metadata
+        self._user_agent_metadata = user_agent_metadata
 
     def __call__(self, *args, **kwargs):
         """Invoke the low-level RPC with retry, timeout, and metadata."""
@@ -156,17 +130,18 @@ def __call__(self, *args, **kwargs):
         # Apply all applicable decorators.
         wrapped_func = _apply_decorators(self._target, [retry, timeout_])
 
-        # Set the metadata for the call using the metadata calculated by
-        # _prepare_metadata.
-        if self._metadata is not None:
-            kwargs['metadata'] = self._metadata
+        # Add the user agent metadata to the call.
+        if self._user_agent_metadata is not None:
+            metadata = kwargs.get('metadata', [])
+            metadata.append(self._user_agent_metadata)
+            kwargs['metadata'] = metadata
 
         return wrapped_func(*args, **kwargs)
 
 
 def wrap_method(
         func, default_retry=None, default_timeout=None,
-        metadata=USE_DEFAULT_METADATA):
+        client_info=client_info.DEFAULT_CLIENT_INFO):
     """Wrap an RPC method with common behavior.
 
     This applies common error wrapping, retry, and timeout behavior a function.
@@ -234,11 +209,12 @@ def get_topic(name, timeout=None):
         default_timeout (Optional[google.api_core.Timeout]): The default
             timeout strategy. Can also be specified as an int or float. If
             ``None``, the method will not have timeout specified by default.
-        metadata (Optional(Mapping[str, str])): A dict of metadata keys and
-            values. This will be augmented with common ``x-google-api-client``
-            metadata. If ``None``, metadata will not be passed to the function
-            at all, if :attr:`USE_DEFAULT_METADATA` (the default) then only the
-            common metadata will be provided.
+        client_info
+            (Optional[google.api_core.gapic_v1.client_info.ClientInfo]):
+                Client information used to create a user-agent string that's
+                passed as gRPC metadata to the method. If unspecified, then
+                a sane default will be used. If ``None``, then no user agent
+                metadata will be provided to the RPC method.
 
     Returns:
         Callable: A new callable that takes optional ``retry`` and ``timeout``
@@ -247,14 +223,15 @@ def get_topic(name, timeout=None):
     """
     func = grpc_helpers.wrap_errors(func)
 
-    if metadata is USE_DEFAULT_METADATA:
-        metadata = {}
-
-    if metadata is not None:
-        metadata = _prepare_metadata(metadata)
+    if client_info is not None:
+        user_agent_metadata = client_info.to_grpc_metadata()
+    else:
+        user_agent_metadata = None
 
     return general_helpers.wraps(func)(
-        _GapicCallable(func, default_retry, default_timeout, metadata))
+        _GapicCallable(
+            func, default_retry, default_timeout,
+            user_agent_metadata=user_agent_metadata))
 
 
 def wrap_with_paging(


Test patch:
diff --git a/api_core/tests/unit/gapic/test_client_info.py b/api_core/tests/unit/gapic/test_client_info.py
new file mode 100644
--- /dev/null
+++ b/api_core/tests/unit/gapic/test_client_info.py
@@ -0,0 +1,73 @@
+# Copyright 2017 Google Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+
+from google.api_core.gapic_v1 import client_info
+
+
+def test_constructor_defaults():
+    info = client_info.ClientInfo()
+
+    assert info.python_version is not None
+    assert info.grpc_version is not None
+    assert info.api_core_version is not None
+    assert info.gapic_version is None
+    assert info.client_library_version is None
+
+
+def test_constructor_options():
+    info = client_info.ClientInfo(
+        python_version='1',
+        grpc_version='2',
+        api_core_version='3',
+        gapic_version='4',
+        client_library_version='5')
+
+    assert info.python_version == '1'
+    assert info.grpc_version == '2'
+    assert info.api_core_version == '3'
+    assert info.gapic_version == '4'
+    assert info.client_library_version == '5'
+
+
+def test_to_user_agent_minimal():
+    info = client_info.ClientInfo(
+        python_version='1',
+        grpc_version='2',
+        api_core_version='3')
+
+    user_agent = info.to_user_agent()
+
+    assert user_agent == 'gl-python/1 gax/3 grpc/2'
+
+
+def test_to_user_agent_full():
+    info = client_info.ClientInfo(
+        python_version='1',
+        grpc_version='2',
+        api_core_version='3',
+        gapic_version='4',
+        client_library_version='5')
+
+    user_agent = info.to_user_agent()
+
+    assert user_agent == 'gl-python/1 gccl/5 gapic/4 gax/3 grpc/2'
+
+
+def test_to_grpc_metadata():
+    info = client_info.ClientInfo()
+
+    metadata = info.to_grpc_metadata()
+
+    assert metadata == (client_info.METRICS_METADATA_KEY, info.to_user_agent())
diff --git a/api_core/tests/unit/gapic/test_method.py b/api_core/tests/unit/gapic/test_method.py
--- a/api_core/tests/unit/gapic/test_method.py
+++ b/api_core/tests/unit/gapic/test_method.py
@@ -19,6 +19,7 @@
 from google.api_core import exceptions
 from google.api_core import retry
 from google.api_core import timeout
+import google.api_core.gapic_v1.client_info
 import google.api_core.gapic_v1.method
 import google.api_core.page_iterator
 
@@ -34,59 +35,48 @@ def _utcnow_monotonic():
 def test_wrap_method_basic():
     method = mock.Mock(spec=['__call__'], return_value=42)
 
-    wrapped_method = google.api_core.gapic_v1.method.wrap_method(
-        method, metadata=None)
+    wrapped_method = google.api_core.gapic_v1.method.wrap_method(method)
 
     result = wrapped_method(1, 2, meep='moop')
 
     assert result == 42
-    method.assert_called_once_with(1, 2, meep='moop')
-
-
-def test_wrap_method_with_default_metadata():
-    method = mock.Mock(spec=['__call__'])
-
-    wrapped_method = google.api_core.gapic_v1.method.wrap_method(method)
-
-    wrapped_method(1, 2, meep='moop')
-
     method.assert_called_once_with(1, 2, meep='moop', metadata=mock.ANY)
 
+    # Check that the default client info was specified in the metadata.
     metadata = method.call_args[1]['metadata']
     assert len(metadata) == 1
-    assert metadata[0][0] == 'x-goog-api-client'
-    assert 'api-core' in metadata[0][1]
+    client_info = google.api_core.gapic_v1.client_info.DEFAULT_CLIENT_INFO
+    user_agent_metadata = client_info.to_grpc_metadata()
+    assert user_agent_metadata in metadata
 
 
-def test_wrap_method_with_custom_metadata():
+def test_wrap_method_with_no_client_info():
     method = mock.Mock(spec=['__call__'])
 
     wrapped_method = google.api_core.gapic_v1.method.wrap_method(
-        method, metadata={'foo': 'bar'})
+        method, client_info=None)
 
     wrapped_method(1, 2, meep='moop')
 
-    method.assert_called_once_with(1, 2, meep='moop', metadata=mock.ANY)
-
-    metadata = method.call_args[1]['metadata']
-    assert len(metadata) == 2
-    assert ('foo', 'bar') in metadata
+    method.assert_called_once_with(1, 2, meep='moop')
 
 
-def test_wrap_method_with_merged_metadata():
+def test_wrap_method_with_custom_client_info():
+    client_info = google.api_core.gapic_v1.client_info.ClientInfo(
+        python_version=1, grpc_version=2, api_core_version=3, gapic_version=4,
+        client_library_version=5)
     method = mock.Mock(spec=['__call__'])
 
     wrapped_method = google.api_core.gapic_v1.method.wrap_method(
-        method, metadata={'x-goog-api-client': 'foo/1.2.3'})
+        method, client_info=client_info)
 
     wrapped_method(1, 2, meep='moop')
 
     method.assert_called_once_with(1, 2, meep='moop', metadata=mock.ANY)
 
+    # Check that the custom client info was specified in the metadata.
     metadata = method.call_args[1]['metadata']
-    assert len(metadata) == 1
-    assert metadata[0][0] == 'x-goog-api-client'
-    assert metadata[0][1].endswith(' foo/1.2.3')
+    assert client_info.to_grpc_metadata() in metadata
 
 
 @mock.patch('time.sleep')