diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml
index b75186cf..ee94722a 100644
--- a/.github/.OwlBot.lock.yaml
+++ b/.github/.OwlBot.lock.yaml
@@ -1,3 +1,3 @@
docker:
- image: gcr.io/repo-automation-bots/owlbot-python:latest
- digest: sha256:d6761eec279244e57fe9d21f8343381a01d3632c034811a72f68b83119e58c69
+ image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest
+ digest: sha256:6e7328583be8edd3ba8f35311c76a1ecbc823010279ccb6ab46b7a76e25eafcc
diff --git a/.github/.OwlBot.yaml b/.github/.OwlBot.yaml
index 3caf68d4..c8b40cc7 100644
--- a/.github/.OwlBot.yaml
+++ b/.github/.OwlBot.yaml
@@ -13,7 +13,7 @@
# limitations under the License.
docker:
- image: gcr.io/repo-automation-bots/owlbot-python:latest
+ image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest
begin-after-commit-hash: 7af2cb8b2b725641ac0d07e2f256d453682802e6
diff --git a/.kokoro/samples/lint/common.cfg b/.kokoro/samples/lint/common.cfg
index f5dddb4b..1a2b87b2 100644
--- a/.kokoro/samples/lint/common.cfg
+++ b/.kokoro/samples/lint/common.cfg
@@ -31,4 +31,4 @@ gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples"
gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline"
# Use the trampoline script to run in docker.
-build_file: "python-api-core/.kokoro/trampoline.sh"
\ No newline at end of file
+build_file: "python-api-core/.kokoro/trampoline_v2.sh"
\ No newline at end of file
diff --git a/.kokoro/samples/python3.6/common.cfg b/.kokoro/samples/python3.6/common.cfg
index 7b4f5cd0..3bb6b3a6 100644
--- a/.kokoro/samples/python3.6/common.cfg
+++ b/.kokoro/samples/python3.6/common.cfg
@@ -37,4 +37,4 @@ gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples"
gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline"
# Use the trampoline script to run in docker.
-build_file: "python-api-core/.kokoro/trampoline.sh"
\ No newline at end of file
+build_file: "python-api-core/.kokoro/trampoline_v2.sh"
\ No newline at end of file
diff --git a/.kokoro/samples/python3.6/periodic.cfg b/.kokoro/samples/python3.6/periodic.cfg
index 50fec964..71cd1e59 100644
--- a/.kokoro/samples/python3.6/periodic.cfg
+++ b/.kokoro/samples/python3.6/periodic.cfg
@@ -3,4 +3,4 @@
env_vars: {
key: "INSTALL_LIBRARY_FROM_SOURCE"
value: "False"
-}
\ No newline at end of file
+}
diff --git a/.kokoro/samples/python3.7/common.cfg b/.kokoro/samples/python3.7/common.cfg
index 1198d7ba..a3aa10b5 100644
--- a/.kokoro/samples/python3.7/common.cfg
+++ b/.kokoro/samples/python3.7/common.cfg
@@ -37,4 +37,4 @@ gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples"
gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline"
# Use the trampoline script to run in docker.
-build_file: "python-api-core/.kokoro/trampoline.sh"
\ No newline at end of file
+build_file: "python-api-core/.kokoro/trampoline_v2.sh"
\ No newline at end of file
diff --git a/.kokoro/samples/python3.7/periodic.cfg b/.kokoro/samples/python3.7/periodic.cfg
index 50fec964..71cd1e59 100644
--- a/.kokoro/samples/python3.7/periodic.cfg
+++ b/.kokoro/samples/python3.7/periodic.cfg
@@ -3,4 +3,4 @@
env_vars: {
key: "INSTALL_LIBRARY_FROM_SOURCE"
value: "False"
-}
\ No newline at end of file
+}
diff --git a/.kokoro/samples/python3.8/common.cfg b/.kokoro/samples/python3.8/common.cfg
index b7ec7f5e..20c941aa 100644
--- a/.kokoro/samples/python3.8/common.cfg
+++ b/.kokoro/samples/python3.8/common.cfg
@@ -37,4 +37,4 @@ gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples"
gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline"
# Use the trampoline script to run in docker.
-build_file: "python-api-core/.kokoro/trampoline.sh"
\ No newline at end of file
+build_file: "python-api-core/.kokoro/trampoline_v2.sh"
\ No newline at end of file
diff --git a/.kokoro/samples/python3.8/periodic.cfg b/.kokoro/samples/python3.8/periodic.cfg
index 50fec964..71cd1e59 100644
--- a/.kokoro/samples/python3.8/periodic.cfg
+++ b/.kokoro/samples/python3.8/periodic.cfg
@@ -3,4 +3,4 @@
env_vars: {
key: "INSTALL_LIBRARY_FROM_SOURCE"
value: "False"
-}
\ No newline at end of file
+}
diff --git a/.kokoro/samples/python3.9/common.cfg b/.kokoro/samples/python3.9/common.cfg
index cf034ec1..234887c6 100644
--- a/.kokoro/samples/python3.9/common.cfg
+++ b/.kokoro/samples/python3.9/common.cfg
@@ -37,4 +37,4 @@ gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples"
gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline"
# Use the trampoline script to run in docker.
-build_file: "python-api-core/.kokoro/trampoline.sh"
\ No newline at end of file
+build_file: "python-api-core/.kokoro/trampoline_v2.sh"
\ No newline at end of file
diff --git a/.kokoro/samples/python3.9/periodic.cfg b/.kokoro/samples/python3.9/periodic.cfg
index 50fec964..71cd1e59 100644
--- a/.kokoro/samples/python3.9/periodic.cfg
+++ b/.kokoro/samples/python3.9/periodic.cfg
@@ -3,4 +3,4 @@
env_vars: {
key: "INSTALL_LIBRARY_FROM_SOURCE"
value: "False"
-}
\ No newline at end of file
+}
diff --git a/.kokoro/test-samples-against-head.sh b/.kokoro/test-samples-against-head.sh
index a7858e4c..ba3a707b 100755
--- a/.kokoro/test-samples-against-head.sh
+++ b/.kokoro/test-samples-against-head.sh
@@ -23,6 +23,4 @@ set -eo pipefail
# Enables `**` to include files nested inside sub-folders
shopt -s globstar
-cd github/python-api-core
-
exec .kokoro/test-samples-impl.sh
diff --git a/.kokoro/test-samples.sh b/.kokoro/test-samples.sh
index ee3146bd..11c042d3 100755
--- a/.kokoro/test-samples.sh
+++ b/.kokoro/test-samples.sh
@@ -24,8 +24,6 @@ set -eo pipefail
# Enables `**` to include files nested inside sub-folders
shopt -s globstar
-cd github/python-api-core
-
# Run periodic samples tests at latest release
if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"periodic"* ]]; then
# preserving the test runner implementation.
diff --git a/.repo-metadata.json b/.repo-metadata.json
index 59aa936d..e16c9d27 100644
--- a/.repo-metadata.json
+++ b/.repo-metadata.json
@@ -1,10 +1,12 @@
{
- "name": "google-api-core",
- "name_pretty": "Google API client core library",
- "client_documentation": "https://googleapis.dev/python/google-api-core/latest",
- "release_level": "ga",
- "language": "python",
- "library_type": "CORE",
- "repo": "googleapis/python-api-core",
- "distribution_name": "google-api-core"
-}
\ No newline at end of file
+ "name": "google-api-core",
+ "name_pretty": "Google API client core library",
+ "client_documentation": "https://googleapis.dev/python/google-api-core/latest",
+ "release_level": "ga",
+ "language": "python",
+ "library_type": "CORE",
+ "repo": "googleapis/python-api-core",
+ "distribution_name": "google-api-core",
+ "default_version": "",
+ "codeowner_team": "@googleapis/actools-python"
+}
diff --git a/.trampolinerc b/.trampolinerc
index 383b6ec8..0eee72ab 100644
--- a/.trampolinerc
+++ b/.trampolinerc
@@ -16,15 +16,26 @@
# Add required env vars here.
required_envvars+=(
- "STAGING_BUCKET"
- "V2_STAGING_BUCKET"
)
# Add env vars which are passed down into the container here.
pass_down_envvars+=(
+ "NOX_SESSION"
+ ###############
+ # Docs builds
+ ###############
"STAGING_BUCKET"
"V2_STAGING_BUCKET"
- "NOX_SESSION"
+ ##################
+ # Samples builds
+ ##################
+ "INSTALL_LIBRARY_FROM_SOURCE"
+ "RUN_TESTS_SESSION"
+ "BUILD_SPECIFIC_GCLOUD_PROJECT"
+ # Target directories.
+ "RUN_TESTS_DIRS"
+ # The nox session to run.
+ "RUN_TESTS_SESSION"
)
# Prevent unintentional override on the default image.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fdca59ba..00910e1f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,15 @@
[1]: https://pypi.org/project/google-api-core/#history
+## [2.1.0](https://www.github.com/googleapis/python-api-core/compare/v2.0.1...v2.1.0) (2021-10-05)
+
+
+### Features
+
+* add grpc transcoding + tests ([#259](https://www.github.com/googleapis/python-api-core/issues/259)) ([afe0fa1](https://www.github.com/googleapis/python-api-core/commit/afe0fa14c21289c8244606a9f81544cff8ac5f7c))
+* Add helper function to format query_params for rest transport. ([#275](https://www.github.com/googleapis/python-api-core/issues/275)) ([1c5eb4d](https://www.github.com/googleapis/python-api-core/commit/1c5eb4df93d78e791082d9282330ebf0faacd222))
+* add support for Python 3.10 ([#284](https://www.github.com/googleapis/python-api-core/issues/284)) ([a422a5d](https://www.github.com/googleapis/python-api-core/commit/a422a5d72cb6f363d57e7a4effe421ba8e049cde))
+
### [2.0.1](https://www.github.com/googleapis/python-api-core/compare/v2.0.0...v2.0.1) (2021-08-31)
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 6db668ef..6b375f03 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -21,7 +21,7 @@ In order to add a feature:
documentation.
- The feature must work fully on the following CPython versions:
- 3.6, 3.7, 3.8 and 3.9 on both UNIX and Windows.
+ 3.6, 3.7, 3.8, 3.9, and 3.10 on both UNIX and Windows.
- The feature must not add unnecessary dependencies (where
"unnecessary" is of course subjective, but new dependencies should
@@ -71,7 +71,7 @@ We use `nox `__ to instrument our tests.
- To run a single unit test::
- $ nox -s unit-3.9 -- -k
+ $ nox -s unit-3.10 -- -k
.. note::
@@ -201,11 +201,13 @@ We support:
- `Python 3.7`_
- `Python 3.8`_
- `Python 3.9`_
+- `Python 3.10`_
.. _Python 3.6: https://docs.python.org/3.6/
.. _Python 3.7: https://docs.python.org/3.7/
.. _Python 3.8: https://docs.python.org/3.8/
.. _Python 3.9: https://docs.python.org/3.9/
+.. _Python 3.10: https://docs.python.org/3.10/
Supported versions can be found in our ``noxfile.py`` `config`_.
diff --git a/google/api_core/bidi.py b/google/api_core/bidi.py
index 56a021a9..4b4963f7 100644
--- a/google/api_core/bidi.py
+++ b/google/api_core/bidi.py
@@ -727,7 +727,7 @@ def resume(self):
"""Resumes the response stream."""
with self._wake:
self._paused = False
- self._wake.notifyAll()
+ self._wake.notify_all()
@property
def is_paused(self):
diff --git a/google/api_core/path_template.py b/google/api_core/path_template.py
index c5969c14..41fbd4fe 100644
--- a/google/api_core/path_template.py
+++ b/google/api_core/path_template.py
@@ -25,6 +25,8 @@
from __future__ import unicode_literals
+from collections import deque
+import copy
import functools
import re
@@ -64,7 +66,7 @@ def _expand_variable_match(positional_vars, named_vars, match):
"""Expand a matched variable with its value.
Args:
- positional_vars (list): A list of positonal variables. This list will
+ positional_vars (list): A list of positional variables. This list will
be modified.
named_vars (dict): A dictionary of named variables.
match (re.Match): A regular expression match.
@@ -170,6 +172,46 @@ def _generate_pattern_for_template(tmpl):
return _VARIABLE_RE.sub(_replace_variable_with_pattern, tmpl)
+def get_field(request, field):
+ """Get the value of a field from a given dictionary.
+
+ Args:
+ request (dict): A dictionary object.
+ field (str): The key to the request in dot notation.
+
+ Returns:
+ The value of the field.
+ """
+ parts = field.split(".")
+ value = request
+ for part in parts:
+ if not isinstance(value, dict):
+ return
+ value = value.get(part)
+ if isinstance(value, dict):
+ return
+ return value
+
+
+def delete_field(request, field):
+ """Delete the value of a field from a given dictionary.
+
+ Args:
+ request (dict): A dictionary object.
+ field (str): The key to the request in dot notation.
+ """
+ parts = deque(field.split("."))
+ while len(parts) > 1:
+ if not isinstance(request, dict):
+ return
+ part = parts.popleft()
+ request = request.get(part)
+ part = parts.popleft()
+ if not isinstance(request, dict):
+ return
+ request.pop(part, None)
+
+
def validate(tmpl, path):
"""Validate a path against the path template.
@@ -193,3 +235,66 @@ def validate(tmpl, path):
"""
pattern = _generate_pattern_for_template(tmpl) + "$"
return True if re.match(pattern, path) is not None else False
+
+
+def transcode(http_options, **request_kwargs):
+ """Transcodes a grpc request pattern into a proper HTTP request following the rules outlined here,
+ https://github.com/googleapis/googleapis/blob/master/google/api/http.proto#L44-L312
+
+ Args:
+ http_options (list(dict)): A list of dicts which consist of these keys,
+ 'method' (str): The http method
+ 'uri' (str): The path template
+ 'body' (str): The body field name (optional)
+ (This is a simplified representation of the proto option `google.api.http`)
+
+ request_kwargs (dict) : A dict representing the request object
+
+ Returns:
+ dict: The transcoded request with these keys,
+ 'method' (str) : The http method
+ 'uri' (str) : The expanded uri
+ 'body' (dict) : A dict representing the body (optional)
+ 'query_params' (dict) : A dict mapping query parameter variables and values
+
+ Raises:
+ ValueError: If the request does not match the given template.
+ """
+ for http_option in http_options:
+ request = {}
+
+ # Assign path
+ uri_template = http_option["uri"]
+ path_fields = [
+ match.group("name") for match in _VARIABLE_RE.finditer(uri_template)
+ ]
+ path_args = {field: get_field(request_kwargs, field) for field in path_fields}
+ request["uri"] = expand(uri_template, **path_args)
+
+ # Remove fields used in uri path from request
+ leftovers = copy.deepcopy(request_kwargs)
+ for path_field in path_fields:
+ delete_field(leftovers, path_field)
+
+ if not validate(uri_template, request["uri"]) or not all(path_args.values()):
+ continue
+
+ # Assign body and query params
+ body = http_option.get("body")
+
+ if body:
+ if body == "*":
+ request["body"] = leftovers
+ request["query_params"] = {}
+ else:
+ try:
+ request["body"] = leftovers.pop(body)
+ except KeyError:
+ continue
+ request["query_params"] = leftovers
+ else:
+ request["query_params"] = leftovers
+ request["method"] = http_option["method"]
+ return request
+
+ raise ValueError("Request obj does not match any template")
diff --git a/google/api_core/rest_helpers.py b/google/api_core/rest_helpers.py
new file mode 100644
index 00000000..23fb614f
--- /dev/null
+++ b/google/api_core/rest_helpers.py
@@ -0,0 +1,94 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+
+"""Helpers for rest transports."""
+
+import functools
+import operator
+
+
+def flatten_query_params(obj):
+ """Flatten a nested dict into a list of (name,value) tuples.
+
+ The result is suitable for setting query params on an http request.
+
+ .. code-block:: python
+
+ >>> obj = {'a':
+ ... {'b':
+ ... {'c': ['x', 'y', 'z']} },
+ ... 'd': 'uvw', }
+ >>> flatten_query_params(obj)
+ [('a.b.c', 'x'), ('a.b.c', 'y'), ('a.b.c', 'z'), ('d', 'uvw')]
+
+ Note that, as described in
+ https://github.com/googleapis/googleapis/blob/48d9fb8c8e287c472af500221c6450ecd45d7d39/google/api/http.proto#L117,
+ repeated fields (i.e. list-valued fields) may only contain primitive types (not lists or dicts).
+ This is enforced in this function.
+
+ Args:
+ obj: a nested dictionary (from json), or None
+
+ Returns: a list of tuples, with each tuple having a (possibly) multi-part name
+ and a scalar value.
+
+ Raises:
+ TypeError if obj is not a dict or None
+ ValueError if obj contains a list of non-primitive values.
+ """
+
+ if obj is not None and not isinstance(obj, dict):
+ raise TypeError("flatten_query_params must be called with dict object")
+
+ return _flatten(obj, key_path=[])
+
+
+def _flatten(obj, key_path):
+ if obj is None:
+ return []
+ if isinstance(obj, dict):
+ return _flatten_dict(obj, key_path=key_path)
+ if isinstance(obj, list):
+ return _flatten_list(obj, key_path=key_path)
+ return _flatten_value(obj, key_path=key_path)
+
+
+def _is_primitive_value(obj):
+ if obj is None:
+ return False
+
+ if isinstance(obj, (list, dict)):
+ raise ValueError("query params may not contain repeated dicts or lists")
+
+ return True
+
+
+def _flatten_value(obj, key_path):
+ return [(".".join(key_path), obj)]
+
+
+def _flatten_dict(obj, key_path):
+ items = (_flatten(value, key_path=key_path + [key]) for key, value in obj.items())
+ return functools.reduce(operator.concat, items, [])
+
+
+def _flatten_list(elems, key_path):
+ # Only lists of scalar values are supported.
+ # The name (key_path) is repeated for each value.
+ items = (
+ _flatten_value(elem, key_path=key_path)
+ for elem in elems
+ if _is_primitive_value(elem)
+ )
+ return functools.reduce(operator.concat, items, [])
diff --git a/google/api_core/version.py b/google/api_core/version.py
index 956a957b..8b5d3328 100644
--- a/google/api_core/version.py
+++ b/google/api_core/version.py
@@ -12,4 +12,4 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-__version__ = "2.0.1"
+__version__ = "2.1.0"
diff --git a/noxfile.py b/noxfile.py
index 6478bfde..617dc580 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -121,7 +121,7 @@ def default(session):
session.run(*pytest_args)
-@nox.session(python=["3.6", "3.7", "3.8", "3.9"])
+@nox.session(python=["3.6", "3.7", "3.8", "3.9", "3.10"])
def unit(session):
"""Run the unit test suite."""
default(session)
diff --git a/owlbot.py b/owlbot.py
index a2f04592..451f7c48 100644
--- a/owlbot.py
+++ b/owlbot.py
@@ -44,49 +44,4 @@
""",
)
-# Remove the replacements below once https://github.com/googleapis/synthtool/pull/1188 is merged
-
-# Update googleapis/repo-automation-bots repo to main in .kokoro/*.sh files
-s.replace(
- ".kokoro/*.sh", "repo-automation-bots/tree/master", "repo-automation-bots/tree/main"
-)
-
-# Customize CONTRIBUTING.rst to replace master with main
-s.replace(
- "CONTRIBUTING.rst",
- "fetch and merge changes from upstream into master",
- "fetch and merge changes from upstream into main",
-)
-
-s.replace(
- "CONTRIBUTING.rst", "git merge upstream/master", "git merge upstream/main",
-)
-
-s.replace(
- "CONTRIBUTING.rst",
- """export GOOGLE_CLOUD_TESTING_BRANCH=\"master\"""",
- """export GOOGLE_CLOUD_TESTING_BRANCH=\"main\"""",
-)
-
-s.replace(
- "CONTRIBUTING.rst", r"remote \(``master``\)", "remote (``main``)",
-)
-
-s.replace(
- "CONTRIBUTING.rst", "blob/master/CONTRIBUTING.rst", "blob/main/CONTRIBUTING.rst",
-)
-
-s.replace(
- "CONTRIBUTING.rst", "blob/master/noxfile.py", "blob/main/noxfile.py",
-)
-
-s.replace(
- "docs/conf.py", "master_doc", "root_doc",
-)
-
-s.replace(
- "docs/conf.py", "# The master toctree document.", "# The root toctree document.",
-)
-
-
s.shell.run(["nox", "-s", "blacken"], hide_output=False)
diff --git a/renovate.json b/renovate.json
index 9fa8816f..c21036d3 100644
--- a/renovate.json
+++ b/renovate.json
@@ -1,6 +1,7 @@
{
"extends": [
"config:base",
+ "group:all",
":preserveSemverRanges",
":disableDependencyDashboard"
],
diff --git a/setup.py b/setup.py
index cade3d82..d150bc01 100644
--- a/setup.py
+++ b/setup.py
@@ -34,7 +34,6 @@
"google-auth >= 1.25.0, < 3.0dev",
"requests >= 2.18.0, < 3.0.0dev",
"setuptools >= 40.3.0",
- 'futures >= 3.2.0; python_version < "3.2"',
]
extras = {
"grpc": "grpcio >= 1.33.2, < 2.0dev",
@@ -88,6 +87,7 @@
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
+ "Programming Language :: Python :: 3.10",
"Operating System :: OS Independent",
"Topic :: Internet",
],
diff --git a/tests/unit/test_grpc_helpers.py b/tests/unit/test_grpc_helpers.py
index 12bf1849..f5849290 100644
--- a/tests/unit/test_grpc_helpers.py
+++ b/tests/unit/test_grpc_helpers.py
@@ -66,6 +66,128 @@ def test_wrap_unary_errors():
assert exc_info.value.response == grpc_error
+class Test_StreamingResponseIterator:
+ @staticmethod
+ def _make_wrapped(*items):
+ return iter(items)
+
+ @staticmethod
+ def _make_one(wrapped, **kw):
+ return grpc_helpers._StreamingResponseIterator(wrapped, **kw)
+
+ def test_ctor_defaults(self):
+ wrapped = self._make_wrapped("a", "b", "c")
+ iterator = self._make_one(wrapped)
+ assert iterator._stored_first_result == "a"
+ assert list(wrapped) == ["b", "c"]
+
+ def test_ctor_explicit(self):
+ wrapped = self._make_wrapped("a", "b", "c")
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+ assert getattr(iterator, "_stored_first_result", self) is self
+ assert list(wrapped) == ["a", "b", "c"]
+
+ def test_ctor_w_rpc_error_on_prefetch(self):
+ wrapped = mock.MagicMock()
+ wrapped.__next__.side_effect = grpc.RpcError()
+
+ with pytest.raises(grpc.RpcError):
+ self._make_one(wrapped)
+
+ def test___iter__(self):
+ wrapped = self._make_wrapped("a", "b", "c")
+ iterator = self._make_one(wrapped)
+ assert iter(iterator) is iterator
+
+ def test___next___w_cached_first_result(self):
+ wrapped = self._make_wrapped("a", "b", "c")
+ iterator = self._make_one(wrapped)
+ assert next(iterator) == "a"
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+ assert next(iterator) == "b"
+ assert next(iterator) == "c"
+
+ def test___next___wo_cached_first_result(self):
+ wrapped = self._make_wrapped("a", "b", "c")
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+ assert next(iterator) == "a"
+ assert next(iterator) == "b"
+ assert next(iterator) == "c"
+
+ def test___next___w_rpc_error(self):
+ wrapped = mock.MagicMock()
+ wrapped.__next__.side_effect = grpc.RpcError()
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+
+ with pytest.raises(exceptions.GoogleAPICallError):
+ next(iterator)
+
+ def test_add_callback(self):
+ wrapped = mock.MagicMock()
+ callback = mock.Mock(spec={})
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+
+ assert iterator.add_callback(callback) is wrapped.add_callback.return_value
+
+ wrapped.add_callback.assert_called_once_with(callback)
+
+ def test_cancel(self):
+ wrapped = mock.MagicMock()
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+
+ assert iterator.cancel() is wrapped.cancel.return_value
+
+ wrapped.cancel.assert_called_once_with()
+
+ def test_code(self):
+ wrapped = mock.MagicMock()
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+
+ assert iterator.code() is wrapped.code.return_value
+
+ wrapped.code.assert_called_once_with()
+
+ def test_details(self):
+ wrapped = mock.MagicMock()
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+
+ assert iterator.details() is wrapped.details.return_value
+
+ wrapped.details.assert_called_once_with()
+
+ def test_initial_metadata(self):
+ wrapped = mock.MagicMock()
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+
+ assert iterator.initial_metadata() is wrapped.initial_metadata.return_value
+
+ wrapped.initial_metadata.assert_called_once_with()
+
+ def test_is_active(self):
+ wrapped = mock.MagicMock()
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+
+ assert iterator.is_active() is wrapped.is_active.return_value
+
+ wrapped.is_active.assert_called_once_with()
+
+ def test_time_remaining(self):
+ wrapped = mock.MagicMock()
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+
+ assert iterator.time_remaining() is wrapped.time_remaining.return_value
+
+ wrapped.time_remaining.assert_called_once_with()
+
+ def test_trailing_metadata(self):
+ wrapped = mock.MagicMock()
+ iterator = self._make_one(wrapped, prefetch_first_result=False)
+
+ assert iterator.trailing_metadata() is wrapped.trailing_metadata.return_value
+
+ wrapped.trailing_metadata.assert_called_once_with()
+
+
def test_wrap_stream_okay():
expected_responses = [1, 2, 3]
callable_ = mock.Mock(spec=["__call__"], return_value=iter(expected_responses))
diff --git a/tests/unit/test_path_template.py b/tests/unit/test_path_template.py
index 4c8a7c5e..2c5216e0 100644
--- a/tests/unit/test_path_template.py
+++ b/tests/unit/test_path_template.py
@@ -84,6 +84,61 @@ def test_expanded_failure(tmpl, args, kwargs, exc_match):
path_template.expand(tmpl, *args, **kwargs)
+@pytest.mark.parametrize(
+ "request_obj, field, expected_result",
+ [
+ [{"field": "stringValue"}, "field", "stringValue"],
+ [{"field": "stringValue"}, "nosuchfield", None],
+ [{"field": "stringValue"}, "field.subfield", None],
+ [{"field": {"subfield": "stringValue"}}, "field", None],
+ [{"field": {"subfield": "stringValue"}}, "field.subfield", "stringValue"],
+ [{"field": {"subfield": [1, 2, 3]}}, "field.subfield", [1, 2, 3]],
+ [{"field": {"subfield": "stringValue"}}, "field", None],
+ [{"field": {"subfield": "stringValue"}}, "field.nosuchfield", None],
+ [
+ {"field": {"subfield": {"subsubfield": "stringValue"}}},
+ "field.subfield.subsubfield",
+ "stringValue",
+ ],
+ ["string", "field", None],
+ ],
+)
+def test_get_field(request_obj, field, expected_result):
+ result = path_template.get_field(request_obj, field)
+ assert result == expected_result
+
+
+@pytest.mark.parametrize(
+ "request_obj, field, expected_result",
+ [
+ [{"field": "stringValue"}, "field", {}],
+ [{"field": "stringValue"}, "nosuchfield", {"field": "stringValue"}],
+ [{"field": "stringValue"}, "field.subfield", {"field": "stringValue"}],
+ [{"field": {"subfield": "stringValue"}}, "field.subfield", {"field": {}}],
+ [
+ {"field": {"subfield": "stringValue", "q": "w"}, "e": "f"},
+ "field.subfield",
+ {"field": {"q": "w"}, "e": "f"},
+ ],
+ [
+ {"field": {"subfield": "stringValue"}},
+ "field.nosuchfield",
+ {"field": {"subfield": "stringValue"}},
+ ],
+ [
+ {"field": {"subfield": {"subsubfield": "stringValue", "q": "w"}}},
+ "field.subfield.subsubfield",
+ {"field": {"subfield": {"q": "w"}}},
+ ],
+ ["string", "field", "string"],
+ ["string", "field.subfield", "string"],
+ ],
+)
+def test_delete_field(request_obj, field, expected_result):
+ path_template.delete_field(request_obj, field)
+ assert request_obj == expected_result
+
+
@pytest.mark.parametrize(
"tmpl, path",
[
@@ -113,3 +168,222 @@ def test__replace_variable_with_pattern():
match.group.return_value = None
with pytest.raises(ValueError, match="Unknown"):
path_template._replace_variable_with_pattern(match)
+
+
+@pytest.mark.parametrize(
+ "http_options, request_kwargs, expected_result",
+ [
+ [
+ [["get", "/v1/no/template", ""]],
+ {"foo": "bar"},
+ ["get", "/v1/no/template", {}, {"foo": "bar"}],
+ ],
+ # Single templates
+ [
+ [["get", "/v1/{field}", ""]],
+ {"field": "parent"},
+ ["get", "/v1/parent", {}, {}],
+ ],
+ [
+ [["get", "/v1/{field.sub}", ""]],
+ {"field": {"sub": "parent"}, "foo": "bar"},
+ ["get", "/v1/parent", {}, {"field": {}, "foo": "bar"}],
+ ],
+ ],
+)
+def test_transcode_base_case(http_options, request_kwargs, expected_result):
+ http_options, expected_result = helper_test_transcode(http_options, expected_result)
+ result = path_template.transcode(http_options, **request_kwargs)
+ assert result == expected_result
+
+
+@pytest.mark.parametrize(
+ "http_options, request_kwargs, expected_result",
+ [
+ [
+ [["get", "/v1/{field.subfield}", ""]],
+ {"field": {"subfield": "parent"}, "foo": "bar"},
+ ["get", "/v1/parent", {}, {"field": {}, "foo": "bar"}],
+ ],
+ [
+ [["get", "/v1/{field.subfield.subsubfield}", ""]],
+ {"field": {"subfield": {"subsubfield": "parent"}}, "foo": "bar"},
+ ["get", "/v1/parent", {}, {"field": {"subfield": {}}, "foo": "bar"}],
+ ],
+ [
+ [["get", "/v1/{field.subfield1}/{field.subfield2}", ""]],
+ {"field": {"subfield1": "parent", "subfield2": "child"}, "foo": "bar"},
+ ["get", "/v1/parent/child", {}, {"field": {}, "foo": "bar"}],
+ ],
+ ],
+)
+def test_transcode_subfields(http_options, request_kwargs, expected_result):
+ http_options, expected_result = helper_test_transcode(http_options, expected_result)
+ result = path_template.transcode(http_options, **request_kwargs)
+ assert result == expected_result
+
+
+@pytest.mark.parametrize(
+ "http_options, request_kwargs, expected_result",
+ [
+ # Single segment wildcard
+ [
+ [["get", "/v1/{field=*}", ""]],
+ {"field": "parent"},
+ ["get", "/v1/parent", {}, {}],
+ ],
+ [
+ [["get", "/v1/{field=a/*/b/*}", ""]],
+ {"field": "a/parent/b/child", "foo": "bar"},
+ ["get", "/v1/a/parent/b/child", {}, {"foo": "bar"}],
+ ],
+ # Double segment wildcard
+ [
+ [["get", "/v1/{field=**}", ""]],
+ {"field": "parent/p1"},
+ ["get", "/v1/parent/p1", {}, {}],
+ ],
+ [
+ [["get", "/v1/{field=a/**/b/**}", ""]],
+ {"field": "a/parent/p1/b/child/c1", "foo": "bar"},
+ ["get", "/v1/a/parent/p1/b/child/c1", {}, {"foo": "bar"}],
+ ],
+ # Combined single and double segment wildcard
+ [
+ [["get", "/v1/{field=a/*/b/**}", ""]],
+ {"field": "a/parent/b/child/c1"},
+ ["get", "/v1/a/parent/b/child/c1", {}, {}],
+ ],
+ [
+ [["get", "/v1/{field=a/**/b/*}/v2/{name}", ""]],
+ {"field": "a/parent/p1/b/child", "name": "first", "foo": "bar"},
+ ["get", "/v1/a/parent/p1/b/child/v2/first", {}, {"foo": "bar"}],
+ ],
+ ],
+)
+def test_transcode_with_wildcard(http_options, request_kwargs, expected_result):
+ http_options, expected_result = helper_test_transcode(http_options, expected_result)
+ result = path_template.transcode(http_options, **request_kwargs)
+ assert result == expected_result
+
+
+@pytest.mark.parametrize(
+ "http_options, request_kwargs, expected_result",
+ [
+ # Single field body
+ [
+ [["post", "/v1/no/template", "data"]],
+ {"data": {"id": 1, "info": "some info"}, "foo": "bar"},
+ ["post", "/v1/no/template", {"id": 1, "info": "some info"}, {"foo": "bar"}],
+ ],
+ [
+ [["post", "/v1/{field=a/*}/b/{name=**}", "data"]],
+ {
+ "field": "a/parent",
+ "name": "first/last",
+ "data": {"id": 1, "info": "some info"},
+ "foo": "bar",
+ },
+ [
+ "post",
+ "/v1/a/parent/b/first/last",
+ {"id": 1, "info": "some info"},
+ {"foo": "bar"},
+ ],
+ ],
+ # Wildcard body
+ [
+ [["post", "/v1/{field=a/*}/b/{name=**}", "*"]],
+ {
+ "field": "a/parent",
+ "name": "first/last",
+ "data": {"id": 1, "info": "some info"},
+ "foo": "bar",
+ },
+ [
+ "post",
+ "/v1/a/parent/b/first/last",
+ {"data": {"id": 1, "info": "some info"}, "foo": "bar"},
+ {},
+ ],
+ ],
+ ],
+)
+def test_transcode_with_body(http_options, request_kwargs, expected_result):
+ http_options, expected_result = helper_test_transcode(http_options, expected_result)
+ result = path_template.transcode(http_options, **request_kwargs)
+ assert result == expected_result
+
+
+@pytest.mark.parametrize(
+ "http_options, request_kwargs, expected_result",
+ [
+ # Additional bindings
+ [
+ [
+ ["post", "/v1/{field=a/*}/b/{name=**}", "extra_data"],
+ ["post", "/v1/{field=a/*}/b/{name=**}", "*"],
+ ],
+ {
+ "field": "a/parent",
+ "name": "first/last",
+ "data": {"id": 1, "info": "some info"},
+ "foo": "bar",
+ },
+ [
+ "post",
+ "/v1/a/parent/b/first/last",
+ {"data": {"id": 1, "info": "some info"}, "foo": "bar"},
+ {},
+ ],
+ ],
+ [
+ [
+ ["get", "/v1/{field=a/*}/b/{name=**}", ""],
+ ["get", "/v1/{field=a/*}/b/first/last", ""],
+ ],
+ {"field": "a/parent", "foo": "bar"},
+ ["get", "/v1/a/parent/b/first/last", {}, {"foo": "bar"}],
+ ],
+ ],
+)
+def test_transcode_with_additional_bindings(
+ http_options, request_kwargs, expected_result
+):
+ http_options, expected_result = helper_test_transcode(http_options, expected_result)
+ result = path_template.transcode(http_options, **request_kwargs)
+ assert result == expected_result
+
+
+@pytest.mark.parametrize(
+ "http_options, request_kwargs",
+ [
+ [[["get", "/v1/{name}", ""]], {"foo": "bar"}],
+ [[["get", "/v1/{name}", ""]], {"name": "first/last"}],
+ [[["get", "/v1/{name=mr/*/*}", ""]], {"name": "first/last"}],
+ [[["post", "/v1/{name}", "data"]], {"name": "first/last"}],
+ ],
+)
+def test_transcode_fails(http_options, request_kwargs):
+ http_options, _ = helper_test_transcode(http_options, range(4))
+ with pytest.raises(ValueError):
+ path_template.transcode(http_options, **request_kwargs)
+
+
+def helper_test_transcode(http_options_list, expected_result_list):
+ http_options = []
+ for opt_list in http_options_list:
+ http_option = {"method": opt_list[0], "uri": opt_list[1]}
+ if opt_list[2]:
+ http_option["body"] = opt_list[2]
+ http_options.append(http_option)
+
+ expected_result = {
+ "method": expected_result_list[0],
+ "uri": expected_result_list[1],
+ "query_params": expected_result_list[3],
+ }
+ if expected_result_list[2]:
+ expected_result["body"] = expected_result_list[2]
+
+ return (http_options, expected_result)
diff --git a/tests/unit/test_rest_helpers.py b/tests/unit/test_rest_helpers.py
new file mode 100644
index 00000000..5932fa55
--- /dev/null
+++ b/tests/unit/test_rest_helpers.py
@@ -0,0 +1,77 @@
+# Copyright 2021 Google LLC
+#
+# 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.
+
+import pytest
+
+from google.api_core import rest_helpers
+
+
+def test_flatten_simple_value():
+ with pytest.raises(TypeError):
+ rest_helpers.flatten_query_params("abc")
+
+
+def test_flatten_list():
+ with pytest.raises(TypeError):
+ rest_helpers.flatten_query_params(["abc", "def"])
+
+
+def test_flatten_none():
+ assert rest_helpers.flatten_query_params(None) == []
+
+
+def test_flatten_empty_dict():
+ assert rest_helpers.flatten_query_params({}) == []
+
+
+def test_flatten_simple_dict():
+ assert rest_helpers.flatten_query_params({"a": "abc", "b": "def"}) == [
+ ("a", "abc"),
+ ("b", "def"),
+ ]
+
+
+def test_flatten_repeated_field():
+ assert rest_helpers.flatten_query_params({"a": ["x", "y", "z", None]}) == [
+ ("a", "x"),
+ ("a", "y"),
+ ("a", "z"),
+ ]
+
+
+def test_flatten_nested_dict():
+ obj = {"a": {"b": {"c": ["x", "y", "z"]}}, "d": {"e": "uvw"}}
+ expected_result = [("a.b.c", "x"), ("a.b.c", "y"), ("a.b.c", "z"), ("d.e", "uvw")]
+
+ assert rest_helpers.flatten_query_params(obj) == expected_result
+
+
+def test_flatten_repeated_dict():
+ obj = {
+ "a": {"b": {"c": [{"v": 1}, {"v": 2}]}},
+ "d": "uvw",
+ }
+
+ with pytest.raises(ValueError):
+ rest_helpers.flatten_query_params(obj)
+
+
+def test_flatten_repeated_list():
+ obj = {
+ "a": {"b": {"c": [["e", "f"], ["g", "h"]]}},
+ "d": "uvw",
+ }
+
+ with pytest.raises(ValueError):
+ rest_helpers.flatten_query_params(obj)