feat(phase1): add image storage utilities
- Create backend/services/image_storage.py with 4 core functions:
- sanitize_filename(): remove unsafe chars, limit to 255 chars, convert to lowercase
- get_unique_filename(): handle collisions with UUID suffix (format: {name}_{uuid8}_{variant}.jpg)
- ensure_image_directories(): create /images/ root and category subdirs on startup
- save_image(): save bytes to /images/{category}/{filename}, returns relative path
- Create comprehensive test suite (22 tests) covering all functionality
- Integrate ensure_image_directories() into FastAPI startup event
- Directory structure: /images/{category}/{filename}
- Collision handling: auto-suffix with UUID if filename exists
- All tests passing, pathlib.Path for safe operations
This commit is contained in:
57
venv/lib/python3.12/site-packages/google/auth/__init__.py
Normal file
57
venv/lib/python3.12/site-packages/google/auth/__init__.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Google Auth Library for Python."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from google.auth import version as google_auth_version
|
||||
from google.auth._default import (
|
||||
default,
|
||||
load_credentials_from_dict,
|
||||
load_credentials_from_file,
|
||||
)
|
||||
|
||||
|
||||
__version__ = google_auth_version.__version__
|
||||
|
||||
|
||||
__all__ = ["default", "load_credentials_from_file", "load_credentials_from_dict"]
|
||||
|
||||
|
||||
class Python37DeprecationWarning(DeprecationWarning): # pragma: NO COVER
|
||||
"""
|
||||
Deprecation warning raised when Python 3.7 runtime is detected.
|
||||
Python 3.7 support will be dropped after January 1, 2024.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Raise warnings for deprecated versions
|
||||
eol_message = (
|
||||
"You are using a Python version {} past its end of life. Google will update "
|
||||
"google-auth with critical bug fixes on a best-effort basis, but not "
|
||||
"with any other fixes or features. Please upgrade your Python version, "
|
||||
"and then update google-auth."
|
||||
)
|
||||
if sys.version_info.major == 3 and sys.version_info.minor == 8: # pragma: NO COVER
|
||||
warnings.warn(eol_message.format("3.8"), FutureWarning)
|
||||
elif sys.version_info.major == 3 and sys.version_info.minor == 9: # pragma: NO COVER
|
||||
warnings.warn(eol_message.format("3.9"), FutureWarning)
|
||||
|
||||
# Set default logging handler to avoid "No handler found" warnings.
|
||||
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
||||
@@ -0,0 +1,272 @@
|
||||
# Copyright 2025 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 Agent Identity credentials."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from google.auth import environment_vars
|
||||
from google.auth import exceptions
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CRYPTOGRAPHY_NOT_FOUND_ERROR = (
|
||||
"The cryptography library is required for certificate-based authentication."
|
||||
"Please install it with `pip install google-auth[cryptography]`."
|
||||
)
|
||||
|
||||
# SPIFFE trust domain patterns for Agent Identities.
|
||||
_AGENT_IDENTITY_SPIFFE_TRUST_DOMAIN_PATTERNS = [
|
||||
r"^agents\.global\.org-\d+\.system\.id\.goog$",
|
||||
r"^agents\.global\.proj-\d+\.system\.id\.goog$",
|
||||
]
|
||||
|
||||
_WELL_KNOWN_CERT_PATH = "/var/run/secrets/workload-spiffe-credentials/certificates.pem"
|
||||
|
||||
# Constants for polling the certificate file.
|
||||
_FAST_POLL_CYCLES = 50
|
||||
_FAST_POLL_INTERVAL = 0.1 # 100ms
|
||||
_SLOW_POLL_INTERVAL = 0.5 # 500ms
|
||||
_TOTAL_TIMEOUT = 30 # seconds
|
||||
|
||||
# Calculate the number of slow poll cycles based on the total timeout.
|
||||
_SLOW_POLL_CYCLES = int(
|
||||
(_TOTAL_TIMEOUT - (_FAST_POLL_CYCLES * _FAST_POLL_INTERVAL)) / _SLOW_POLL_INTERVAL
|
||||
)
|
||||
|
||||
_POLLING_INTERVALS = ([_FAST_POLL_INTERVAL] * _FAST_POLL_CYCLES) + (
|
||||
[_SLOW_POLL_INTERVAL] * _SLOW_POLL_CYCLES
|
||||
)
|
||||
|
||||
|
||||
def _is_certificate_file_ready(path):
|
||||
"""Checks if a file exists and is not empty."""
|
||||
return path and os.path.exists(path) and os.path.getsize(path) > 0
|
||||
|
||||
|
||||
def get_agent_identity_certificate_path():
|
||||
"""Gets the certificate path from the certificate config file.
|
||||
|
||||
The path to the certificate config file is read from the
|
||||
GOOGLE_API_CERTIFICATE_CONFIG environment variable. This function
|
||||
implements a retry mechanism to handle cases where the environment
|
||||
variable is set before the files are available on the filesystem.
|
||||
|
||||
Returns:
|
||||
str: The path to the leaf certificate file.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the certificate config file
|
||||
or the certificate file cannot be found after retries.
|
||||
"""
|
||||
import json
|
||||
|
||||
cert_config_path = os.environ.get(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG)
|
||||
if not cert_config_path:
|
||||
return None
|
||||
|
||||
has_logged_warning = False
|
||||
|
||||
for interval in _POLLING_INTERVALS:
|
||||
try:
|
||||
with open(cert_config_path, "r") as f:
|
||||
cert_config = json.load(f)
|
||||
cert_path = (
|
||||
cert_config.get("cert_configs", {})
|
||||
.get("workload", {})
|
||||
.get("cert_path")
|
||||
)
|
||||
if _is_certificate_file_ready(cert_path):
|
||||
return cert_path
|
||||
except (IOError, ValueError, KeyError):
|
||||
if not has_logged_warning:
|
||||
_LOGGER.warning(
|
||||
"Certificate config file not found at %s (from %s environment "
|
||||
"variable). Retrying for up to %s seconds.",
|
||||
cert_config_path,
|
||||
environment_vars.GOOGLE_API_CERTIFICATE_CONFIG,
|
||||
_TOTAL_TIMEOUT,
|
||||
)
|
||||
has_logged_warning = True
|
||||
pass
|
||||
|
||||
# As a fallback, check the well-known certificate path.
|
||||
if _is_certificate_file_ready(_WELL_KNOWN_CERT_PATH):
|
||||
return _WELL_KNOWN_CERT_PATH
|
||||
|
||||
# A sleep is required in two cases:
|
||||
# 1. The config file is not found (the except block).
|
||||
# 2. The config file is found, but the certificate is not yet available.
|
||||
# In both cases, we need to poll, so we sleep on every iteration
|
||||
# that doesn't return a certificate.
|
||||
time.sleep(interval)
|
||||
|
||||
raise exceptions.RefreshError(
|
||||
"Certificate config or certificate file not found after multiple retries. "
|
||||
f"Token binding protection is failing. You can turn off this protection by setting "
|
||||
f"{environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES} to false "
|
||||
"to fall back to unbound tokens."
|
||||
)
|
||||
|
||||
|
||||
def get_and_parse_agent_identity_certificate():
|
||||
"""Gets and parses the agent identity certificate if not opted out.
|
||||
|
||||
Checks if the user has opted out of certificate-bound tokens. If not,
|
||||
it gets the certificate path, reads the file, and parses it.
|
||||
|
||||
Returns:
|
||||
The parsed certificate object if found and not opted out, otherwise None.
|
||||
"""
|
||||
# If the user has opted out of cert bound tokens, there is no need to
|
||||
# look up the certificate.
|
||||
is_opted_out = (
|
||||
os.environ.get(
|
||||
environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES,
|
||||
"true",
|
||||
).lower()
|
||||
== "false"
|
||||
)
|
||||
if is_opted_out:
|
||||
return None
|
||||
|
||||
cert_path = get_agent_identity_certificate_path()
|
||||
if not cert_path:
|
||||
return None
|
||||
|
||||
with open(cert_path, "rb") as cert_file:
|
||||
cert_bytes = cert_file.read()
|
||||
|
||||
return parse_certificate(cert_bytes)
|
||||
|
||||
|
||||
def parse_certificate(cert_bytes):
|
||||
"""Parses a PEM-encoded certificate.
|
||||
|
||||
Args:
|
||||
cert_bytes (bytes): The PEM-encoded certificate bytes.
|
||||
|
||||
Returns:
|
||||
cryptography.x509.Certificate: The parsed certificate object.
|
||||
"""
|
||||
try:
|
||||
from cryptography import x509
|
||||
|
||||
return x509.load_pem_x509_certificate(cert_bytes)
|
||||
except ImportError as e:
|
||||
raise ImportError(CRYPTOGRAPHY_NOT_FOUND_ERROR) from e
|
||||
|
||||
|
||||
def _is_agent_identity_certificate(cert):
|
||||
"""Checks if a certificate is an Agent Identity certificate.
|
||||
|
||||
This is determined by checking the Subject Alternative Name (SAN) for a
|
||||
SPIFFE ID with a trust domain matching Agent Identity patterns.
|
||||
|
||||
Args:
|
||||
cert (cryptography.x509.Certificate): The parsed certificate object.
|
||||
|
||||
Returns:
|
||||
bool: True if the certificate is an Agent Identity certificate,
|
||||
False otherwise.
|
||||
"""
|
||||
try:
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import ExtensionOID
|
||||
|
||||
try:
|
||||
ext = cert.extensions.get_extension_for_oid(
|
||||
ExtensionOID.SUBJECT_ALTERNATIVE_NAME
|
||||
)
|
||||
except x509.ExtensionNotFound:
|
||||
return False
|
||||
uris = ext.value.get_values_for_type(x509.UniformResourceIdentifier)
|
||||
|
||||
for uri in uris:
|
||||
parsed_uri = urlparse(uri)
|
||||
if parsed_uri.scheme == "spiffe":
|
||||
trust_domain = parsed_uri.netloc
|
||||
for pattern in _AGENT_IDENTITY_SPIFFE_TRUST_DOMAIN_PATTERNS:
|
||||
if re.match(pattern, trust_domain):
|
||||
return True
|
||||
return False
|
||||
except ImportError as e:
|
||||
raise ImportError(CRYPTOGRAPHY_NOT_FOUND_ERROR) from e
|
||||
|
||||
|
||||
def calculate_certificate_fingerprint(cert):
|
||||
"""Calculates the URL-encoded, unpadded, base64-encoded SHA256 hash of a
|
||||
DER-encoded certificate.
|
||||
|
||||
Args:
|
||||
cert (cryptography.x509.Certificate): The parsed certificate object.
|
||||
|
||||
Returns:
|
||||
str: The URL-encoded, unpadded, base64-encoded SHA256 fingerprint.
|
||||
"""
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
der_cert = cert.public_bytes(serialization.Encoding.DER)
|
||||
fingerprint = hashlib.sha256(der_cert).digest()
|
||||
# The certificate fingerprint is generated in two steps to align with GFE's
|
||||
# expectations and ensure proper URL transmission:
|
||||
# 1. Standard base64 encoding is applied, and padding ('=') is removed.
|
||||
# 2. The resulting string is then URL-encoded to handle special characters
|
||||
# ('+', '/') that would otherwise be misinterpreted in URL parameters.
|
||||
base64_fingerprint = base64.b64encode(fingerprint).decode("utf-8")
|
||||
unpadded_base64_fingerprint = base64_fingerprint.rstrip("=")
|
||||
return quote(unpadded_base64_fingerprint)
|
||||
except ImportError as e:
|
||||
raise ImportError(CRYPTOGRAPHY_NOT_FOUND_ERROR) from e
|
||||
|
||||
|
||||
def should_request_bound_token(cert):
|
||||
"""Determines if a bound token should be requested.
|
||||
|
||||
This is based on the GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES
|
||||
environment variable and whether the certificate is an agent identity cert.
|
||||
|
||||
Args:
|
||||
cert (cryptography.x509.Certificate): The parsed certificate object.
|
||||
|
||||
Returns:
|
||||
bool: True if a bound token should be requested, False otherwise.
|
||||
"""
|
||||
is_agent_cert = _is_agent_identity_certificate(cert)
|
||||
is_opted_in = (
|
||||
os.environ.get(
|
||||
environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES,
|
||||
"true",
|
||||
).lower()
|
||||
== "true"
|
||||
)
|
||||
return is_agent_cert and is_opted_in
|
||||
|
||||
|
||||
def get_cached_cert_fingerprint(cached_cert):
|
||||
"""Returns the fingerprint of the cached certificate."""
|
||||
if cached_cert:
|
||||
cert_obj = parse_certificate(cached_cert)
|
||||
cached_cert_fingerprint = calculate_certificate_fingerprint(cert_obj)
|
||||
else:
|
||||
raise ValueError("mTLS connection is not configured.")
|
||||
return cached_cert_fingerprint
|
||||
64
venv/lib/python3.12/site-packages/google/auth/_cache.py
Normal file
64
venv/lib/python3.12/site-packages/google/auth/_cache.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# Copyright 2025 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
|
||||
#
|
||||
# https://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 collections import OrderedDict
|
||||
|
||||
|
||||
class LRUCache(dict):
|
||||
def __init__(self, maxsize):
|
||||
super().__init__()
|
||||
self._order = OrderedDict()
|
||||
self.maxsize = maxsize
|
||||
|
||||
def clear(self):
|
||||
super().clear()
|
||||
self._order.clear()
|
||||
|
||||
def get(self, key, default=None):
|
||||
try:
|
||||
value = super().__getitem__(key)
|
||||
self._update(key)
|
||||
return value
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def __getitem__(self, key):
|
||||
value = super().__getitem__(key)
|
||||
self._update(key)
|
||||
return value
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
maxsize = self.maxsize
|
||||
if maxsize <= 0:
|
||||
return
|
||||
if key not in self:
|
||||
while len(self) >= maxsize:
|
||||
self.popitem()
|
||||
super().__setitem__(key, value)
|
||||
self._update(key)
|
||||
|
||||
def __delitem__(self, key):
|
||||
super().__delitem__(key)
|
||||
del self._order[key]
|
||||
|
||||
def popitem(self):
|
||||
"""Remove and return the least recently used key-value pair."""
|
||||
key, _ = self._order.popitem(last=False)
|
||||
return key, super().pop(key)
|
||||
|
||||
def _update(self, key):
|
||||
try:
|
||||
self._order.move_to_end(key)
|
||||
except KeyError:
|
||||
self._order[key] = None
|
||||
153
venv/lib/python3.12/site-packages/google/auth/_cloud_sdk.py
Normal file
153
venv/lib/python3.12/site-packages/google/auth/_cloud_sdk.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# Copyright 2015 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.
|
||||
|
||||
"""Helpers for reading the Google Cloud SDK's configuration."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import environment_vars
|
||||
from google.auth import exceptions
|
||||
|
||||
|
||||
# The ~/.config subdirectory containing gcloud credentials.
|
||||
_CONFIG_DIRECTORY = "gcloud"
|
||||
# Windows systems store config at %APPDATA%\gcloud
|
||||
_WINDOWS_CONFIG_ROOT_ENV_VAR = "APPDATA"
|
||||
# The name of the file in the Cloud SDK config that contains default
|
||||
# credentials.
|
||||
_CREDENTIALS_FILENAME = "application_default_credentials.json"
|
||||
# The name of the Cloud SDK shell script
|
||||
_CLOUD_SDK_POSIX_COMMAND = "gcloud"
|
||||
_CLOUD_SDK_WINDOWS_COMMAND = "gcloud.cmd"
|
||||
# The command to get the Cloud SDK configuration
|
||||
_CLOUD_SDK_CONFIG_GET_PROJECT_COMMAND = ("config", "get", "project")
|
||||
# The command to get google user access token
|
||||
_CLOUD_SDK_USER_ACCESS_TOKEN_COMMAND = ("auth", "print-access-token")
|
||||
# Cloud SDK's application-default client ID
|
||||
CLOUD_SDK_CLIENT_ID = (
|
||||
"764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com"
|
||||
)
|
||||
|
||||
|
||||
def get_config_path():
|
||||
"""Returns the absolute path the the Cloud SDK's configuration directory.
|
||||
|
||||
Returns:
|
||||
str: The Cloud SDK config path.
|
||||
"""
|
||||
# If the path is explicitly set, return that.
|
||||
try:
|
||||
return os.environ[environment_vars.CLOUD_SDK_CONFIG_DIR]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Non-windows systems store this at ~/.config/gcloud
|
||||
if os.name != "nt":
|
||||
return os.path.join(os.path.expanduser("~"), ".config", _CONFIG_DIRECTORY)
|
||||
# Windows systems store config at %APPDATA%\gcloud
|
||||
else:
|
||||
try:
|
||||
return os.path.join(
|
||||
os.environ[_WINDOWS_CONFIG_ROOT_ENV_VAR], _CONFIG_DIRECTORY
|
||||
)
|
||||
except KeyError:
|
||||
# This should never happen unless someone is really
|
||||
# messing with things, but we'll cover the case anyway.
|
||||
drive = os.environ.get("SystemDrive", "C:")
|
||||
return os.path.join(drive, "\\", _CONFIG_DIRECTORY)
|
||||
|
||||
|
||||
def get_application_default_credentials_path():
|
||||
"""Gets the path to the application default credentials file.
|
||||
|
||||
The path may or may not exist.
|
||||
|
||||
Returns:
|
||||
str: The full path to application default credentials.
|
||||
"""
|
||||
config_path = get_config_path()
|
||||
return os.path.join(config_path, _CREDENTIALS_FILENAME)
|
||||
|
||||
|
||||
def _run_subprocess_ignore_stderr(command):
|
||||
"""Return subprocess.check_output with the given command and ignores stderr."""
|
||||
with open(os.devnull, "w") as devnull:
|
||||
output = subprocess.check_output(command, stderr=devnull)
|
||||
return output
|
||||
|
||||
|
||||
def get_project_id():
|
||||
"""Gets the project ID from the Cloud SDK.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The project ID.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
command = _CLOUD_SDK_WINDOWS_COMMAND
|
||||
else:
|
||||
command = _CLOUD_SDK_POSIX_COMMAND
|
||||
|
||||
try:
|
||||
# Ignore the stderr coming from gcloud, so it won't be mixed into the output.
|
||||
# https://github.com/googleapis/google-auth-library-python/issues/673
|
||||
project = _run_subprocess_ignore_stderr(
|
||||
(command,) + _CLOUD_SDK_CONFIG_GET_PROJECT_COMMAND
|
||||
)
|
||||
|
||||
# Turn bytes into a string and remove "\n"
|
||||
project = _helpers.from_bytes(project).strip()
|
||||
return project if project else None
|
||||
except (subprocess.CalledProcessError, OSError, IOError):
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_access_token(account=None):
|
||||
"""Load user access token with the ``gcloud auth print-access-token`` command.
|
||||
|
||||
Args:
|
||||
account (Optional[str]): Account to get the access token for. If not
|
||||
specified, the current active account will be used.
|
||||
|
||||
Returns:
|
||||
str: The user access token.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.UserAccessTokenError: if failed to get access
|
||||
token from gcloud.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
command = _CLOUD_SDK_WINDOWS_COMMAND
|
||||
else:
|
||||
command = _CLOUD_SDK_POSIX_COMMAND
|
||||
|
||||
try:
|
||||
if account:
|
||||
command = (
|
||||
(command,)
|
||||
+ _CLOUD_SDK_USER_ACCESS_TOKEN_COMMAND
|
||||
+ ("--account=" + account,)
|
||||
)
|
||||
else:
|
||||
command = (command,) + _CLOUD_SDK_USER_ACCESS_TOKEN_COMMAND
|
||||
|
||||
access_token = subprocess.check_output(command, stderr=subprocess.STDOUT)
|
||||
# remove the trailing "\n"
|
||||
return access_token.decode("utf-8").strip()
|
||||
except (subprocess.CalledProcessError, OSError, IOError) as caught_exc:
|
||||
new_exc = exceptions.UserAccessTokenError(
|
||||
"Failed to obtain access token", caught_exc
|
||||
)
|
||||
raise new_exc from caught_exc
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Shared constants."""
|
||||
|
||||
_SERVICE_ACCOUNT_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{service_account_email}/allowedLocations"
|
||||
_WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/locations/global/workforcePools/{pool_id}/allowedLocations"
|
||||
_WORKLOAD_IDENTITY_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT = "https://iamcredentials.{universe_domain}/v1/projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/allowedLocations"
|
||||
@@ -0,0 +1,171 @@
|
||||
# Copyright 2020 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.
|
||||
|
||||
|
||||
"""Interfaces for credentials."""
|
||||
|
||||
import abc
|
||||
import inspect
|
||||
|
||||
from google.auth import credentials
|
||||
|
||||
|
||||
class Credentials(credentials.Credentials, metaclass=abc.ABCMeta):
|
||||
"""Async inherited credentials class from google.auth.credentials.
|
||||
The added functionality is the before_request call which requires
|
||||
async/await syntax.
|
||||
All credentials have a :attr:`token` that is used for authentication and
|
||||
may also optionally set an :attr:`expiry` to indicate when the token will
|
||||
no longer be valid.
|
||||
|
||||
Most credentials will be :attr:`invalid` until :meth:`refresh` is called.
|
||||
Credentials can do this automatically before the first HTTP request in
|
||||
:meth:`before_request`.
|
||||
|
||||
Although the token and expiration will change as the credentials are
|
||||
:meth:`refreshed <refresh>` and used, credentials should be considered
|
||||
immutable. Various credentials will accept configuration such as private
|
||||
keys, scopes, and other options. These options are not changeable after
|
||||
construction. Some classes will provide mechanisms to copy the credentials
|
||||
with modifications such as :meth:`ScopedCredentials.with_scopes`.
|
||||
"""
|
||||
|
||||
async def before_request(self, request, method, url, headers):
|
||||
"""Performs credential-specific before request logic.
|
||||
|
||||
Refreshes the credentials if necessary, then calls :meth:`apply` to
|
||||
apply the token to the authentication header.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
method (str): The request's HTTP method or the RPC method being
|
||||
invoked.
|
||||
url (str): The request's URI or the RPC service's URI.
|
||||
headers (Mapping): The request's headers.
|
||||
"""
|
||||
# pylint: disable=unused-argument
|
||||
# (Subclasses may use these arguments to ascertain information about
|
||||
# the http request.)
|
||||
|
||||
if not self.valid:
|
||||
if inspect.iscoroutinefunction(self.refresh):
|
||||
await self.refresh(request)
|
||||
else:
|
||||
self.refresh(request)
|
||||
self.apply(headers)
|
||||
|
||||
|
||||
class CredentialsWithQuotaProject(credentials.CredentialsWithQuotaProject):
|
||||
"""Abstract base for credentials supporting ``with_quota_project`` factory"""
|
||||
|
||||
|
||||
class AnonymousCredentials(credentials.AnonymousCredentials, Credentials):
|
||||
"""Credentials that do not provide any authentication information.
|
||||
|
||||
These are useful in the case of services that support anonymous access or
|
||||
local service emulators that do not use credentials. This class inherits
|
||||
from the sync anonymous credentials file, but is kept if async credentials
|
||||
is initialized and we would like anonymous credentials.
|
||||
"""
|
||||
|
||||
|
||||
class ReadOnlyScoped(credentials.ReadOnlyScoped, metaclass=abc.ABCMeta):
|
||||
"""Interface for credentials whose scopes can be queried.
|
||||
|
||||
OAuth 2.0-based credentials allow limiting access using scopes as described
|
||||
in `RFC6749 Section 3.3`_.
|
||||
If a credential class implements this interface then the credentials either
|
||||
use scopes in their implementation.
|
||||
|
||||
Some credentials require scopes in order to obtain a token. You can check
|
||||
if scoping is necessary with :attr:`requires_scopes`::
|
||||
|
||||
if credentials.requires_scopes:
|
||||
# Scoping is required.
|
||||
credentials = _credentials_async.with_scopes(scopes=['one', 'two'])
|
||||
|
||||
Credentials that require scopes must either be constructed with scopes::
|
||||
|
||||
credentials = SomeScopedCredentials(scopes=['one', 'two'])
|
||||
|
||||
Or must copy an existing instance using :meth:`with_scopes`::
|
||||
|
||||
scoped_credentials = _credentials_async.with_scopes(scopes=['one', 'two'])
|
||||
|
||||
Some credentials have scopes but do not allow or require scopes to be set,
|
||||
these credentials can be used as-is.
|
||||
|
||||
.. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3
|
||||
"""
|
||||
|
||||
|
||||
class Scoped(credentials.Scoped):
|
||||
"""Interface for credentials whose scopes can be replaced while copying.
|
||||
|
||||
OAuth 2.0-based credentials allow limiting access using scopes as described
|
||||
in `RFC6749 Section 3.3`_.
|
||||
If a credential class implements this interface then the credentials either
|
||||
use scopes in their implementation.
|
||||
|
||||
Some credentials require scopes in order to obtain a token. You can check
|
||||
if scoping is necessary with :attr:`requires_scopes`::
|
||||
|
||||
if credentials.requires_scopes:
|
||||
# Scoping is required.
|
||||
credentials = _credentials_async.create_scoped(['one', 'two'])
|
||||
|
||||
Credentials that require scopes must either be constructed with scopes::
|
||||
|
||||
credentials = SomeScopedCredentials(scopes=['one', 'two'])
|
||||
|
||||
Or must copy an existing instance using :meth:`with_scopes`::
|
||||
|
||||
scoped_credentials = credentials.with_scopes(scopes=['one', 'two'])
|
||||
|
||||
Some credentials have scopes but do not allow or require scopes to be set,
|
||||
these credentials can be used as-is.
|
||||
|
||||
.. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3
|
||||
"""
|
||||
|
||||
|
||||
def with_scopes_if_required(credentials, scopes):
|
||||
"""Creates a copy of the credentials with scopes if scoping is required.
|
||||
|
||||
This helper function is useful when you do not know (or care to know) the
|
||||
specific type of credentials you are using (such as when you use
|
||||
:func:`google.auth.default`). This function will call
|
||||
:meth:`Scoped.with_scopes` if the credentials are scoped credentials and if
|
||||
the credentials require scoping. Otherwise, it will return the credentials
|
||||
as-is.
|
||||
|
||||
Args:
|
||||
credentials (google.auth.credentials.Credentials): The credentials to
|
||||
scope if necessary.
|
||||
scopes (Sequence[str]): The list of scopes to use.
|
||||
|
||||
Returns:
|
||||
google.auth._credentials_async.Credentials: Either a new set of scoped
|
||||
credentials, or the passed in credentials instance if no scoping
|
||||
was required.
|
||||
"""
|
||||
if isinstance(credentials, Scoped) and credentials.requires_scopes:
|
||||
return credentials.with_scopes(scopes)
|
||||
else:
|
||||
return credentials
|
||||
|
||||
|
||||
class Signing(credentials.Signing, metaclass=abc.ABCMeta):
|
||||
"""Interface for credentials that can cryptographically sign messages."""
|
||||
@@ -0,0 +1,75 @@
|
||||
# Copyright 2024 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.
|
||||
|
||||
|
||||
"""Interface for base credentials."""
|
||||
|
||||
import abc
|
||||
|
||||
from google.auth import _helpers
|
||||
|
||||
|
||||
class _BaseCredentials(metaclass=abc.ABCMeta):
|
||||
"""Base class for all credentials.
|
||||
|
||||
All credentials have a :attr:`token` that is used for authentication and
|
||||
may also optionally set an :attr:`expiry` to indicate when the token will
|
||||
no longer be valid.
|
||||
|
||||
Most credentials will be :attr:`invalid` until :meth:`refresh` is called.
|
||||
Credentials can do this automatically before the first HTTP request in
|
||||
:meth:`before_request`.
|
||||
|
||||
Although the token and expiration will change as the credentials are
|
||||
:meth:`refreshed <refresh>` and used, credentials should be considered
|
||||
immutable. Various credentials will accept configuration such as private
|
||||
keys, scopes, and other options. These options are not changeable after
|
||||
construction. Some classes will provide mechanisms to copy the credentials
|
||||
with modifications such as :meth:`ScopedCredentials.with_scopes`.
|
||||
|
||||
Attributes:
|
||||
token (Optional[str]): The bearer token that can be used in HTTP headers to make
|
||||
authenticated requests.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.token = None
|
||||
|
||||
@abc.abstractmethod
|
||||
def refresh(self, request):
|
||||
"""Refreshes the access token.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the credentials could
|
||||
not be refreshed.
|
||||
"""
|
||||
# pylint: disable=missing-raises-doc
|
||||
# (pylint doesn't recognize that this is abstract)
|
||||
raise NotImplementedError("Refresh must be implemented")
|
||||
|
||||
def _apply(self, headers, token=None):
|
||||
"""Apply the token to the authentication header.
|
||||
|
||||
Args:
|
||||
headers (Mapping): The HTTP request headers.
|
||||
token (Optional[str]): If specified, overrides the current access
|
||||
token.
|
||||
"""
|
||||
headers["authorization"] = "Bearer {}".format(
|
||||
_helpers.from_bytes(token or self.token)
|
||||
)
|
||||
748
venv/lib/python3.12/site-packages/google/auth/_default.py
Normal file
748
venv/lib/python3.12/site-packages/google/auth/_default.py
Normal file
@@ -0,0 +1,748 @@
|
||||
# Copyright 2015 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.
|
||||
|
||||
"""Application default credentials.
|
||||
|
||||
Implements application default credentials and project ID detection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Optional, Sequence, TYPE_CHECKING
|
||||
import warnings
|
||||
|
||||
from google.auth import environment_vars
|
||||
from google.auth import exceptions
|
||||
|
||||
if TYPE_CHECKING: # pragma: NO COVER
|
||||
import google.auth.credentials.Credentials # type: ignore
|
||||
import google.auth.transport.Request # type: ignore
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Valid types accepted for file-based credentials.
|
||||
_AUTHORIZED_USER_TYPE = "authorized_user"
|
||||
_SERVICE_ACCOUNT_TYPE = "service_account"
|
||||
_EXTERNAL_ACCOUNT_TYPE = "external_account"
|
||||
_EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE = "external_account_authorized_user"
|
||||
_IMPERSONATED_SERVICE_ACCOUNT_TYPE = "impersonated_service_account"
|
||||
_GDCH_SERVICE_ACCOUNT_TYPE = "gdch_service_account"
|
||||
_VALID_TYPES = (
|
||||
_AUTHORIZED_USER_TYPE,
|
||||
_SERVICE_ACCOUNT_TYPE,
|
||||
_EXTERNAL_ACCOUNT_TYPE,
|
||||
_EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE,
|
||||
_IMPERSONATED_SERVICE_ACCOUNT_TYPE,
|
||||
_GDCH_SERVICE_ACCOUNT_TYPE,
|
||||
)
|
||||
|
||||
# Help message when no credentials can be found.
|
||||
_CLOUD_SDK_MISSING_CREDENTIALS = """\
|
||||
Your default credentials were not found. To set up Application Default Credentials, \
|
||||
see https://cloud.google.com/docs/authentication/external/set-up-adc for more information.\
|
||||
"""
|
||||
|
||||
# Warning when using Cloud SDK user credentials
|
||||
_CLOUD_SDK_CREDENTIALS_WARNING = """\
|
||||
Your application has authenticated using end user credentials from Google \
|
||||
Cloud SDK without a quota project. You might receive a "quota exceeded" \
|
||||
or "API not enabled" error. See the following page for troubleshooting: \
|
||||
https://cloud.google.com/docs/authentication/adc-troubleshooting/user-creds. \
|
||||
"""
|
||||
|
||||
_GENERIC_LOAD_METHOD_WARNING = """\
|
||||
The {} method is deprecated because of a potential security risk.
|
||||
|
||||
This method does not validate the credential configuration. The security
|
||||
risk occurs when a credential configuration is accepted from a source that
|
||||
is not under your control and used without validation on your side.
|
||||
|
||||
If you know that you will be loading credential configurations of a
|
||||
specific type, it is recommended to use a credential-type-specific
|
||||
load method.
|
||||
This will ensure that an unexpected credential type with potential for
|
||||
malicious intent is not loaded unintentionally. You might still have to do
|
||||
validation for certain credential types. Please follow the recommendations
|
||||
for that method. For example, if you want to load only service accounts,
|
||||
you can create the service account credentials explicitly:
|
||||
|
||||
```
|
||||
from google.oauth2 import service_account
|
||||
creds = service_account.Credentials.from_service_account_file(filename)
|
||||
```
|
||||
|
||||
If you are loading your credential configuration from an untrusted source and have
|
||||
not mitigated the risks (e.g. by validating the configuration yourself), make
|
||||
these changes as soon as possible to prevent security risks to your environment.
|
||||
|
||||
Regardless of the method used, it is always your responsibility to validate
|
||||
configurations received from external sources.
|
||||
|
||||
Refer to https://cloud.google.com/docs/authentication/external/externally-sourced-credentials
|
||||
for more details.
|
||||
"""
|
||||
|
||||
# The subject token type used for AWS external_account credentials.
|
||||
_AWS_SUBJECT_TOKEN_TYPE = "urn:ietf:params:aws:token-type:aws4_request"
|
||||
|
||||
|
||||
def _warn_about_problematic_credentials(credentials):
|
||||
"""Determines if the credentials are problematic.
|
||||
|
||||
Credentials from the Cloud SDK that are associated with Cloud SDK's project
|
||||
are problematic because they may not have APIs enabled and have limited
|
||||
quota. If this is the case, warn about it.
|
||||
"""
|
||||
from google.auth import _cloud_sdk
|
||||
|
||||
if credentials.client_id == _cloud_sdk.CLOUD_SDK_CLIENT_ID:
|
||||
warnings.warn(_CLOUD_SDK_CREDENTIALS_WARNING)
|
||||
|
||||
|
||||
def _warn_about_generic_load_method(method_name): # pragma: NO COVER
|
||||
"""Warns that a generic load method is being used.
|
||||
|
||||
This is to discourage use of the generic load methods in favor of
|
||||
more specific methods. The generic methods are more likely to lead to
|
||||
security issues if the input is not validated.
|
||||
|
||||
Args:
|
||||
method_name (str): The name of the method being used.
|
||||
"""
|
||||
|
||||
warnings.warn(_GENERIC_LOAD_METHOD_WARNING.format(method_name), DeprecationWarning)
|
||||
|
||||
|
||||
def load_credentials_from_file(
|
||||
filename, scopes=None, default_scopes=None, quota_project_id=None, request=None
|
||||
):
|
||||
"""Loads Google credentials from a file.
|
||||
|
||||
The credentials file must be a service account key, stored authorized
|
||||
user credentials, external account credentials, or impersonated service
|
||||
account credentials.
|
||||
|
||||
.. warning::
|
||||
Important: If you accept a credential configuration (credential JSON/File/Stream)
|
||||
from an external source for authentication to Google Cloud Platform, you must
|
||||
validate it before providing it to any Google API or client library. Providing an
|
||||
unvalidated credential configuration to Google APIs or libraries can compromise
|
||||
the security of your systems and data. For more information, refer to
|
||||
`Validate credential configurations from external sources`_.
|
||||
|
||||
.. _Validate credential configurations from external sources:
|
||||
https://cloud.google.com/docs/authentication/external/externally-sourced-credentials
|
||||
|
||||
Args:
|
||||
filename (str): The full path to the credentials file.
|
||||
scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If
|
||||
specified, the credentials will automatically be scoped if
|
||||
necessary
|
||||
default_scopes (Optional[Sequence[str]]): Default scopes passed by a
|
||||
Google client library. Use 'scopes' for user-defined scopes.
|
||||
quota_project_id (Optional[str]): The project ID used for
|
||||
quota and billing.
|
||||
request (Optional[google.auth.transport.Request]): An object used to make
|
||||
HTTP requests. This is used to determine the associated project ID
|
||||
for a workload identity pool resource (external account credentials).
|
||||
If not specified, then it will use a
|
||||
google.auth.transport.requests.Request client to make requests.
|
||||
|
||||
Returns:
|
||||
Tuple[google.auth.credentials.Credentials, Optional[str]]: Loaded
|
||||
credentials and the project ID. Authorized user credentials do not
|
||||
have the project ID information. External account credentials project
|
||||
IDs may not always be determined.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.DefaultCredentialsError: if the file is in the
|
||||
wrong format or is missing.
|
||||
"""
|
||||
_warn_about_generic_load_method("load_credentials_from_file")
|
||||
|
||||
if not os.path.exists(filename):
|
||||
raise exceptions.DefaultCredentialsError(
|
||||
"File {} was not found.".format(filename)
|
||||
)
|
||||
|
||||
with io.open(filename, "r") as file_obj:
|
||||
try:
|
||||
info = json.load(file_obj)
|
||||
except ValueError as caught_exc:
|
||||
new_exc = exceptions.DefaultCredentialsError(
|
||||
"File {} is not a valid json file.".format(filename), caught_exc
|
||||
)
|
||||
raise new_exc from caught_exc
|
||||
return _load_credentials_from_info(
|
||||
filename, info, scopes, default_scopes, quota_project_id, request
|
||||
)
|
||||
|
||||
|
||||
def load_credentials_from_dict(
|
||||
info, scopes=None, default_scopes=None, quota_project_id=None, request=None
|
||||
):
|
||||
"""Loads Google credentials from a dict.
|
||||
|
||||
The credentials file must be a service account key, stored authorized
|
||||
user credentials, external account credentials, or impersonated service
|
||||
account credentials.
|
||||
|
||||
.. warning::
|
||||
Important: If you accept a credential configuration (credential JSON/File/Stream)
|
||||
from an external source for authentication to Google Cloud Platform, you must
|
||||
validate it before providing it to any Google API or client library. Providing an
|
||||
unvalidated credential configuration to Google APIs or libraries can compromise
|
||||
the security of your systems and data. For more information, refer to
|
||||
`Validate credential configurations from external sources`_.
|
||||
|
||||
.. _Validate credential configurations from external sources:
|
||||
https://cloud.google.com/docs/authentication/external/externally-sourced-credentials
|
||||
|
||||
Args:
|
||||
info (Dict[str, Any]): A dict object containing the credentials
|
||||
scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If
|
||||
specified, the credentials will automatically be scoped if
|
||||
necessary
|
||||
default_scopes (Optional[Sequence[str]]): Default scopes passed by a
|
||||
Google client library. Use 'scopes' for user-defined scopes.
|
||||
quota_project_id (Optional[str]): The project ID used for
|
||||
quota and billing.
|
||||
request (Optional[google.auth.transport.Request]): An object used to make
|
||||
HTTP requests. This is used to determine the associated project ID
|
||||
for a workload identity pool resource (external account credentials).
|
||||
If not specified, then it will use a
|
||||
google.auth.transport.requests.Request client to make requests.
|
||||
|
||||
Returns:
|
||||
Tuple[google.auth.credentials.Credentials, Optional[str]]: Loaded
|
||||
credentials and the project ID. Authorized user credentials do not
|
||||
have the project ID information. External account credentials project
|
||||
IDs may not always be determined.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.DefaultCredentialsError: if the file is in the
|
||||
wrong format or is missing.
|
||||
"""
|
||||
_warn_about_generic_load_method("load_credentials_from_dict")
|
||||
if not isinstance(info, dict):
|
||||
raise exceptions.DefaultCredentialsError(
|
||||
"info object was of type {} but dict type was expected.".format(type(info))
|
||||
)
|
||||
|
||||
return _load_credentials_from_info(
|
||||
"dict object", info, scopes, default_scopes, quota_project_id, request
|
||||
)
|
||||
|
||||
|
||||
def _load_credentials_from_info(
|
||||
filename, info, scopes, default_scopes, quota_project_id, request
|
||||
):
|
||||
from google.auth.credentials import CredentialsWithQuotaProject
|
||||
|
||||
credential_type = info.get("type")
|
||||
|
||||
if credential_type == _AUTHORIZED_USER_TYPE:
|
||||
credentials, project_id = _get_authorized_user_credentials(
|
||||
filename, info, scopes
|
||||
)
|
||||
|
||||
elif credential_type == _SERVICE_ACCOUNT_TYPE:
|
||||
credentials, project_id = _get_service_account_credentials(
|
||||
filename, info, scopes, default_scopes
|
||||
)
|
||||
|
||||
elif credential_type == _EXTERNAL_ACCOUNT_TYPE:
|
||||
credentials, project_id = _get_external_account_credentials(
|
||||
info,
|
||||
filename,
|
||||
scopes=scopes,
|
||||
default_scopes=default_scopes,
|
||||
request=request,
|
||||
)
|
||||
|
||||
elif credential_type == _EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE:
|
||||
credentials, project_id = _get_external_account_authorized_user_credentials(
|
||||
filename, info, request
|
||||
)
|
||||
|
||||
elif credential_type == _IMPERSONATED_SERVICE_ACCOUNT_TYPE:
|
||||
credentials, project_id = _get_impersonated_service_account_credentials(
|
||||
filename, info, scopes
|
||||
)
|
||||
elif credential_type == _GDCH_SERVICE_ACCOUNT_TYPE:
|
||||
credentials, project_id = _get_gdch_service_account_credentials(filename, info)
|
||||
else:
|
||||
raise exceptions.DefaultCredentialsError(
|
||||
"The file {file} does not have a valid type. "
|
||||
"Type is {type}, expected one of {valid_types}.".format(
|
||||
file=filename, type=credential_type, valid_types=_VALID_TYPES
|
||||
)
|
||||
)
|
||||
if isinstance(credentials, CredentialsWithQuotaProject):
|
||||
credentials = _apply_quota_project_id(credentials, quota_project_id)
|
||||
return credentials, project_id
|
||||
|
||||
|
||||
def _get_gcloud_sdk_credentials(quota_project_id=None):
|
||||
"""Gets the credentials and project ID from the Cloud SDK."""
|
||||
from google.auth import _cloud_sdk
|
||||
|
||||
_LOGGER.debug("Checking Cloud SDK credentials as part of auth process...")
|
||||
|
||||
# Check if application default credentials exist.
|
||||
credentials_filename = _cloud_sdk.get_application_default_credentials_path()
|
||||
|
||||
if not os.path.isfile(credentials_filename):
|
||||
_LOGGER.debug("Cloud SDK credentials not found on disk; not using them")
|
||||
return None, None
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
credentials, project_id = load_credentials_from_file(
|
||||
credentials_filename, quota_project_id=quota_project_id
|
||||
)
|
||||
credentials._cred_file_path = credentials_filename
|
||||
|
||||
if not project_id:
|
||||
project_id = _cloud_sdk.get_project_id()
|
||||
|
||||
return credentials, project_id
|
||||
|
||||
|
||||
def _get_explicit_environ_credentials(quota_project_id=None):
|
||||
"""Gets credentials from the GOOGLE_APPLICATION_CREDENTIALS environment
|
||||
variable."""
|
||||
from google.auth import _cloud_sdk
|
||||
|
||||
cloud_sdk_adc_path = _cloud_sdk.get_application_default_credentials_path()
|
||||
explicit_file = os.environ.get(environment_vars.CREDENTIALS, "")
|
||||
|
||||
_LOGGER.debug(
|
||||
"Checking '%s' for explicit credentials as part of auth process...",
|
||||
explicit_file,
|
||||
)
|
||||
|
||||
if explicit_file != "" and explicit_file == cloud_sdk_adc_path:
|
||||
# Cloud sdk flow calls gcloud to fetch project id, so if the explicit
|
||||
# file path is cloud sdk credentials path, then we should fall back
|
||||
# to cloud sdk flow, otherwise project id cannot be obtained.
|
||||
_LOGGER.debug(
|
||||
"Explicit credentials path '%s' is the same as Cloud SDK credentials path, fall back to Cloud SDK credentials flow...",
|
||||
explicit_file,
|
||||
)
|
||||
return _get_gcloud_sdk_credentials(quota_project_id=quota_project_id)
|
||||
|
||||
if explicit_file != "":
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
credentials, project_id = load_credentials_from_file(
|
||||
os.environ[environment_vars.CREDENTIALS],
|
||||
quota_project_id=quota_project_id,
|
||||
)
|
||||
credentials._cred_file_path = f"{explicit_file} file via the GOOGLE_APPLICATION_CREDENTIALS environment variable"
|
||||
return credentials, project_id
|
||||
|
||||
else:
|
||||
return None, None
|
||||
|
||||
|
||||
def _get_gae_credentials():
|
||||
"""Gets Google App Engine App Identity credentials and project ID."""
|
||||
# If not GAE gen1, prefer the metadata service even if the GAE APIs are
|
||||
# available as per https://google.aip.dev/auth/4115.
|
||||
if os.environ.get(environment_vars.LEGACY_APPENGINE_RUNTIME) != "python27":
|
||||
return None, None
|
||||
|
||||
# While this library is normally bundled with app_engine, there are
|
||||
# some cases where it's not available, so we tolerate ImportError.
|
||||
try:
|
||||
_LOGGER.debug("Checking for App Engine runtime as part of auth process...")
|
||||
import google.auth.app_engine as app_engine
|
||||
except ImportError:
|
||||
_LOGGER.warning("Import of App Engine auth library failed.")
|
||||
return None, None
|
||||
|
||||
try:
|
||||
credentials = app_engine.Credentials()
|
||||
project_id = app_engine.get_project_id()
|
||||
return credentials, project_id
|
||||
except EnvironmentError:
|
||||
_LOGGER.debug(
|
||||
"No App Engine library was found so cannot authentication via App Engine Identity Credentials."
|
||||
)
|
||||
return None, None
|
||||
|
||||
|
||||
def _get_gce_credentials(request=None, quota_project_id=None):
|
||||
"""Gets credentials and project ID from the GCE Metadata Service."""
|
||||
# While this library is normally bundled with compute_engine, there are
|
||||
# some cases where it's not available, so we tolerate ImportError.
|
||||
# Compute Engine requires optional `requests` dependency.
|
||||
try:
|
||||
from google.auth import compute_engine
|
||||
from google.auth.compute_engine import _metadata
|
||||
import google.auth.transport.requests
|
||||
except ImportError:
|
||||
_LOGGER.warning("Import of Compute Engine auth library failed.")
|
||||
return None, None
|
||||
|
||||
if request is None:
|
||||
request = google.auth.transport.requests.Request()
|
||||
|
||||
if _metadata.is_on_gce(request=request):
|
||||
# Get the project ID.
|
||||
try:
|
||||
project_id = _metadata.get_project_id(request=request)
|
||||
except exceptions.TransportError:
|
||||
project_id = None
|
||||
|
||||
cred = compute_engine.Credentials()
|
||||
cred = _apply_quota_project_id(cred, quota_project_id)
|
||||
|
||||
return cred, project_id
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Authentication failed using Compute Engine authentication due to unavailable metadata server."
|
||||
)
|
||||
return None, None
|
||||
|
||||
|
||||
def _get_external_account_credentials(
|
||||
info, filename, scopes=None, default_scopes=None, request=None
|
||||
):
|
||||
"""Loads external account Credentials from the parsed external account info.
|
||||
|
||||
The credentials information must correspond to a supported external account
|
||||
credentials.
|
||||
|
||||
Args:
|
||||
info (Mapping[str, str]): The external account info in Google format.
|
||||
filename (str): The full path to the credentials file.
|
||||
scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If
|
||||
specified, the credentials will automatically be scoped if
|
||||
necessary.
|
||||
default_scopes (Optional[Sequence[str]]): Default scopes passed by a
|
||||
Google client library. Use 'scopes' for user-defined scopes.
|
||||
request (Optional[google.auth.transport.Request]): An object used to make
|
||||
HTTP requests. This is used to determine the associated project ID
|
||||
for a workload identity pool resource (external account credentials).
|
||||
If not specified, then it will use a
|
||||
google.auth.transport.requests.Request client to make requests.
|
||||
|
||||
Returns:
|
||||
Tuple[google.auth.credentials.Credentials, Optional[str]]: Loaded
|
||||
credentials and the project ID. External account credentials project
|
||||
IDs may not always be determined.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.DefaultCredentialsError: if the info dictionary
|
||||
is in the wrong format or is missing required information.
|
||||
"""
|
||||
# There are currently 3 types of external_account credentials.
|
||||
if info.get("subject_token_type") == _AWS_SUBJECT_TOKEN_TYPE:
|
||||
# Check if configuration corresponds to an AWS credentials.
|
||||
from google.auth import aws
|
||||
|
||||
credentials = aws.Credentials.from_info(
|
||||
info, scopes=scopes, default_scopes=default_scopes
|
||||
)
|
||||
elif (
|
||||
info.get("credential_source") is not None
|
||||
and info.get("credential_source").get("executable") is not None
|
||||
):
|
||||
from google.auth import pluggable
|
||||
|
||||
credentials = pluggable.Credentials.from_info(
|
||||
info, scopes=scopes, default_scopes=default_scopes
|
||||
)
|
||||
else:
|
||||
try:
|
||||
# Check if configuration corresponds to an Identity Pool credentials.
|
||||
from google.auth import identity_pool
|
||||
|
||||
credentials = identity_pool.Credentials.from_info(
|
||||
info, scopes=scopes, default_scopes=default_scopes
|
||||
)
|
||||
except ValueError:
|
||||
# If the configuration is invalid or does not correspond to any
|
||||
# supported external_account credentials, raise an error.
|
||||
raise exceptions.DefaultCredentialsError(
|
||||
"Failed to load external account credentials from {}".format(filename)
|
||||
)
|
||||
if request is None:
|
||||
import google.auth.transport.requests
|
||||
|
||||
request = google.auth.transport.requests.Request()
|
||||
|
||||
return credentials, credentials.get_project_id(request=request)
|
||||
|
||||
|
||||
def _get_external_account_authorized_user_credentials(
|
||||
filename, info, scopes=None, default_scopes=None, request=None
|
||||
):
|
||||
try:
|
||||
from google.auth import external_account_authorized_user
|
||||
|
||||
credentials = external_account_authorized_user.Credentials.from_info(info)
|
||||
except ValueError:
|
||||
raise exceptions.DefaultCredentialsError(
|
||||
"Failed to load external account authorized user credentials from {}".format(
|
||||
filename
|
||||
)
|
||||
)
|
||||
|
||||
return credentials, None
|
||||
|
||||
|
||||
def _get_authorized_user_credentials(filename, info, scopes=None):
|
||||
from google.oauth2 import credentials
|
||||
|
||||
try:
|
||||
credentials = credentials.Credentials.from_authorized_user_info(
|
||||
info, scopes=scopes
|
||||
)
|
||||
except ValueError as caught_exc:
|
||||
msg = "Failed to load authorized user credentials from {}".format(filename)
|
||||
new_exc = exceptions.DefaultCredentialsError(msg, caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
return credentials, None
|
||||
|
||||
|
||||
def _get_service_account_credentials(filename, info, scopes=None, default_scopes=None):
|
||||
from google.oauth2 import service_account
|
||||
|
||||
try:
|
||||
credentials = service_account.Credentials.from_service_account_info(
|
||||
info, scopes=scopes, default_scopes=default_scopes
|
||||
)
|
||||
except ValueError as caught_exc:
|
||||
msg = "Failed to load service account credentials from {}".format(filename)
|
||||
new_exc = exceptions.DefaultCredentialsError(msg, caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
return credentials, info.get("project_id")
|
||||
|
||||
|
||||
def _get_impersonated_service_account_credentials(filename, info, scopes):
|
||||
from google.auth import impersonated_credentials
|
||||
|
||||
try:
|
||||
credentials = (
|
||||
impersonated_credentials.Credentials.from_impersonated_service_account_info(
|
||||
info, scopes=scopes
|
||||
)
|
||||
)
|
||||
except ValueError as caught_exc:
|
||||
msg = "Failed to load impersonated service account credentials from {}".format(
|
||||
filename
|
||||
)
|
||||
new_exc = exceptions.DefaultCredentialsError(msg, caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
return credentials, None
|
||||
|
||||
|
||||
def _get_gdch_service_account_credentials(filename, info):
|
||||
from google.oauth2 import gdch_credentials
|
||||
|
||||
try:
|
||||
credentials = (
|
||||
gdch_credentials.ServiceAccountCredentials.from_service_account_info(info)
|
||||
)
|
||||
except ValueError as caught_exc:
|
||||
msg = "Failed to load GDCH service account credentials from {}".format(filename)
|
||||
new_exc = exceptions.DefaultCredentialsError(msg, caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
return credentials, info.get("project")
|
||||
|
||||
|
||||
def get_api_key_credentials(key):
|
||||
"""Return credentials with the given API key."""
|
||||
from google.auth import api_key
|
||||
|
||||
return api_key.Credentials(key)
|
||||
|
||||
|
||||
def _apply_quota_project_id(credentials, quota_project_id):
|
||||
if quota_project_id:
|
||||
credentials = credentials.with_quota_project(quota_project_id)
|
||||
else:
|
||||
credentials = credentials.with_quota_project_from_environment()
|
||||
|
||||
from google.oauth2 import credentials as authorized_user_credentials
|
||||
|
||||
if isinstance(credentials, authorized_user_credentials.Credentials) and (
|
||||
not credentials.quota_project_id
|
||||
):
|
||||
_warn_about_problematic_credentials(credentials)
|
||||
return credentials
|
||||
|
||||
|
||||
def default(
|
||||
scopes: Optional[Sequence[str]] = None,
|
||||
request: Optional["google.auth.transport.Request"] = None,
|
||||
quota_project_id: Optional[str] = None,
|
||||
default_scopes: Optional[Sequence[str]] = None,
|
||||
) -> tuple["google.auth.credentials.Credentials", Optional[str]]:
|
||||
"""Gets the default credentials for the current environment.
|
||||
|
||||
`Application Default Credentials`_ provides an easy way to obtain
|
||||
credentials to call Google APIs for server-to-server or local applications.
|
||||
This function acquires credentials from the environment in the following
|
||||
order:
|
||||
|
||||
1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
|
||||
to the path of a valid service account JSON private key file, then it is
|
||||
loaded and returned. The project ID returned is the project ID defined
|
||||
in the service account file if available (some older files do not
|
||||
contain project ID information).
|
||||
|
||||
If the environment variable is set to the path of a valid external
|
||||
account JSON configuration file (workload identity federation), then the
|
||||
configuration file is used to determine and retrieve the external
|
||||
credentials from the current environment (AWS, Azure, etc).
|
||||
These will then be exchanged for Google access tokens via the Google STS
|
||||
endpoint.
|
||||
The project ID returned in this case is the one corresponding to the
|
||||
underlying workload identity pool resource if determinable.
|
||||
|
||||
If the environment variable is set to the path of a valid GDCH service
|
||||
account JSON file (`Google Distributed Cloud Hosted`_), then a GDCH
|
||||
credential will be returned. The project ID returned is the project
|
||||
specified in the JSON file.
|
||||
2. If the `Google Cloud SDK`_ is installed and has application default
|
||||
credentials set they are loaded and returned.
|
||||
|
||||
To enable application default credentials with the Cloud SDK run::
|
||||
|
||||
gcloud auth application-default login
|
||||
|
||||
If the Cloud SDK has an active project, the project ID is returned. The
|
||||
active project can be set using::
|
||||
|
||||
gcloud config set project
|
||||
|
||||
3. If the application is running in the `App Engine standard environment`_
|
||||
(first generation) then the credentials and project ID from the
|
||||
`App Identity Service`_ are used.
|
||||
4. If the application is running in `Compute Engine`_ or `Cloud Run`_ or
|
||||
the `App Engine flexible environment`_ or the `App Engine standard
|
||||
environment`_ (second generation) then the credentials and project ID
|
||||
are obtained from the `Metadata Service`_.
|
||||
5. If no credentials are found,
|
||||
:class:`~google.auth.exceptions.DefaultCredentialsError` will be raised.
|
||||
|
||||
.. _Application Default Credentials: https://developers.google.com\
|
||||
/identity/protocols/application-default-credentials
|
||||
.. _Google Cloud SDK: https://cloud.google.com/sdk
|
||||
.. _App Engine standard environment: https://cloud.google.com/appengine
|
||||
.. _App Identity Service: https://cloud.google.com/appengine/docs/python\
|
||||
/appidentity/
|
||||
.. _Compute Engine: https://cloud.google.com/compute
|
||||
.. _App Engine flexible environment: https://cloud.google.com\
|
||||
/appengine/flexible
|
||||
.. _Metadata Service: https://cloud.google.com/compute/docs\
|
||||
/storing-retrieving-metadata
|
||||
.. _Cloud Run: https://cloud.google.com/run
|
||||
.. _Google Distributed Cloud Hosted: https://cloud.google.com/blog/topics\
|
||||
/hybrid-cloud/announcing-google-distributed-cloud-edge-and-hosted
|
||||
|
||||
Example::
|
||||
|
||||
import google.auth
|
||||
|
||||
credentials, project_id = google.auth.default()
|
||||
|
||||
Args:
|
||||
scopes (Sequence[str]): The list of scopes for the credentials. If
|
||||
specified, the credentials will automatically be scoped if
|
||||
necessary.
|
||||
request (Optional[google.auth.transport.Request]): An object used to make
|
||||
HTTP requests. This is used to either detect whether the application
|
||||
is running on Compute Engine or to determine the associated project
|
||||
ID for a workload identity pool resource (external account
|
||||
credentials). If not specified, then it will either use the standard
|
||||
library http client to make requests for Compute Engine credentials
|
||||
or a google.auth.transport.requests.Request client for external
|
||||
account credentials.
|
||||
quota_project_id (Optional[str]): The project ID used for
|
||||
quota and billing.
|
||||
default_scopes (Optional[Sequence[str]]): Default scopes passed by a
|
||||
Google client library. Use 'scopes' for user-defined scopes.
|
||||
Returns:
|
||||
Tuple[~google.auth.credentials.Credentials, Optional[str]]:
|
||||
the current environment's credentials and project ID. Project ID
|
||||
may be None, which indicates that the Project ID could not be
|
||||
ascertained from the environment.
|
||||
|
||||
Raises:
|
||||
~google.auth.exceptions.DefaultCredentialsError:
|
||||
If no credentials were found, or if the credentials found were
|
||||
invalid.
|
||||
"""
|
||||
from google.auth.credentials import with_scopes_if_required
|
||||
from google.auth.credentials import CredentialsWithQuotaProject
|
||||
|
||||
explicit_project_id = os.environ.get(
|
||||
environment_vars.PROJECT, os.environ.get(environment_vars.LEGACY_PROJECT)
|
||||
)
|
||||
|
||||
checkers = (
|
||||
# Avoid passing scopes here to prevent passing scopes to user credentials.
|
||||
# with_scopes_if_required() below will ensure scopes/default scopes are
|
||||
# safely set on the returned credentials since requires_scopes will
|
||||
# guard against setting scopes on user credentials.
|
||||
lambda: _get_explicit_environ_credentials(quota_project_id=quota_project_id),
|
||||
lambda: _get_gcloud_sdk_credentials(quota_project_id=quota_project_id),
|
||||
_get_gae_credentials,
|
||||
lambda: _get_gce_credentials(request, quota_project_id=quota_project_id),
|
||||
)
|
||||
|
||||
for checker in checkers:
|
||||
credentials, project_id = checker()
|
||||
if credentials is not None:
|
||||
credentials = with_scopes_if_required(
|
||||
credentials, scopes, default_scopes=default_scopes
|
||||
)
|
||||
|
||||
effective_project_id = explicit_project_id or project_id
|
||||
|
||||
# For external account credentials, scopes are required to determine
|
||||
# the project ID. Try to get the project ID again if not yet
|
||||
# determined.
|
||||
if not effective_project_id and callable(
|
||||
getattr(credentials, "get_project_id", None)
|
||||
):
|
||||
if request is None:
|
||||
import google.auth.transport.requests
|
||||
|
||||
request = google.auth.transport.requests.Request()
|
||||
effective_project_id = credentials.get_project_id(request=request)
|
||||
|
||||
if quota_project_id and isinstance(
|
||||
credentials, CredentialsWithQuotaProject
|
||||
):
|
||||
credentials = credentials.with_quota_project(quota_project_id)
|
||||
|
||||
if not effective_project_id:
|
||||
_LOGGER.warning(
|
||||
"No project ID could be determined. Consider running "
|
||||
"`gcloud config set project` or setting the %s "
|
||||
"environment variable",
|
||||
environment_vars.PROJECT,
|
||||
)
|
||||
return credentials, effective_project_id
|
||||
|
||||
raise exceptions.DefaultCredentialsError(_CLOUD_SDK_MISSING_CREDENTIALS)
|
||||
288
venv/lib/python3.12/site-packages/google/auth/_default_async.py
Normal file
288
venv/lib/python3.12/site-packages/google/auth/_default_async.py
Normal file
@@ -0,0 +1,288 @@
|
||||
# Copyright 2020 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.
|
||||
|
||||
"""Application default credentials.
|
||||
|
||||
Implements application default credentials and project ID detection.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import warnings
|
||||
|
||||
from google.auth import _default
|
||||
from google.auth import environment_vars
|
||||
from google.auth import exceptions
|
||||
|
||||
|
||||
def load_credentials_from_file(filename, scopes=None, quota_project_id=None):
|
||||
"""Loads Google credentials from a file.
|
||||
|
||||
The credentials file must be a service account key or stored authorized
|
||||
user credentials.
|
||||
|
||||
Args:
|
||||
filename (str): The full path to the credentials file.
|
||||
scopes (Optional[Sequence[str]]): The list of scopes for the credentials. If
|
||||
specified, the credentials will automatically be scoped if
|
||||
necessary
|
||||
quota_project_id (Optional[str]): The project ID used for
|
||||
quota and billing.
|
||||
|
||||
Returns:
|
||||
Tuple[google.auth.credentials.Credentials, Optional[str]]: Loaded
|
||||
credentials and the project ID. Authorized user credentials do not
|
||||
have the project ID information.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.DefaultCredentialsError: if the file is in the
|
||||
wrong format or is missing.
|
||||
"""
|
||||
if not os.path.exists(filename):
|
||||
raise exceptions.DefaultCredentialsError(
|
||||
"File {} was not found.".format(filename)
|
||||
)
|
||||
|
||||
with io.open(filename, "r") as file_obj:
|
||||
try:
|
||||
info = json.load(file_obj)
|
||||
except ValueError as caught_exc:
|
||||
new_exc = exceptions.DefaultCredentialsError(
|
||||
"File {} is not a valid json file.".format(filename), caught_exc
|
||||
)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
# The type key should indicate that the file is either a service account
|
||||
# credentials file or an authorized user credentials file.
|
||||
credential_type = info.get("type")
|
||||
|
||||
if credential_type == _default._AUTHORIZED_USER_TYPE:
|
||||
from google.oauth2 import _credentials_async as credentials
|
||||
|
||||
try:
|
||||
credentials = credentials.Credentials.from_authorized_user_info(
|
||||
info, scopes=scopes
|
||||
)
|
||||
except ValueError as caught_exc:
|
||||
msg = "Failed to load authorized user credentials from {}".format(filename)
|
||||
new_exc = exceptions.DefaultCredentialsError(msg, caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
if quota_project_id:
|
||||
credentials = credentials.with_quota_project(quota_project_id)
|
||||
if not credentials.quota_project_id:
|
||||
_default._warn_about_problematic_credentials(credentials)
|
||||
return credentials, None
|
||||
|
||||
elif credential_type == _default._SERVICE_ACCOUNT_TYPE:
|
||||
from google.oauth2 import _service_account_async as service_account
|
||||
|
||||
try:
|
||||
credentials = service_account.Credentials.from_service_account_info(
|
||||
info, scopes=scopes
|
||||
).with_quota_project(quota_project_id)
|
||||
except ValueError as caught_exc:
|
||||
msg = "Failed to load service account credentials from {}".format(filename)
|
||||
new_exc = exceptions.DefaultCredentialsError(msg, caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
return credentials, info.get("project_id")
|
||||
|
||||
else:
|
||||
raise exceptions.DefaultCredentialsError(
|
||||
"The file {file} does not have a valid type. "
|
||||
"Type is {type}, expected one of {valid_types}.".format(
|
||||
file=filename, type=credential_type, valid_types=_default._VALID_TYPES
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _get_gcloud_sdk_credentials(quota_project_id=None):
|
||||
"""Gets the credentials and project ID from the Cloud SDK."""
|
||||
from google.auth import _cloud_sdk
|
||||
|
||||
# Check if application default credentials exist.
|
||||
credentials_filename = _cloud_sdk.get_application_default_credentials_path()
|
||||
|
||||
if not os.path.isfile(credentials_filename):
|
||||
return None, None
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
credentials, project_id = load_credentials_from_file(
|
||||
credentials_filename, quota_project_id=quota_project_id
|
||||
)
|
||||
|
||||
if not project_id:
|
||||
project_id = _cloud_sdk.get_project_id()
|
||||
|
||||
return credentials, project_id
|
||||
|
||||
|
||||
def _get_explicit_environ_credentials(quota_project_id=None):
|
||||
"""Gets credentials from the GOOGLE_APPLICATION_CREDENTIALS environment
|
||||
variable."""
|
||||
from google.auth import _cloud_sdk
|
||||
|
||||
cloud_sdk_adc_path = _cloud_sdk.get_application_default_credentials_path()
|
||||
explicit_file = os.environ.get(environment_vars.CREDENTIALS)
|
||||
|
||||
if explicit_file is not None and explicit_file == cloud_sdk_adc_path:
|
||||
# Cloud sdk flow calls gcloud to fetch project id, so if the explicit
|
||||
# file path is cloud sdk credentials path, then we should fall back
|
||||
# to cloud sdk flow, otherwise project id cannot be obtained.
|
||||
return _get_gcloud_sdk_credentials(quota_project_id=quota_project_id)
|
||||
|
||||
if explicit_file is not None:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
credentials, project_id = load_credentials_from_file(
|
||||
os.environ[environment_vars.CREDENTIALS],
|
||||
quota_project_id=quota_project_id,
|
||||
)
|
||||
|
||||
return credentials, project_id
|
||||
|
||||
else:
|
||||
return None, None
|
||||
|
||||
|
||||
def _get_gae_credentials():
|
||||
"""Gets Google App Engine App Identity credentials and project ID."""
|
||||
# While this library is normally bundled with app_engine, there are
|
||||
# some cases where it's not available, so we tolerate ImportError.
|
||||
|
||||
return _default._get_gae_credentials()
|
||||
|
||||
|
||||
def _get_gce_credentials(request=None):
|
||||
"""Gets credentials and project ID from the GCE Metadata Service."""
|
||||
# Ping requires a transport, but we want application default credentials
|
||||
# to require no arguments. So, we'll use the _http_client transport which
|
||||
# uses http.client. This is only acceptable because the metadata server
|
||||
# doesn't do SSL and never requires proxies.
|
||||
|
||||
# While this library is normally bundled with compute_engine, there are
|
||||
# some cases where it's not available, so we tolerate ImportError.
|
||||
|
||||
return _default._get_gce_credentials(request)
|
||||
|
||||
|
||||
def default_async(scopes=None, request=None, quota_project_id=None):
|
||||
"""Gets the default credentials for the current environment.
|
||||
|
||||
`Application Default Credentials`_ provides an easy way to obtain
|
||||
credentials to call Google APIs for server-to-server or local applications.
|
||||
This function acquires credentials from the environment in the following
|
||||
order:
|
||||
|
||||
1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set
|
||||
to the path of a valid service account JSON private key file, then it is
|
||||
loaded and returned. The project ID returned is the project ID defined
|
||||
in the service account file if available (some older files do not
|
||||
contain project ID information).
|
||||
2. If the `Google Cloud SDK`_ is installed and has application default
|
||||
credentials set they are loaded and returned.
|
||||
|
||||
To enable application default credentials with the Cloud SDK run::
|
||||
|
||||
gcloud auth application-default login
|
||||
|
||||
If the Cloud SDK has an active project, the project ID is returned. The
|
||||
active project can be set using::
|
||||
|
||||
gcloud config set project
|
||||
|
||||
3. If the application is running in the `App Engine standard environment`_
|
||||
(first generation) then the credentials and project ID from the
|
||||
`App Identity Service`_ are used.
|
||||
4. If the application is running in `Compute Engine`_ or `Cloud Run`_ or
|
||||
the `App Engine flexible environment`_ or the `App Engine standard
|
||||
environment`_ (second generation) then the credentials and project ID
|
||||
are obtained from the `Metadata Service`_.
|
||||
5. If no credentials are found,
|
||||
:class:`~google.auth.exceptions.DefaultCredentialsError` will be raised.
|
||||
|
||||
.. _Application Default Credentials: https://developers.google.com\
|
||||
/identity/protocols/application-default-credentials
|
||||
.. _Google Cloud SDK: https://cloud.google.com/sdk
|
||||
.. _App Engine standard environment: https://cloud.google.com/appengine
|
||||
.. _App Identity Service: https://cloud.google.com/appengine/docs/python\
|
||||
/appidentity/
|
||||
.. _Compute Engine: https://cloud.google.com/compute
|
||||
.. _App Engine flexible environment: https://cloud.google.com\
|
||||
/appengine/flexible
|
||||
.. _Metadata Service: https://cloud.google.com/compute/docs\
|
||||
/storing-retrieving-metadata
|
||||
.. _Cloud Run: https://cloud.google.com/run
|
||||
|
||||
Example::
|
||||
|
||||
import google.auth
|
||||
|
||||
credentials, project_id = google.auth.default()
|
||||
|
||||
Args:
|
||||
scopes (Sequence[str]): The list of scopes for the credentials. If
|
||||
specified, the credentials will automatically be scoped if
|
||||
necessary.
|
||||
request (google.auth.transport.Request): An object used to make
|
||||
HTTP requests. This is used to detect whether the application
|
||||
is running on Compute Engine. If not specified, then it will
|
||||
use the standard library http client to make requests.
|
||||
quota_project_id (Optional[str]): The project ID used for
|
||||
quota and billing.
|
||||
Returns:
|
||||
Tuple[~google.auth.credentials.Credentials, Optional[str]]:
|
||||
the current environment's credentials and project ID. Project ID
|
||||
may be None, which indicates that the Project ID could not be
|
||||
ascertained from the environment.
|
||||
|
||||
Raises:
|
||||
~google.auth.exceptions.DefaultCredentialsError:
|
||||
If no credentials were found, or if the credentials found were
|
||||
invalid.
|
||||
"""
|
||||
from google.auth._credentials_async import with_scopes_if_required
|
||||
from google.auth.credentials import CredentialsWithQuotaProject
|
||||
|
||||
explicit_project_id = os.environ.get(
|
||||
environment_vars.PROJECT, os.environ.get(environment_vars.LEGACY_PROJECT)
|
||||
)
|
||||
|
||||
checkers = (
|
||||
lambda: _get_explicit_environ_credentials(quota_project_id=quota_project_id),
|
||||
lambda: _get_gcloud_sdk_credentials(quota_project_id=quota_project_id),
|
||||
_get_gae_credentials,
|
||||
lambda: _get_gce_credentials(request),
|
||||
)
|
||||
|
||||
for checker in checkers:
|
||||
credentials, project_id = checker()
|
||||
if credentials is not None:
|
||||
credentials = with_scopes_if_required(credentials, scopes)
|
||||
if quota_project_id and isinstance(
|
||||
credentials, CredentialsWithQuotaProject
|
||||
):
|
||||
credentials = credentials.with_quota_project(quota_project_id)
|
||||
effective_project_id = explicit_project_id or project_id
|
||||
if not effective_project_id:
|
||||
_default._LOGGER.warning(
|
||||
"No project ID could be determined. Consider running "
|
||||
"`gcloud config set project` or setting the %s "
|
||||
"environment variable",
|
||||
environment_vars.PROJECT,
|
||||
)
|
||||
return credentials, effective_project_id
|
||||
|
||||
raise exceptions.DefaultCredentialsError(_default._CLOUD_SDK_MISSING_CREDENTIALS)
|
||||
@@ -0,0 +1,164 @@
|
||||
# Copyright 2022 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 asyncio
|
||||
import random
|
||||
import time
|
||||
|
||||
from google.auth import exceptions
|
||||
|
||||
# The default amount of retry attempts
|
||||
_DEFAULT_RETRY_TOTAL_ATTEMPTS = 3
|
||||
|
||||
# The default initial backoff period (1.0 second).
|
||||
_DEFAULT_INITIAL_INTERVAL_SECONDS = 1.0
|
||||
|
||||
# The default randomization factor (0.1 which results in a random period ranging
|
||||
# between 10% below and 10% above the retry interval).
|
||||
_DEFAULT_RANDOMIZATION_FACTOR = 0.1
|
||||
|
||||
# The default multiplier value (2 which is 100% increase per back off).
|
||||
_DEFAULT_MULTIPLIER = 2.0
|
||||
|
||||
"""Exponential Backoff Utility
|
||||
|
||||
This is a private module that implements the exponential back off algorithm.
|
||||
It can be used as a utility for code that needs to retry on failure, for example
|
||||
an HTTP request.
|
||||
"""
|
||||
|
||||
|
||||
class _BaseExponentialBackoff:
|
||||
"""An exponential backoff iterator base class.
|
||||
|
||||
Args:
|
||||
total_attempts Optional[int]:
|
||||
The maximum amount of retries that should happen.
|
||||
The default value is 3 attempts.
|
||||
initial_wait_seconds Optional[int]:
|
||||
The amount of time to sleep in the first backoff. This parameter
|
||||
should be in seconds.
|
||||
The default value is 1 second.
|
||||
randomization_factor Optional[float]:
|
||||
The amount of jitter that should be in each backoff. For example,
|
||||
a value of 0.1 will introduce a jitter range of 10% to the
|
||||
current backoff period.
|
||||
The default value is 0.1.
|
||||
multiplier Optional[float]:
|
||||
The backoff multipler. This adjusts how much each backoff will
|
||||
increase. For example a value of 2.0 leads to a 200% backoff
|
||||
on each attempt. If the initial_wait is 1.0 it would look like
|
||||
this sequence [1.0, 2.0, 4.0, 8.0].
|
||||
The default value is 2.0.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
total_attempts=_DEFAULT_RETRY_TOTAL_ATTEMPTS,
|
||||
initial_wait_seconds=_DEFAULT_INITIAL_INTERVAL_SECONDS,
|
||||
randomization_factor=_DEFAULT_RANDOMIZATION_FACTOR,
|
||||
multiplier=_DEFAULT_MULTIPLIER,
|
||||
):
|
||||
if total_attempts < 1:
|
||||
raise exceptions.InvalidValue(
|
||||
f"total_attempts must be greater than or equal to 1 but was {total_attempts}"
|
||||
)
|
||||
|
||||
self._total_attempts = total_attempts
|
||||
self._initial_wait_seconds = initial_wait_seconds
|
||||
|
||||
self._current_wait_in_seconds = self._initial_wait_seconds
|
||||
|
||||
self._randomization_factor = randomization_factor
|
||||
self._multiplier = multiplier
|
||||
self._backoff_count = 0
|
||||
|
||||
@property
|
||||
def total_attempts(self):
|
||||
"""The total amount of backoff attempts that will be made."""
|
||||
return self._total_attempts
|
||||
|
||||
@property
|
||||
def backoff_count(self):
|
||||
"""The current amount of backoff attempts that have been made."""
|
||||
return self._backoff_count
|
||||
|
||||
def _reset(self):
|
||||
self._backoff_count = 0
|
||||
self._current_wait_in_seconds = self._initial_wait_seconds
|
||||
|
||||
def _calculate_jitter(self):
|
||||
jitter_variance = self._current_wait_in_seconds * self._randomization_factor
|
||||
jitter = random.uniform(
|
||||
self._current_wait_in_seconds - jitter_variance,
|
||||
self._current_wait_in_seconds + jitter_variance,
|
||||
)
|
||||
|
||||
return jitter
|
||||
|
||||
|
||||
class ExponentialBackoff(_BaseExponentialBackoff):
|
||||
"""An exponential backoff iterator. This can be used in a for loop to
|
||||
perform requests with exponential backoff.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(ExponentialBackoff, self).__init__(*args, **kwargs)
|
||||
|
||||
def __iter__(self):
|
||||
self._reset()
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if self._backoff_count >= self._total_attempts:
|
||||
raise StopIteration
|
||||
self._backoff_count += 1
|
||||
|
||||
if self._backoff_count <= 1:
|
||||
return self._backoff_count
|
||||
|
||||
jitter = self._calculate_jitter()
|
||||
|
||||
time.sleep(jitter)
|
||||
|
||||
self._current_wait_in_seconds *= self._multiplier
|
||||
return self._backoff_count
|
||||
|
||||
|
||||
class AsyncExponentialBackoff(_BaseExponentialBackoff):
|
||||
"""An async exponential backoff iterator. This can be used in a for loop to
|
||||
perform async requests with exponential backoff.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AsyncExponentialBackoff, self).__init__(*args, **kwargs)
|
||||
|
||||
def __aiter__(self):
|
||||
self._reset()
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if self._backoff_count >= self._total_attempts:
|
||||
raise StopAsyncIteration
|
||||
self._backoff_count += 1
|
||||
|
||||
if self._backoff_count <= 1:
|
||||
return self._backoff_count
|
||||
|
||||
jitter = self._calculate_jitter()
|
||||
|
||||
await asyncio.sleep(jitter)
|
||||
|
||||
self._current_wait_in_seconds *= self._multiplier
|
||||
return self._backoff_count
|
||||
575
venv/lib/python3.12/site-packages/google/auth/_helpers.py
Normal file
575
venv/lib/python3.12/site-packages/google/auth/_helpers.py
Normal file
@@ -0,0 +1,575 @@
|
||||
# Copyright 2015 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.
|
||||
|
||||
"""Helper functions for commonly used utilities."""
|
||||
|
||||
import base64
|
||||
import calendar
|
||||
import datetime
|
||||
from email.message import Message
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Dict, Mapping, Optional, Union
|
||||
import urllib
|
||||
|
||||
from google.auth import exceptions
|
||||
|
||||
|
||||
# _BASE_LOGGER_NAME is the base logger for all google-based loggers.
|
||||
_BASE_LOGGER_NAME = "google"
|
||||
|
||||
# _LOGGING_INITIALIZED ensures that base logger is only configured once
|
||||
# (unless already configured by the end-user).
|
||||
_LOGGING_INITIALIZED = False
|
||||
|
||||
|
||||
# The smallest MDS cache used by this library stores tokens until 4 minutes from
|
||||
# expiry.
|
||||
REFRESH_THRESHOLD = datetime.timedelta(minutes=3, seconds=45)
|
||||
|
||||
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1684): Audit and update the list below.
|
||||
_SENSITIVE_FIELDS = {
|
||||
"accessToken",
|
||||
"access_token",
|
||||
"id_token",
|
||||
"client_id",
|
||||
"refresh_token",
|
||||
"client_secret",
|
||||
}
|
||||
|
||||
|
||||
def copy_docstring(source_class):
|
||||
"""Decorator that copies a method's docstring from another class.
|
||||
|
||||
Args:
|
||||
source_class (type): The class that has the documented method.
|
||||
|
||||
Returns:
|
||||
Callable: A decorator that will copy the docstring of the same
|
||||
named method in the source class to the decorated method.
|
||||
"""
|
||||
|
||||
def decorator(method):
|
||||
"""Decorator implementation.
|
||||
|
||||
Args:
|
||||
method (Callable): The method to copy the docstring to.
|
||||
|
||||
Returns:
|
||||
Callable: the same method passed in with an updated docstring.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidOperation: if the method already has a docstring.
|
||||
"""
|
||||
if method.__doc__:
|
||||
raise exceptions.InvalidOperation("Method already has a docstring.")
|
||||
|
||||
source_method = getattr(source_class, method.__name__)
|
||||
method.__doc__ = source_method.__doc__
|
||||
|
||||
return method
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def parse_content_type(header_value):
|
||||
"""Parse a 'content-type' header value to get just the plain media-type (without parameters).
|
||||
|
||||
This is done using the class Message from email.message as suggested in PEP 594
|
||||
(because the cgi is now deprecated and will be removed in python 3.13,
|
||||
see https://peps.python.org/pep-0594/#cgi).
|
||||
|
||||
Args:
|
||||
header_value (str): The value of a 'content-type' header as a string.
|
||||
|
||||
Returns:
|
||||
str: A string with just the lowercase media-type from the parsed 'content-type' header.
|
||||
If the provided content-type is not parsable, returns 'text/plain',
|
||||
the default value for textual files.
|
||||
"""
|
||||
m = Message()
|
||||
m["content-type"] = header_value
|
||||
return (
|
||||
m.get_content_type()
|
||||
) # Despite the name, actually returns just the media-type
|
||||
|
||||
|
||||
def utcnow():
|
||||
"""Returns the current UTC datetime.
|
||||
|
||||
Returns:
|
||||
datetime: The current time in UTC.
|
||||
"""
|
||||
# We used datetime.utcnow() before, since it's deprecated from python 3.12,
|
||||
# we are using datetime.now(timezone.utc) now. "utcnow()" is offset-native
|
||||
# (no timezone info), but "now()" is offset-aware (with timezone info).
|
||||
# This will cause datetime comparison problem. For backward compatibility,
|
||||
# we need to remove the timezone info.
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
now = now.replace(tzinfo=None)
|
||||
return now
|
||||
|
||||
|
||||
def utcfromtimestamp(timestamp):
|
||||
"""Returns the UTC datetime from a timestamp.
|
||||
|
||||
Args:
|
||||
timestamp (float): The timestamp to convert.
|
||||
|
||||
Returns:
|
||||
datetime: The time in UTC.
|
||||
"""
|
||||
# We used datetime.utcfromtimestamp() before, since it's deprecated from
|
||||
# python 3.12, we are using datetime.fromtimestamp(timestamp, timezone.utc)
|
||||
# now. "utcfromtimestamp()" is offset-native (no timezone info), but
|
||||
# "fromtimestamp(timestamp, timezone.utc)" is offset-aware (with timezone
|
||||
# info). This will cause datetime comparison problem. For backward
|
||||
# compatibility, we need to remove the timezone info.
|
||||
dt = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
|
||||
dt = dt.replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
|
||||
def datetime_to_secs(value):
|
||||
"""Convert a datetime object to the number of seconds since the UNIX epoch.
|
||||
|
||||
Args:
|
||||
value (datetime): The datetime to convert.
|
||||
|
||||
Returns:
|
||||
int: The number of seconds since the UNIX epoch.
|
||||
"""
|
||||
return calendar.timegm(value.utctimetuple())
|
||||
|
||||
|
||||
def to_bytes(value, encoding="utf-8"):
|
||||
"""Converts a string value to bytes, if necessary.
|
||||
|
||||
Args:
|
||||
value (Union[str, bytes]): The value to be converted.
|
||||
encoding (str): The encoding to use to convert unicode to bytes.
|
||||
Defaults to "utf-8".
|
||||
|
||||
Returns:
|
||||
bytes: The original value converted to bytes (if unicode) or as
|
||||
passed in if it started out as bytes.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidValue: If the value could not be converted to bytes.
|
||||
"""
|
||||
result = value.encode(encoding) if isinstance(value, str) else value
|
||||
if isinstance(result, bytes):
|
||||
return result
|
||||
else:
|
||||
raise exceptions.InvalidValue(
|
||||
"{0!r} could not be converted to bytes".format(value)
|
||||
)
|
||||
|
||||
|
||||
def from_bytes(value):
|
||||
"""Converts bytes to a string value, if necessary.
|
||||
|
||||
Args:
|
||||
value (Union[str, bytes]): The value to be converted.
|
||||
|
||||
Returns:
|
||||
str: The original value converted to unicode (if bytes) or as passed in
|
||||
if it started out as unicode.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidValue: If the value could not be converted to unicode.
|
||||
"""
|
||||
result = value.decode("utf-8") if isinstance(value, bytes) else value
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
else:
|
||||
raise exceptions.InvalidValue(
|
||||
"{0!r} could not be converted to unicode".format(value)
|
||||
)
|
||||
|
||||
|
||||
def update_query(url, params, remove=None):
|
||||
"""Updates a URL's query parameters.
|
||||
|
||||
Replaces any current values if they are already present in the URL.
|
||||
|
||||
Args:
|
||||
url (str): The URL to update.
|
||||
params (Mapping[str, str]): A mapping of query parameter
|
||||
keys to values.
|
||||
remove (Sequence[str]): Parameters to remove from the query string.
|
||||
|
||||
Returns:
|
||||
str: The URL with updated query parameters.
|
||||
|
||||
Examples:
|
||||
|
||||
>>> url = 'http://example.com?a=1'
|
||||
>>> update_query(url, {'a': '2'})
|
||||
http://example.com?a=2
|
||||
>>> update_query(url, {'b': '3'})
|
||||
http://example.com?a=1&b=3
|
||||
>> update_query(url, {'b': '3'}, remove=['a'])
|
||||
http://example.com?b=3
|
||||
|
||||
"""
|
||||
if remove is None:
|
||||
remove = []
|
||||
|
||||
# Split the URL into parts.
|
||||
parts = urllib.parse.urlparse(url)
|
||||
# Parse the query string.
|
||||
query_params = urllib.parse.parse_qs(parts.query)
|
||||
# Update the query parameters with the new parameters.
|
||||
query_params.update(params)
|
||||
# Remove any values specified in remove.
|
||||
query_params = {
|
||||
key: value for key, value in query_params.items() if key not in remove
|
||||
}
|
||||
# Re-encoded the query string.
|
||||
new_query = urllib.parse.urlencode(query_params, doseq=True)
|
||||
# Unsplit the url.
|
||||
new_parts = parts._replace(query=new_query)
|
||||
return urllib.parse.urlunparse(new_parts)
|
||||
|
||||
|
||||
def scopes_to_string(scopes):
|
||||
"""Converts scope value to a string suitable for sending to OAuth 2.0
|
||||
authorization servers.
|
||||
|
||||
Args:
|
||||
scopes (Sequence[str]): The sequence of scopes to convert.
|
||||
|
||||
Returns:
|
||||
str: The scopes formatted as a single string.
|
||||
"""
|
||||
return " ".join(scopes)
|
||||
|
||||
|
||||
def string_to_scopes(scopes):
|
||||
"""Converts stringifed scopes value to a list.
|
||||
|
||||
Args:
|
||||
scopes (Union[Sequence, str]): The string of space-separated scopes
|
||||
to convert.
|
||||
Returns:
|
||||
Sequence(str): The separated scopes.
|
||||
"""
|
||||
if not scopes:
|
||||
return []
|
||||
|
||||
return scopes.split(" ")
|
||||
|
||||
|
||||
def padded_urlsafe_b64decode(value):
|
||||
"""Decodes base64 strings lacking padding characters.
|
||||
|
||||
Google infrastructure tends to omit the base64 padding characters.
|
||||
|
||||
Args:
|
||||
value (Union[str, bytes]): The encoded value.
|
||||
|
||||
Returns:
|
||||
bytes: The decoded value
|
||||
"""
|
||||
b64string = to_bytes(value)
|
||||
padded = b64string + b"=" * (-len(b64string) % 4)
|
||||
return base64.urlsafe_b64decode(padded)
|
||||
|
||||
|
||||
def unpadded_urlsafe_b64encode(value):
|
||||
"""Encodes base64 strings removing any padding characters.
|
||||
|
||||
`rfc 7515`_ defines Base64url to NOT include any padding
|
||||
characters, but the stdlib doesn't do that by default.
|
||||
|
||||
_rfc7515: https://tools.ietf.org/html/rfc7515#page-6
|
||||
|
||||
Args:
|
||||
value (Union[str|bytes]): The bytes-like value to encode
|
||||
|
||||
Returns:
|
||||
Union[str|bytes]: The encoded value
|
||||
"""
|
||||
return base64.urlsafe_b64encode(value).rstrip(b"=")
|
||||
|
||||
|
||||
def get_bool_from_env(variable_name, default=False):
|
||||
"""Gets a boolean value from an environment variable.
|
||||
|
||||
The environment variable is interpreted as a boolean with the following
|
||||
(case-insensitive) rules:
|
||||
- "true", "1" are considered true.
|
||||
- "false", "0" are considered false.
|
||||
Any other values will raise an exception.
|
||||
|
||||
Args:
|
||||
variable_name (str): The name of the environment variable.
|
||||
default (bool): The default value if the environment variable is not
|
||||
set.
|
||||
|
||||
Returns:
|
||||
bool: The boolean value of the environment variable.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidValue: If the environment variable is
|
||||
set to a value that can not be interpreted as a boolean.
|
||||
"""
|
||||
value = os.environ.get(variable_name)
|
||||
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
value = value.lower()
|
||||
|
||||
if value in ("true", "1"):
|
||||
return True
|
||||
elif value in ("false", "0"):
|
||||
return False
|
||||
else:
|
||||
raise exceptions.InvalidValue(
|
||||
'Environment variable "{}" must be one of "true", "false", "1", or "0".'.format(
|
||||
variable_name
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_python_3():
|
||||
"""Check if the Python interpreter is Python 2 or 3.
|
||||
|
||||
Returns:
|
||||
bool: True if the Python interpreter is Python 3 and False otherwise.
|
||||
"""
|
||||
|
||||
return sys.version_info > (3, 0) # pragma: NO COVER
|
||||
|
||||
|
||||
def _hash_sensitive_info(data: Union[dict, list]) -> Union[dict, list, str]:
|
||||
"""
|
||||
Hashes sensitive information within a dictionary.
|
||||
|
||||
Args:
|
||||
data: The dictionary containing data to be processed.
|
||||
|
||||
Returns:
|
||||
A new dictionary with sensitive values replaced by their SHA512 hashes.
|
||||
If the input is a list, returns a list with each element recursively processed.
|
||||
If the input is neither a dict nor a list, returns the type of the input as a string.
|
||||
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
hashed_data: Dict[Any, Union[Optional[str], dict, list]] = {}
|
||||
for key, value in data.items():
|
||||
if key in _SENSITIVE_FIELDS and not isinstance(value, (dict, list)):
|
||||
hashed_data[key] = _hash_value(value, key)
|
||||
elif isinstance(value, (dict, list)):
|
||||
hashed_data[key] = _hash_sensitive_info(value)
|
||||
else:
|
||||
hashed_data[key] = value
|
||||
return hashed_data
|
||||
elif isinstance(data, list):
|
||||
hashed_list = []
|
||||
for val in data:
|
||||
hashed_list.append(_hash_sensitive_info(val))
|
||||
return hashed_list
|
||||
else:
|
||||
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1701):
|
||||
# Investigate and hash sensitive info before logging when the data type is
|
||||
# not a dict or a list.
|
||||
return str(type(data))
|
||||
|
||||
|
||||
def _hash_value(value, field_name: str) -> Optional[str]:
|
||||
"""Hashes a value and returns a formatted hash string."""
|
||||
if value is None:
|
||||
return None
|
||||
encoded_value = str(value).encode("utf-8")
|
||||
hash_object = hashlib.sha512()
|
||||
hash_object.update(encoded_value)
|
||||
hex_digest = hash_object.hexdigest()
|
||||
return f"hashed_{field_name}-{hex_digest}"
|
||||
|
||||
|
||||
def _logger_configured(logger: logging.Logger) -> bool:
|
||||
"""Determines whether `logger` has non-default configuration
|
||||
|
||||
Args:
|
||||
logger: The logger to check.
|
||||
|
||||
Returns:
|
||||
bool: Whether the logger has any non-default configuration.
|
||||
"""
|
||||
return (
|
||||
logger.handlers != [] or logger.level != logging.NOTSET or not logger.propagate
|
||||
)
|
||||
|
||||
|
||||
def is_logging_enabled(logger: logging.Logger) -> bool:
|
||||
"""
|
||||
Checks if debug logging is enabled for the given logger.
|
||||
|
||||
Args:
|
||||
logger: The logging.Logger instance to check.
|
||||
|
||||
Returns:
|
||||
True if debug logging is enabled, False otherwise.
|
||||
"""
|
||||
# NOTE: Log propagation to the root logger is disabled unless
|
||||
# the base logger i.e. logging.getLogger("google") is
|
||||
# explicitly configured by the end user. Ideally this
|
||||
# needs to happen in the client layer (already does for GAPICs).
|
||||
# However, this is implemented here to avoid logging
|
||||
# (if a root logger is configured) when a version of google-auth
|
||||
# which supports logging is used with:
|
||||
# - an older version of a GAPIC which does not support logging.
|
||||
# - Apiary client which does not support logging.
|
||||
global _LOGGING_INITIALIZED
|
||||
if not _LOGGING_INITIALIZED:
|
||||
base_logger = logging.getLogger(_BASE_LOGGER_NAME)
|
||||
if not _logger_configured(base_logger):
|
||||
base_logger.propagate = False
|
||||
_LOGGING_INITIALIZED = True
|
||||
|
||||
return logger.isEnabledFor(logging.DEBUG)
|
||||
|
||||
|
||||
def request_log(
|
||||
logger: logging.Logger,
|
||||
method: str,
|
||||
url: str,
|
||||
body: Optional[bytes],
|
||||
headers: Optional[Mapping[str, str]],
|
||||
) -> None:
|
||||
"""
|
||||
Logs an HTTP request at the DEBUG level if logging is enabled.
|
||||
|
||||
Args:
|
||||
logger: The logging.Logger instance to use.
|
||||
method: The HTTP method (e.g., "GET", "POST").
|
||||
url: The URL of the request.
|
||||
body: The request body (can be None).
|
||||
headers: The request headers (can be None).
|
||||
"""
|
||||
if is_logging_enabled(logger):
|
||||
content_type = (
|
||||
headers["Content-Type"] if headers and "Content-Type" in headers else ""
|
||||
)
|
||||
json_body = _parse_request_body(body, content_type=content_type)
|
||||
logged_body = _hash_sensitive_info(json_body)
|
||||
logger.debug(
|
||||
"Making request...",
|
||||
extra={
|
||||
"httpRequest": {
|
||||
"method": method,
|
||||
"url": url,
|
||||
"body": logged_body,
|
||||
"headers": headers,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _parse_request_body(body: Optional[bytes], content_type: str = "") -> Any:
|
||||
"""
|
||||
Parses a request body, handling bytes and string types, and different content types.
|
||||
|
||||
Args:
|
||||
body (Optional[bytes]): The request body.
|
||||
content_type (str): The content type of the request body, e.g., "application/json",
|
||||
"application/x-www-form-urlencoded", or "text/plain". If empty, attempts
|
||||
to parse as JSON.
|
||||
|
||||
Returns:
|
||||
Parsed body (dict, str, or None).
|
||||
- JSON: Decodes if content_type is "application/json" or None (fallback).
|
||||
- URL-encoded: Parses if content_type is "application/x-www-form-urlencoded".
|
||||
- Plain text: Returns string if content_type is "text/plain".
|
||||
- None: Returns if body is None, UTF-8 decode fails, or content_type is unknown.
|
||||
"""
|
||||
if body is None:
|
||||
return None
|
||||
try:
|
||||
body_str = body.decode("utf-8")
|
||||
except (UnicodeDecodeError, AttributeError):
|
||||
return None
|
||||
content_type = content_type.lower()
|
||||
if not content_type or "application/json" in content_type:
|
||||
try:
|
||||
return json.loads(body_str)
|
||||
except (TypeError, ValueError):
|
||||
return body_str
|
||||
if "application/x-www-form-urlencoded" in content_type:
|
||||
parsed_query = urllib.parse.parse_qs(body_str)
|
||||
result = {k: v[0] for k, v in parsed_query.items()}
|
||||
return result
|
||||
if "text/plain" in content_type:
|
||||
return body_str
|
||||
return None
|
||||
|
||||
|
||||
def _parse_response(response: Any) -> Any:
|
||||
"""
|
||||
Parses a response, attempting to decode JSON.
|
||||
|
||||
Args:
|
||||
response: The response object to parse. This can be any type, but
|
||||
it is expected to have a `json()` method if it contains JSON.
|
||||
|
||||
Returns:
|
||||
The parsed response. If the response contains valid JSON, the
|
||||
decoded JSON object (e.g., a dictionary or list) is returned.
|
||||
If the response does not have a `json()` method or if the JSON
|
||||
decoding fails, None is returned.
|
||||
"""
|
||||
try:
|
||||
json_response = response.json()
|
||||
return json_response
|
||||
except Exception:
|
||||
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1744):
|
||||
# Parse and return response payload as json based on different content types.
|
||||
return None
|
||||
|
||||
|
||||
def _response_log_base(logger: logging.Logger, parsed_response: Any) -> None:
|
||||
"""
|
||||
Logs a parsed HTTP response at the DEBUG level.
|
||||
|
||||
This internal helper function takes a parsed response and logs it
|
||||
using the provided logger. It also applies a hashing function to
|
||||
potentially sensitive information before logging.
|
||||
|
||||
Args:
|
||||
logger: The logging.Logger instance to use for logging.
|
||||
parsed_response: The parsed HTTP response object (e.g., a dictionary,
|
||||
list, or the original response if parsing failed).
|
||||
"""
|
||||
|
||||
logged_response = _hash_sensitive_info(parsed_response)
|
||||
logger.debug("Response received...", extra={"httpResponse": logged_response})
|
||||
|
||||
|
||||
def response_log(logger: logging.Logger, response: Any) -> None:
|
||||
"""
|
||||
Logs an HTTP response at the DEBUG level if logging is enabled.
|
||||
|
||||
Args:
|
||||
logger: The logging.Logger instance to use.
|
||||
response: The HTTP response object to log.
|
||||
"""
|
||||
if is_logging_enabled(logger):
|
||||
json_response = _parse_response(response)
|
||||
_response_log_base(logger, json_response)
|
||||
164
venv/lib/python3.12/site-packages/google/auth/_jwt_async.py
Normal file
164
venv/lib/python3.12/site-packages/google/auth/_jwt_async.py
Normal file
@@ -0,0 +1,164 @@
|
||||
# Copyright 2020 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.
|
||||
|
||||
"""JSON Web Tokens
|
||||
|
||||
Provides support for creating (encoding) and verifying (decoding) JWTs,
|
||||
especially JWTs generated and consumed by Google infrastructure.
|
||||
|
||||
See `rfc7519`_ for more details on JWTs.
|
||||
|
||||
To encode a JWT use :func:`encode`::
|
||||
|
||||
from google.auth import crypt
|
||||
from google.auth import jwt_async
|
||||
|
||||
signer = crypt.Signer(private_key)
|
||||
payload = {'some': 'payload'}
|
||||
encoded = jwt_async.encode(signer, payload)
|
||||
|
||||
To decode a JWT and verify claims use :func:`decode`::
|
||||
|
||||
claims = jwt_async.decode(encoded, certs=public_certs)
|
||||
|
||||
You can also skip verification::
|
||||
|
||||
claims = jwt_async.decode(encoded, verify=False)
|
||||
|
||||
.. _rfc7519: https://tools.ietf.org/html/rfc7519
|
||||
|
||||
|
||||
NOTE: This async support is experimental and marked internal. This surface may
|
||||
change in minor releases.
|
||||
"""
|
||||
|
||||
from google.auth import _credentials_async
|
||||
from google.auth import jwt
|
||||
|
||||
|
||||
def encode(signer, payload, header=None, key_id=None):
|
||||
"""Make a signed JWT.
|
||||
|
||||
Args:
|
||||
signer (google.auth.crypt.Signer): The signer used to sign the JWT.
|
||||
payload (Mapping[str, str]): The JWT payload.
|
||||
header (Mapping[str, str]): Additional JWT header payload.
|
||||
key_id (str): The key id to add to the JWT header. If the
|
||||
signer has a key id it will be used as the default. If this is
|
||||
specified it will override the signer's key id.
|
||||
|
||||
Returns:
|
||||
bytes: The encoded JWT.
|
||||
"""
|
||||
return jwt.encode(signer, payload, header, key_id)
|
||||
|
||||
|
||||
def decode(token, certs=None, verify=True, audience=None):
|
||||
"""Decode and verify a JWT.
|
||||
|
||||
Args:
|
||||
token (str): The encoded JWT.
|
||||
certs (Union[str, bytes, Mapping[str, Union[str, bytes]]]): The
|
||||
certificate used to validate the JWT signature. If bytes or string,
|
||||
it must the the public key certificate in PEM format. If a mapping,
|
||||
it must be a mapping of key IDs to public key certificates in PEM
|
||||
format. The mapping must contain the same key ID that's specified
|
||||
in the token's header.
|
||||
verify (bool): Whether to perform signature and claim validation.
|
||||
Verification is done by default.
|
||||
audience (str): The audience claim, 'aud', that this JWT should
|
||||
contain. If None then the JWT's 'aud' parameter is not verified.
|
||||
|
||||
Returns:
|
||||
Mapping[str, str]: The deserialized JSON payload in the JWT.
|
||||
|
||||
Raises:
|
||||
ValueError: if any verification checks failed.
|
||||
"""
|
||||
|
||||
return jwt.decode(token, certs, verify, audience)
|
||||
|
||||
|
||||
class Credentials(
|
||||
jwt.Credentials, _credentials_async.Signing, _credentials_async.Credentials
|
||||
):
|
||||
"""Credentials that use a JWT as the bearer token.
|
||||
|
||||
These credentials require an "audience" claim. This claim identifies the
|
||||
intended recipient of the bearer token.
|
||||
|
||||
The constructor arguments determine the claims for the JWT that is
|
||||
sent with requests. Usually, you'll construct these credentials with
|
||||
one of the helper constructors as shown in the next section.
|
||||
|
||||
To create JWT credentials using a Google service account private key
|
||||
JSON file::
|
||||
|
||||
audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Publisher'
|
||||
credentials = jwt_async.Credentials.from_service_account_file(
|
||||
'service-account.json',
|
||||
audience=audience)
|
||||
|
||||
If you already have the service account file loaded and parsed::
|
||||
|
||||
service_account_info = json.load(open('service_account.json'))
|
||||
credentials = jwt_async.Credentials.from_service_account_info(
|
||||
service_account_info,
|
||||
audience=audience)
|
||||
|
||||
Both helper methods pass on arguments to the constructor, so you can
|
||||
specify the JWT claims::
|
||||
|
||||
credentials = jwt_async.Credentials.from_service_account_file(
|
||||
'service-account.json',
|
||||
audience=audience,
|
||||
additional_claims={'meta': 'data'})
|
||||
|
||||
You can also construct the credentials directly if you have a
|
||||
:class:`~google.auth.crypt.Signer` instance::
|
||||
|
||||
credentials = jwt_async.Credentials(
|
||||
signer,
|
||||
issuer='your-issuer',
|
||||
subject='your-subject',
|
||||
audience=audience)
|
||||
|
||||
The claims are considered immutable. If you want to modify the claims,
|
||||
you can easily create another instance using :meth:`with_claims`::
|
||||
|
||||
new_audience = (
|
||||
'https://pubsub.googleapis.com/google.pubsub.v1.Subscriber')
|
||||
new_credentials = credentials.with_claims(audience=new_audience)
|
||||
"""
|
||||
|
||||
|
||||
class OnDemandCredentials(
|
||||
jwt.OnDemandCredentials, _credentials_async.Signing, _credentials_async.Credentials
|
||||
):
|
||||
"""On-demand JWT credentials.
|
||||
|
||||
Like :class:`Credentials`, this class uses a JWT as the bearer token for
|
||||
authentication. However, this class does not require the audience at
|
||||
construction time. Instead, it will generate a new token on-demand for
|
||||
each request using the request URI as the audience. It caches tokens
|
||||
so that multiple requests to the same URI do not incur the overhead
|
||||
of generating a new token every time.
|
||||
|
||||
This behavior is especially useful for `gRPC`_ clients. A gRPC service may
|
||||
have multiple audience and gRPC clients may not know all of the audiences
|
||||
required for accessing a particular service. With these credentials,
|
||||
no knowledge of the audiences is required ahead of time.
|
||||
|
||||
.. _grpc: http://www.grpc.io/
|
||||
"""
|
||||
167
venv/lib/python3.12/site-packages/google/auth/_oauth2client.py
Normal file
167
venv/lib/python3.12/site-packages/google/auth/_oauth2client.py
Normal file
@@ -0,0 +1,167 @@
|
||||
# Copyright 2016 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 transitioning from oauth2client to google-auth.
|
||||
|
||||
.. warning::
|
||||
This module is private as it is intended to assist first-party downstream
|
||||
clients with the transition from oauth2client to google-auth.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
from google.auth import _helpers
|
||||
import google.auth.app_engine
|
||||
import google.auth.compute_engine
|
||||
import google.oauth2.credentials
|
||||
import google.oauth2.service_account
|
||||
|
||||
try:
|
||||
import oauth2client.client # type: ignore
|
||||
import oauth2client.contrib.gce # type: ignore
|
||||
import oauth2client.service_account # type: ignore
|
||||
except ImportError as caught_exc:
|
||||
raise ImportError("oauth2client is not installed.") from caught_exc
|
||||
|
||||
try:
|
||||
import oauth2client.contrib.appengine # type: ignore
|
||||
|
||||
_HAS_APPENGINE = True
|
||||
except ImportError:
|
||||
_HAS_APPENGINE = False
|
||||
|
||||
|
||||
_CONVERT_ERROR_TMPL = "Unable to convert {} to a google-auth credentials class."
|
||||
|
||||
|
||||
def _convert_oauth2_credentials(credentials):
|
||||
"""Converts to :class:`google.oauth2.credentials.Credentials`.
|
||||
|
||||
Args:
|
||||
credentials (Union[oauth2client.client.OAuth2Credentials,
|
||||
oauth2client.client.GoogleCredentials]): The credentials to
|
||||
convert.
|
||||
|
||||
Returns:
|
||||
google.oauth2.credentials.Credentials: The converted credentials.
|
||||
"""
|
||||
new_credentials = google.oauth2.credentials.Credentials(
|
||||
token=credentials.access_token,
|
||||
refresh_token=credentials.refresh_token,
|
||||
token_uri=credentials.token_uri,
|
||||
client_id=credentials.client_id,
|
||||
client_secret=credentials.client_secret,
|
||||
scopes=credentials.scopes,
|
||||
)
|
||||
|
||||
new_credentials._expires = credentials.token_expiry
|
||||
|
||||
return new_credentials
|
||||
|
||||
|
||||
def _convert_service_account_credentials(credentials):
|
||||
"""Converts to :class:`google.oauth2.service_account.Credentials`.
|
||||
|
||||
Args:
|
||||
credentials (Union[
|
||||
oauth2client.service_account.ServiceAccountCredentials,
|
||||
oauth2client.service_account._JWTAccessCredentials]): The
|
||||
credentials to convert.
|
||||
|
||||
Returns:
|
||||
google.oauth2.service_account.Credentials: The converted credentials.
|
||||
"""
|
||||
info = credentials.serialization_data.copy()
|
||||
info["token_uri"] = credentials.token_uri
|
||||
return google.oauth2.service_account.Credentials.from_service_account_info(info)
|
||||
|
||||
|
||||
def _convert_gce_app_assertion_credentials(credentials):
|
||||
"""Converts to :class:`google.auth.compute_engine.Credentials`.
|
||||
|
||||
Args:
|
||||
credentials (oauth2client.contrib.gce.AppAssertionCredentials): The
|
||||
credentials to convert.
|
||||
|
||||
Returns:
|
||||
google.oauth2.service_account.Credentials: The converted credentials.
|
||||
"""
|
||||
return google.auth.compute_engine.Credentials(
|
||||
service_account_email=credentials.service_account_email
|
||||
)
|
||||
|
||||
|
||||
def _convert_appengine_app_assertion_credentials(credentials):
|
||||
"""Converts to :class:`google.auth.app_engine.Credentials`.
|
||||
|
||||
Args:
|
||||
credentials (oauth2client.contrib.app_engine.AppAssertionCredentials):
|
||||
The credentials to convert.
|
||||
|
||||
Returns:
|
||||
google.oauth2.service_account.Credentials: The converted credentials.
|
||||
"""
|
||||
# pylint: disable=invalid-name
|
||||
return google.auth.app_engine.Credentials(
|
||||
scopes=_helpers.string_to_scopes(credentials.scope),
|
||||
service_account_id=credentials.service_account_id,
|
||||
)
|
||||
|
||||
|
||||
_CLASS_CONVERSION_MAP = {
|
||||
oauth2client.client.OAuth2Credentials: _convert_oauth2_credentials,
|
||||
oauth2client.client.GoogleCredentials: _convert_oauth2_credentials,
|
||||
oauth2client.service_account.ServiceAccountCredentials: _convert_service_account_credentials,
|
||||
oauth2client.service_account._JWTAccessCredentials: _convert_service_account_credentials,
|
||||
oauth2client.contrib.gce.AppAssertionCredentials: _convert_gce_app_assertion_credentials,
|
||||
}
|
||||
|
||||
if _HAS_APPENGINE: # pragma: no cover
|
||||
_CLASS_CONVERSION_MAP[
|
||||
oauth2client.contrib.appengine.AppAssertionCredentials
|
||||
] = _convert_appengine_app_assertion_credentials
|
||||
|
||||
|
||||
def convert(credentials):
|
||||
"""Convert oauth2client credentials to google-auth credentials.
|
||||
|
||||
This class converts:
|
||||
|
||||
- :class:`oauth2client.client.OAuth2Credentials` to
|
||||
:class:`google.oauth2.credentials.Credentials`.
|
||||
- :class:`oauth2client.client.GoogleCredentials` to
|
||||
:class:`google.oauth2.credentials.Credentials`.
|
||||
- :class:`oauth2client.service_account.ServiceAccountCredentials` to
|
||||
:class:`google.oauth2.service_account.Credentials`.
|
||||
- :class:`oauth2client.service_account._JWTAccessCredentials` to
|
||||
:class:`google.oauth2.service_account.Credentials`.
|
||||
- :class:`oauth2client.contrib.gce.AppAssertionCredentials` to
|
||||
:class:`google.auth.compute_engine.Credentials`.
|
||||
- :class:`oauth2client.contrib.appengine.AppAssertionCredentials` to
|
||||
:class:`google.auth.app_engine.Credentials`.
|
||||
|
||||
Returns:
|
||||
google.auth.credentials.Credentials: The converted credentials.
|
||||
|
||||
Raises:
|
||||
ValueError: If the credentials could not be converted.
|
||||
"""
|
||||
|
||||
credentials_class = type(credentials)
|
||||
|
||||
try:
|
||||
return _CLASS_CONVERSION_MAP[credentials_class](credentials)
|
||||
except KeyError as caught_exc:
|
||||
new_exc = ValueError(_CONVERT_ERROR_TMPL.format(credentials_class))
|
||||
raise new_exc from caught_exc
|
||||
109
venv/lib/python3.12/site-packages/google/auth/_refresh_worker.py
Normal file
109
venv/lib/python3.12/site-packages/google/auth/_refresh_worker.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# Copyright 2023 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 copy
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import google.auth.exceptions as e
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RefreshThreadManager:
|
||||
"""
|
||||
Organizes exactly one background job that refresh a token.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initializes the manager."""
|
||||
|
||||
self._worker = None
|
||||
self._lock = threading.Lock() # protects access to worker threads.
|
||||
|
||||
def start_refresh(self, cred, request):
|
||||
"""Starts a refresh thread for the given credentials.
|
||||
The credentials are refreshed using the request parameter.
|
||||
request and cred MUST not be None
|
||||
|
||||
Returns True if a background refresh was kicked off. False otherwise.
|
||||
|
||||
Args:
|
||||
cred: A credentials object.
|
||||
request: A request object.
|
||||
Returns:
|
||||
bool
|
||||
"""
|
||||
if cred is None or request is None:
|
||||
raise e.InvalidValue(
|
||||
"Unable to start refresh. cred and request must be valid and instantiated objects."
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
if self._worker is not None and self._worker._error_info is not None:
|
||||
return False
|
||||
|
||||
if self._worker is None or not self._worker.is_alive(): # pragma: NO COVER
|
||||
self._worker = RefreshThread(cred=cred, request=copy.deepcopy(request))
|
||||
self._worker.start()
|
||||
return True
|
||||
|
||||
def clear_error(self):
|
||||
"""
|
||||
Removes any errors that were stored from previous background refreshes.
|
||||
"""
|
||||
with self._lock:
|
||||
if self._worker:
|
||||
self._worker._error_info = None
|
||||
|
||||
def __getstate__(self):
|
||||
"""Pickle helper that serializes the _lock attribute."""
|
||||
state = self.__dict__.copy()
|
||||
state["_lock"] = None
|
||||
return state
|
||||
|
||||
def __setstate__(self, state):
|
||||
"""Pickle helper that deserializes the _lock attribute."""
|
||||
state["_lock"] = threading.Lock()
|
||||
self.__dict__.update(state)
|
||||
|
||||
|
||||
class RefreshThread(threading.Thread):
|
||||
"""
|
||||
Thread that refreshes credentials.
|
||||
"""
|
||||
|
||||
def __init__(self, cred, request, **kwargs):
|
||||
"""Initializes the thread.
|
||||
|
||||
Args:
|
||||
cred: A Credential object to refresh.
|
||||
request: A Request object used to perform a credential refresh.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
super().__init__(**kwargs)
|
||||
self._cred = cred
|
||||
self._request = request
|
||||
self._error_info = None
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Perform the credential refresh.
|
||||
"""
|
||||
try:
|
||||
self._cred.refresh(self._request)
|
||||
except Exception as err: # pragma: NO COVER
|
||||
_LOGGER.error(f"Background refresh failed due to: {err}")
|
||||
self._error_info = err
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Helper functions for loading data from a Google service account file."""
|
||||
|
||||
import io
|
||||
import json
|
||||
|
||||
from google.auth import crypt
|
||||
from google.auth import exceptions
|
||||
|
||||
|
||||
def from_dict(data, require=None, use_rsa_signer=True):
|
||||
"""Validates a dictionary containing Google service account data.
|
||||
|
||||
Creates and returns a :class:`google.auth.crypt.Signer` instance from the
|
||||
private key specified in the data.
|
||||
|
||||
Args:
|
||||
data (Mapping[str, str]): The service account data
|
||||
require (Sequence[str]): List of keys required to be present in the
|
||||
info.
|
||||
use_rsa_signer (Optional[bool]): Whether to use RSA signer or EC signer.
|
||||
We use RSA signer by default.
|
||||
|
||||
Returns:
|
||||
google.auth.crypt.Signer: A signer created from the private key in the
|
||||
service account file.
|
||||
|
||||
Raises:
|
||||
MalformedError: if the data was in the wrong format, or if one of the
|
||||
required keys is missing.
|
||||
"""
|
||||
keys_needed = set(require if require is not None else [])
|
||||
|
||||
missing = keys_needed.difference(data.keys())
|
||||
|
||||
if missing:
|
||||
raise exceptions.MalformedError(
|
||||
"Service account info was not in the expected format, missing "
|
||||
"fields {}.".format(", ".join(missing))
|
||||
)
|
||||
|
||||
# Create a signer.
|
||||
if use_rsa_signer:
|
||||
signer = crypt.RSASigner.from_service_account_info(data)
|
||||
else:
|
||||
signer = crypt.EsSigner.from_service_account_info(data)
|
||||
|
||||
return signer
|
||||
|
||||
|
||||
def from_filename(filename, require=None, use_rsa_signer=True):
|
||||
"""Reads a Google service account JSON file and returns its parsed info.
|
||||
|
||||
Args:
|
||||
filename (str): The path to the service account .json file.
|
||||
require (Sequence[str]): List of keys required to be present in the
|
||||
info.
|
||||
use_rsa_signer (Optional[bool]): Whether to use RSA signer or EC signer.
|
||||
We use RSA signer by default.
|
||||
|
||||
Returns:
|
||||
Tuple[ Mapping[str, str], google.auth.crypt.Signer ]: The verified
|
||||
info and a signer instance.
|
||||
"""
|
||||
with io.open(filename, "r", encoding="utf-8") as json_file:
|
||||
data = json.load(json_file)
|
||||
return data, from_dict(data, require=require, use_rsa_signer=use_rsa_signer)
|
||||
@@ -0,0 +1,25 @@
|
||||
# Copyright 2024 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.
|
||||
|
||||
"""Google Auth AIO Library for Python."""
|
||||
|
||||
import logging
|
||||
|
||||
from google.auth import version as google_auth_version
|
||||
|
||||
|
||||
__version__ = google_auth_version.__version__
|
||||
|
||||
# Set default logging handler to avoid "No handler found" warnings.
|
||||
logging.getLogger(__name__).addHandler(logging.NullHandler())
|
||||
@@ -0,0 +1,62 @@
|
||||
# Copyright 2025 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.
|
||||
|
||||
"""Helper functions for commonly used utilities."""
|
||||
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from google.auth import _helpers
|
||||
|
||||
|
||||
async def _parse_response_async(response: Any) -> Any:
|
||||
"""
|
||||
Parses an async response, attempting to decode JSON.
|
||||
|
||||
Args:
|
||||
response: The response object to parse. This can be any type, but
|
||||
it is expected to have a `json()` method if it contains JSON.
|
||||
|
||||
Returns:
|
||||
The parsed response. If the response contains valid JSON, the
|
||||
decoded JSON object (e.g., a dictionary) is returned.
|
||||
If the response does not have a `json()` method or if the JSON
|
||||
decoding fails, None is returned.
|
||||
"""
|
||||
try:
|
||||
json_response = await response.json()
|
||||
return json_response
|
||||
except Exception:
|
||||
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1745):
|
||||
# Parse and return response payload as json based on different content types.
|
||||
return None
|
||||
|
||||
|
||||
async def response_log_async(logger: logging.Logger, response: Any) -> None:
|
||||
"""
|
||||
Logs an Async HTTP response at the DEBUG level if logging is enabled.
|
||||
|
||||
Args:
|
||||
logger: The logging.Logger instance to use.
|
||||
response: The HTTP response object to log.
|
||||
"""
|
||||
if _helpers.is_logging_enabled(logger):
|
||||
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1755):
|
||||
# Parsing the response for async streaming logging results in
|
||||
# the stream to be empty downstream. For now, we will not be logging
|
||||
# the response for async responses until we investigate further.
|
||||
# json_response = await _parse_response_async(response)
|
||||
json_response = None
|
||||
_helpers._response_log_base(logger, json_response)
|
||||
143
venv/lib/python3.12/site-packages/google/auth/aio/credentials.py
Normal file
143
venv/lib/python3.12/site-packages/google/auth/aio/credentials.py
Normal file
@@ -0,0 +1,143 @@
|
||||
# Copyright 2024 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.
|
||||
|
||||
|
||||
"""Interfaces for asynchronous credentials."""
|
||||
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import exceptions
|
||||
from google.auth._credentials_base import _BaseCredentials
|
||||
|
||||
|
||||
class Credentials(_BaseCredentials):
|
||||
"""Base class for all asynchronous credentials.
|
||||
|
||||
All credentials have a :attr:`token` that is used for authentication and
|
||||
may also optionally set an :attr:`expiry` to indicate when the token will
|
||||
no longer be valid.
|
||||
|
||||
Most credentials will be :attr:`invalid` until :meth:`refresh` is called.
|
||||
Credentials can do this automatically before the first HTTP request in
|
||||
:meth:`before_request`.
|
||||
|
||||
Although the token and expiration will change as the credentials are
|
||||
:meth:`refreshed <refresh>` and used, credentials should be considered
|
||||
immutable. Various credentials will accept configuration such as private
|
||||
keys, scopes, and other options. These options are not changeable after
|
||||
construction. Some classes will provide mechanisms to copy the credentials
|
||||
with modifications such as :meth:`ScopedCredentials.with_scopes`.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(Credentials, self).__init__()
|
||||
|
||||
async def apply(self, headers, token=None):
|
||||
"""Apply the token to the authentication header.
|
||||
|
||||
Args:
|
||||
headers (Mapping): The HTTP request headers.
|
||||
token (Optional[str]): If specified, overrides the current access
|
||||
token.
|
||||
"""
|
||||
self._apply(headers, token=token)
|
||||
|
||||
async def refresh(self, request):
|
||||
"""Refreshes the access token.
|
||||
|
||||
Args:
|
||||
request (google.auth.aio.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the credentials could
|
||||
not be refreshed.
|
||||
"""
|
||||
raise NotImplementedError("Refresh must be implemented")
|
||||
|
||||
async def before_request(self, request, method, url, headers):
|
||||
"""Performs credential-specific before request logic.
|
||||
|
||||
Refreshes the credentials if necessary, then calls :meth:`apply` to
|
||||
apply the token to the authentication header.
|
||||
|
||||
Args:
|
||||
request (google.auth.aio.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
method (str): The request's HTTP method or the RPC method being
|
||||
invoked.
|
||||
url (str): The request's URI or the RPC service's URI.
|
||||
headers (Mapping): The request's headers.
|
||||
"""
|
||||
await self.apply(headers)
|
||||
|
||||
|
||||
class StaticCredentials(Credentials):
|
||||
"""Asynchronous Credentials representing an immutable access token.
|
||||
|
||||
The credentials are considered immutable except the tokens which can be
|
||||
configured in the constructor ::
|
||||
|
||||
credentials = StaticCredentials(token="token123")
|
||||
|
||||
StaticCredentials does not support :meth `refresh` and assumes that the configured
|
||||
token is valid and not expired. StaticCredentials will never attempt to
|
||||
refresh the token.
|
||||
"""
|
||||
|
||||
def __init__(self, token):
|
||||
"""
|
||||
Args:
|
||||
token (str): The access token.
|
||||
"""
|
||||
super(StaticCredentials, self).__init__()
|
||||
self.token = token
|
||||
|
||||
@_helpers.copy_docstring(Credentials)
|
||||
async def refresh(self, request):
|
||||
raise exceptions.InvalidOperation("Static credentials cannot be refreshed.")
|
||||
|
||||
# Note: before_request should never try to refresh access tokens.
|
||||
# StaticCredentials intentionally does not support it.
|
||||
@_helpers.copy_docstring(Credentials)
|
||||
async def before_request(self, request, method, url, headers):
|
||||
await self.apply(headers)
|
||||
|
||||
|
||||
class AnonymousCredentials(Credentials):
|
||||
"""Asynchronous Credentials that do not provide any authentication information.
|
||||
|
||||
These are useful in the case of services that support anonymous access or
|
||||
local service emulators that do not use credentials.
|
||||
"""
|
||||
|
||||
async def refresh(self, request):
|
||||
"""Raises :class:``InvalidOperation``, anonymous credentials cannot be
|
||||
refreshed."""
|
||||
raise exceptions.InvalidOperation("Anonymous credentials cannot be refreshed.")
|
||||
|
||||
async def apply(self, headers, token=None):
|
||||
"""Anonymous credentials do nothing to the request.
|
||||
|
||||
The optional ``token`` argument is not supported.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidValue: If a token was specified.
|
||||
"""
|
||||
if token is not None:
|
||||
raise exceptions.InvalidValue("Anonymous credentials don't support tokens.")
|
||||
|
||||
async def before_request(self, request, method, url, headers):
|
||||
"""Anonymous credentials do nothing to the request."""
|
||||
pass
|
||||
@@ -0,0 +1,144 @@
|
||||
# Copyright 2024 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.
|
||||
|
||||
"""Transport - Asynchronous HTTP client library support.
|
||||
|
||||
:mod:`google.auth.aio` is designed to work with various asynchronous client libraries such
|
||||
as aiohttp. In order to work across these libraries with different
|
||||
interfaces some abstraction is needed.
|
||||
|
||||
This module provides two interfaces that are implemented by transport adapters
|
||||
to support HTTP libraries. :class:`Request` defines the interface expected by
|
||||
:mod:`google.auth` to make asynchronous requests. :class:`Response` defines the interface
|
||||
for the return value of :class:`Request`.
|
||||
"""
|
||||
|
||||
import abc
|
||||
from typing import AsyncGenerator, Mapping, Optional
|
||||
|
||||
import google.auth.transport
|
||||
|
||||
|
||||
_DEFAULT_TIMEOUT_SECONDS = 180
|
||||
|
||||
DEFAULT_RETRYABLE_STATUS_CODES = google.auth.transport.DEFAULT_RETRYABLE_STATUS_CODES
|
||||
"""Sequence[int]: HTTP status codes indicating a request can be retried.
|
||||
"""
|
||||
|
||||
|
||||
DEFAULT_MAX_RETRY_ATTEMPTS = 3
|
||||
"""int: How many times to retry a request."""
|
||||
|
||||
|
||||
class Response(metaclass=abc.ABCMeta):
|
||||
"""Asynchronous HTTP Response Interface."""
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def status_code(self) -> int:
|
||||
"""
|
||||
The HTTP response status code.
|
||||
|
||||
Returns:
|
||||
int: The HTTP response status code.
|
||||
|
||||
"""
|
||||
raise NotImplementedError("status_code must be implemented.")
|
||||
|
||||
@property
|
||||
@abc.abstractmethod
|
||||
def headers(self) -> Mapping[str, str]:
|
||||
"""The HTTP response headers.
|
||||
|
||||
Returns:
|
||||
Mapping[str, str]: The HTTP response headers.
|
||||
"""
|
||||
raise NotImplementedError("headers must be implemented.")
|
||||
|
||||
@abc.abstractmethod
|
||||
async def content(self, chunk_size: int) -> AsyncGenerator[bytes, None]:
|
||||
"""The raw response content.
|
||||
|
||||
Args:
|
||||
chunk_size (int): The size of each chunk.
|
||||
|
||||
Yields:
|
||||
AsyncGenerator[bytes, None]: An asynchronous generator yielding
|
||||
response chunks as bytes.
|
||||
"""
|
||||
raise NotImplementedError("content must be implemented.")
|
||||
|
||||
@abc.abstractmethod
|
||||
async def read(self) -> bytes:
|
||||
"""Read the entire response content as bytes.
|
||||
|
||||
Returns:
|
||||
bytes: The entire response content.
|
||||
"""
|
||||
raise NotImplementedError("read must be implemented.")
|
||||
|
||||
@abc.abstractmethod
|
||||
async def close(self):
|
||||
"""Close the response after it is fully consumed to resource."""
|
||||
raise NotImplementedError("close must be implemented.")
|
||||
|
||||
|
||||
class Request(metaclass=abc.ABCMeta):
|
||||
"""Interface for a callable that makes HTTP requests.
|
||||
|
||||
Specific transport implementations should provide an implementation of
|
||||
this that adapts their specific request / response API.
|
||||
|
||||
.. automethod:: __call__
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
async def __call__(
|
||||
self,
|
||||
url: str,
|
||||
method: str,
|
||||
body: Optional[bytes],
|
||||
headers: Optional[Mapping[str, str]],
|
||||
timeout: float,
|
||||
**kwargs
|
||||
) -> Response:
|
||||
"""Make an HTTP request.
|
||||
|
||||
Args:
|
||||
url (str): The URI to be requested.
|
||||
method (str): The HTTP method to use for the request. Defaults
|
||||
to 'GET'.
|
||||
body (Optional[bytes]): The payload / body in HTTP request.
|
||||
headers (Mapping[str, str]): Request headers.
|
||||
timeout (float): The number of seconds to wait for a
|
||||
response from the server. If not specified or if None, the
|
||||
transport-specific default timeout will be used.
|
||||
kwargs: Additional arguments passed on to the transport's
|
||||
request method.
|
||||
|
||||
Returns:
|
||||
google.auth.aio.transport.Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: If any exception occurred.
|
||||
"""
|
||||
# pylint: disable=redundant-returns-doc, missing-raises-doc
|
||||
# (pylint doesn't play well with abstract docstrings.)
|
||||
raise NotImplementedError("__call__ must be implemented.")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Close the underlying session.
|
||||
"""
|
||||
raise NotImplementedError("close must be implemented.")
|
||||
@@ -0,0 +1,205 @@
|
||||
# Copyright 2024 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.
|
||||
|
||||
"""Transport adapter for Asynchronous HTTP Requests based on aiohttp."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import AsyncGenerator, Mapping, Optional, TYPE_CHECKING, Union
|
||||
|
||||
try:
|
||||
import aiohttp # type: ignore
|
||||
except ImportError as caught_exc: # pragma: NO COVER
|
||||
raise ImportError(
|
||||
"The aiohttp library is not installed from please install the aiohttp package to use the aiohttp transport."
|
||||
) from caught_exc
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import exceptions
|
||||
from google.auth.aio import _helpers as _helpers_async
|
||||
from google.auth.aio import transport
|
||||
|
||||
if TYPE_CHECKING: # pragma: NO COVER
|
||||
from aiohttp import ClientTimeout # type: ignore
|
||||
|
||||
else:
|
||||
try:
|
||||
from aiohttp import ClientTimeout
|
||||
except (ImportError, AttributeError):
|
||||
ClientTimeout = None
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Response(transport.Response):
|
||||
"""
|
||||
Represents an HTTP response and its data. It is returned by ``google.auth.aio.transport.sessions.AsyncAuthorizedSession``.
|
||||
|
||||
Args:
|
||||
response (aiohttp.ClientResponse): An instance of aiohttp.ClientResponse.
|
||||
|
||||
Attributes:
|
||||
status_code (int): The HTTP status code of the response.
|
||||
headers (Mapping[str, str]): The HTTP headers of the response.
|
||||
"""
|
||||
|
||||
def __init__(self, response: aiohttp.ClientResponse):
|
||||
self._response = response
|
||||
|
||||
@property
|
||||
@_helpers.copy_docstring(transport.Response)
|
||||
def status_code(self) -> int:
|
||||
return self._response.status
|
||||
|
||||
@property
|
||||
@_helpers.copy_docstring(transport.Response)
|
||||
def headers(self) -> Mapping[str, str]:
|
||||
return {key: value for key, value in self._response.headers.items()}
|
||||
|
||||
@_helpers.copy_docstring(transport.Response)
|
||||
async def content(self, chunk_size: int = 1024) -> AsyncGenerator[bytes, None]:
|
||||
try:
|
||||
async for chunk in self._response.content.iter_chunked(
|
||||
chunk_size
|
||||
): # pragma: no branch
|
||||
yield chunk
|
||||
except aiohttp.ClientPayloadError as exc:
|
||||
raise exceptions.ResponseError(
|
||||
"Failed to read from the payload stream."
|
||||
) from exc
|
||||
|
||||
@_helpers.copy_docstring(transport.Response)
|
||||
async def read(self) -> bytes:
|
||||
try:
|
||||
return await self._response.read()
|
||||
except aiohttp.ClientResponseError as exc:
|
||||
raise exceptions.ResponseError("Failed to read the response body.") from exc
|
||||
|
||||
@_helpers.copy_docstring(transport.Response)
|
||||
async def close(self):
|
||||
self._response.close()
|
||||
|
||||
|
||||
class Request(transport.Request):
|
||||
"""Asynchronous Requests request adapter.
|
||||
|
||||
This class is used internally for making requests using aiohttp
|
||||
in a consistent way. If you use :class:`google.auth.aio.transport.sessions.AsyncAuthorizedSession`
|
||||
you do not need to construct or use this class directly.
|
||||
|
||||
This class can be useful if you want to configure a Request callable
|
||||
with a custom ``aiohttp.ClientSession`` in :class:`AuthorizedSession` or if
|
||||
you want to manually refresh a :class:`~google.auth.aio.credentials.Credentials` instance::
|
||||
|
||||
import aiohttp
|
||||
import google.auth.aio.transport.aiohttp
|
||||
|
||||
# Default example:
|
||||
request = google.auth.aio.transport.aiohttp.Request()
|
||||
await credentials.refresh(request)
|
||||
|
||||
# Custom aiohttp Session Example:
|
||||
session = session=aiohttp.ClientSession(auto_decompress=False)
|
||||
request = google.auth.aio.transport.aiohttp.Request(session=session)
|
||||
auth_session = google.auth.aio.transport.sessions.AsyncAuthorizedSession(auth_request=request)
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): An instance :class:`aiohttp.ClientSession` used
|
||||
to make HTTP requests. If not specified, a session will be created.
|
||||
|
||||
.. automethod:: __call__
|
||||
"""
|
||||
|
||||
def __init__(self, session: Optional[aiohttp.ClientSession] = None):
|
||||
self._session = session
|
||||
self._closed = False
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
body: Optional[bytes] = None,
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
timeout: Union[float, ClientTimeout] = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
**kwargs,
|
||||
) -> transport.Response:
|
||||
"""
|
||||
Make an HTTP request using aiohttp.
|
||||
|
||||
Args:
|
||||
url (str): The URL to be requested.
|
||||
method (Optional[str]):
|
||||
The HTTP method to use for the request. Defaults to 'GET'.
|
||||
body (Optional[bytes]):
|
||||
The payload or body in HTTP request.
|
||||
headers (Optional[Mapping[str, str]]):
|
||||
Request headers.
|
||||
timeout (float): The number of seconds to wait for a
|
||||
response from the server. If not specified or if None, the
|
||||
requests default timeout will be used.
|
||||
kwargs: Additional arguments passed through to the underlying
|
||||
aiohttp :meth:`aiohttp.Session.request` method.
|
||||
|
||||
Returns:
|
||||
google.auth.aio.transport.Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
- google.auth.exceptions.TransportError: If the request fails or if the session is closed.
|
||||
- google.auth.exceptions.TimeoutError: If the request times out.
|
||||
"""
|
||||
|
||||
try:
|
||||
if self._closed:
|
||||
raise exceptions.TransportError("session is closed.")
|
||||
|
||||
if not self._session:
|
||||
self._session = aiohttp.ClientSession()
|
||||
|
||||
if isinstance(timeout, aiohttp.ClientTimeout):
|
||||
client_timeout = timeout
|
||||
else:
|
||||
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
||||
_helpers.request_log(_LOGGER, method, url, body, headers)
|
||||
response = await self._session.request(
|
||||
method,
|
||||
url,
|
||||
data=body,
|
||||
headers=headers,
|
||||
timeout=client_timeout,
|
||||
**kwargs,
|
||||
)
|
||||
await _helpers_async.response_log_async(_LOGGER, response)
|
||||
return Response(response)
|
||||
|
||||
except aiohttp.ClientError as caught_exc:
|
||||
client_exc = exceptions.TransportError(f"Failed to send request to {url}.")
|
||||
raise client_exc from caught_exc
|
||||
|
||||
except asyncio.TimeoutError as caught_exc:
|
||||
if isinstance(timeout, aiohttp.ClientTimeout):
|
||||
timeout_seconds = timeout.total
|
||||
else:
|
||||
timeout_seconds = timeout
|
||||
timeout_exc = exceptions.TimeoutError(
|
||||
f"Request timed out after {timeout_seconds} seconds."
|
||||
)
|
||||
raise timeout_exc from caught_exc
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Close the underlying aiohttp session to release the acquired resources.
|
||||
"""
|
||||
if not self._closed and self._session:
|
||||
await self._session.close()
|
||||
self._closed = True
|
||||
@@ -0,0 +1,197 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""
|
||||
Helper functions for mTLS in async for discovery of certs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
from google.auth import exceptions
|
||||
import google.auth.transport._mtls_helper
|
||||
import google.auth.transport.mtls
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _create_temp_file(content: bytes):
|
||||
"""Creates a temporary file with the given content.
|
||||
|
||||
Args:
|
||||
content (bytes): The content to write to the file.
|
||||
|
||||
Yields:
|
||||
str: The path to the temporary file.
|
||||
"""
|
||||
# Create a temporary file that is readable only by the owner.
|
||||
fd, file_path = tempfile.mkstemp()
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
f.write(content)
|
||||
yield file_path
|
||||
finally:
|
||||
# Securely delete the file after use.
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
|
||||
def make_client_cert_ssl_context(
|
||||
cert_bytes: bytes, key_bytes: bytes, passphrase: Optional[bytes] = None
|
||||
) -> ssl.SSLContext:
|
||||
"""Creates an SSLContext with the given client certificate and key.
|
||||
This function writes the certificate and key to temporary files so that
|
||||
ssl.create_default_context can load them, as the ssl module requires
|
||||
file paths for client certificates. These temporary files are deleted
|
||||
immediately after the SSL context is created.
|
||||
Args:
|
||||
cert_bytes (bytes): The client certificate content in PEM format.
|
||||
key_bytes (bytes): The client private key content in PEM format.
|
||||
passphrase (Optional[bytes]): The passphrase for the private key, if any.
|
||||
Returns:
|
||||
ssl.SSLContext: The configured SSL context with client certificate.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: If there is an error loading the certificate.
|
||||
"""
|
||||
with _create_temp_file(cert_bytes) as cert_path, _create_temp_file(
|
||||
key_bytes
|
||||
) as key_path:
|
||||
try:
|
||||
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
||||
context.load_cert_chain(
|
||||
certfile=cert_path, keyfile=key_path, password=passphrase
|
||||
)
|
||||
return context
|
||||
except (ssl.SSLError, OSError, IOError, ValueError, RuntimeError) as exc:
|
||||
raise exceptions.TransportError(
|
||||
"Failed to load client certificate and key for mTLS."
|
||||
) from exc
|
||||
|
||||
|
||||
async def _run_in_executor(func, *args):
|
||||
"""Run a blocking function in an executor to avoid blocking the event loop.
|
||||
|
||||
This implements the non-blocking execution strategy for disk I/O operations.
|
||||
"""
|
||||
try:
|
||||
# For python versions 3.9 and newer versions
|
||||
return await asyncio.to_thread(func, *args)
|
||||
except AttributeError:
|
||||
# Fallback for older Python versions
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(None, func, *args)
|
||||
|
||||
|
||||
def default_client_cert_source():
|
||||
"""Get a callback which returns the default client SSL credentials.
|
||||
|
||||
Returns:
|
||||
Awaitable[Callable[[], Tuple[bytes, bytes]]]: A callback which returns the default
|
||||
client certificate bytes and private key bytes, both in PEM format.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.DefaultClientCertSourceError: If the default
|
||||
client SSL credentials don't exist or are malformed.
|
||||
"""
|
||||
if not google.auth.transport.mtls.has_default_client_cert_source(
|
||||
include_context_aware=False
|
||||
):
|
||||
raise exceptions.MutualTLSChannelError(
|
||||
"Default client cert source doesn't exist"
|
||||
)
|
||||
|
||||
async def callback():
|
||||
try:
|
||||
_, cert_bytes, key_bytes = await get_client_cert_and_key()
|
||||
except (OSError, RuntimeError, ValueError) as caught_exc:
|
||||
new_exc = exceptions.MutualTLSChannelError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
return cert_bytes, key_bytes
|
||||
|
||||
return callback
|
||||
|
||||
|
||||
async def get_client_ssl_credentials(
|
||||
certificate_config_path=None,
|
||||
):
|
||||
"""Returns the client side certificate, private key and passphrase.
|
||||
|
||||
We look for certificates and keys with the following order of priority:
|
||||
1. Certificate and key specified by certificate_config.json.
|
||||
Currently, only X.509 workload certificates are supported.
|
||||
|
||||
Args:
|
||||
certificate_config_path (str): The certificate_config.json file path.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, bytes, bytes, bytes]:
|
||||
A boolean indicating if cert, key and passphrase are obtained, the
|
||||
cert bytes and key bytes both in PEM format, and passphrase bytes.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.ClientCertError: if problems occurs when getting
|
||||
the cert, key and passphrase.
|
||||
"""
|
||||
|
||||
# Attempt to retrieve X.509 Workload cert and key.
|
||||
cert, key = await _run_in_executor(
|
||||
google.auth.transport._mtls_helper._get_workload_cert_and_key,
|
||||
certificate_config_path,
|
||||
False,
|
||||
)
|
||||
|
||||
if cert and key:
|
||||
return True, cert, key, None
|
||||
|
||||
return False, None, None, None
|
||||
|
||||
|
||||
async def get_client_cert_and_key(client_cert_callback=None):
|
||||
"""Returns the client side certificate and private key. The function first
|
||||
tries to get certificate and key from client_cert_callback; if the callback
|
||||
is None or doesn't provide certificate and key, the function tries application
|
||||
default SSL credentials.
|
||||
|
||||
Args:
|
||||
client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): An
|
||||
optional callback which returns client certificate bytes and private
|
||||
key bytes both in PEM format.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, bytes, bytes]:
|
||||
A boolean indicating if cert and key are obtained, the cert bytes
|
||||
and key bytes both in PEM format.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.ClientCertError: if problems occurs when getting
|
||||
the cert and key.
|
||||
"""
|
||||
if client_cert_callback:
|
||||
result = client_cert_callback()
|
||||
try:
|
||||
cert, key = await result
|
||||
except TypeError:
|
||||
cert, key = result
|
||||
return True, cert, key
|
||||
|
||||
has_cert, cert, key, _ = await get_client_ssl_credentials()
|
||||
return has_cert, cert, key
|
||||
@@ -0,0 +1,576 @@
|
||||
# Copyright 2024 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 asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
import functools
|
||||
import time
|
||||
from typing import Mapping, Optional, TYPE_CHECKING, Union
|
||||
|
||||
from google.auth import _exponential_backoff, exceptions
|
||||
from google.auth.aio import transport
|
||||
from google.auth.aio.credentials import Credentials
|
||||
from google.auth.aio.transport import mtls
|
||||
from google.auth.exceptions import TimeoutError
|
||||
import google.auth.transport._mtls_helper
|
||||
|
||||
if TYPE_CHECKING: # pragma: NO COVER
|
||||
import aiohttp
|
||||
from aiohttp import ClientTimeout # type: ignore
|
||||
|
||||
else:
|
||||
try:
|
||||
import aiohttp
|
||||
from aiohttp import ClientTimeout
|
||||
except (ImportError, AttributeError):
|
||||
ClientTimeout = None
|
||||
|
||||
# Tracks the internal aiohttp installation and usage
|
||||
try:
|
||||
from google.auth.aio.transport.aiohttp import Request as AiohttpRequest
|
||||
|
||||
AIOHTTP_INSTALLED = True
|
||||
except ImportError: # pragma: NO COVER
|
||||
AIOHTTP_INSTALLED = False
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def timeout_guard(timeout):
|
||||
"""
|
||||
timeout_guard is an asynchronous context manager to apply a timeout to an asynchronous block of code.
|
||||
|
||||
Args:
|
||||
timeout (float): The time in seconds before the context manager times out.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TimeoutError: If the code within the context exceeds the provided timeout.
|
||||
|
||||
Usage:
|
||||
async with timeout_guard(10) as with_timeout:
|
||||
await with_timeout(async_function())
|
||||
"""
|
||||
start = time.monotonic()
|
||||
total_timeout = timeout
|
||||
|
||||
def _remaining_time():
|
||||
elapsed = time.monotonic() - start
|
||||
remaining = total_timeout - elapsed
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(
|
||||
f"Context manager exceeded the configured timeout of {total_timeout}s."
|
||||
)
|
||||
return remaining
|
||||
|
||||
async def with_timeout(coro):
|
||||
try:
|
||||
remaining = _remaining_time()
|
||||
response = await asyncio.wait_for(coro, remaining)
|
||||
return response
|
||||
except (asyncio.TimeoutError, TimeoutError) as e:
|
||||
raise TimeoutError(
|
||||
f"The operation {coro} exceeded the configured timeout of {total_timeout}s."
|
||||
) from e
|
||||
|
||||
try:
|
||||
yield with_timeout
|
||||
|
||||
finally:
|
||||
_remaining_time()
|
||||
|
||||
|
||||
class AsyncAuthorizedSession:
|
||||
"""This is an asynchronous implementation of :class:`google.auth.requests.AuthorizedSession` class.
|
||||
We utilize an instance of a class that implements :class:`google.auth.aio.transport.Request` configured
|
||||
by the caller or otherwise default to `google.auth.aio.transport.aiohttp.Request` if the external aiohttp
|
||||
package is installed.
|
||||
|
||||
A Requests Session class with credentials.
|
||||
|
||||
This class is used to perform asynchronous requests to API endpoints that require
|
||||
authorization::
|
||||
|
||||
import aiohttp
|
||||
from google.auth.aio.transport import sessions
|
||||
|
||||
async with sessions.AsyncAuthorizedSession(credentials) as authed_session:
|
||||
response = await authed_session.request(
|
||||
'GET', 'https://www.googleapis.com/storage/v1/b')
|
||||
|
||||
The underlying :meth:`request` implementation handles adding the
|
||||
credentials' headers to the request and refreshing credentials as needed.
|
||||
|
||||
Args:
|
||||
credentials (google.auth.aio.credentials.Credentials):
|
||||
The credentials to add to the request.
|
||||
auth_request (Optional[google.auth.aio.transport.Request]):
|
||||
An instance of a class that implements
|
||||
:class:`~google.auth.aio.transport.Request` used to make requests
|
||||
and refresh credentials. If not passed,
|
||||
an instance of :class:`~google.auth.aio.transport.aiohttp.Request`
|
||||
is created.
|
||||
|
||||
Raises:
|
||||
- google.auth.exceptions.TransportError: If `auth_request` is `None`
|
||||
and the external package `aiohttp` is not installed.
|
||||
- google.auth.exceptions.InvalidType: If the provided credentials are
|
||||
not of type `google.auth.aio.credentials.Credentials`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, credentials: Credentials, auth_request: Optional[transport.Request] = None
|
||||
):
|
||||
if not isinstance(credentials, Credentials):
|
||||
raise exceptions.InvalidType(
|
||||
f"The configured credentials of type {type(credentials)} are invalid and must be of type `google.auth.aio.credentials.Credentials`"
|
||||
)
|
||||
self._credentials = credentials
|
||||
_auth_request = auth_request
|
||||
if not _auth_request and AIOHTTP_INSTALLED:
|
||||
_auth_request = AiohttpRequest()
|
||||
self._is_mtls = False
|
||||
self._mtls_init_task = None
|
||||
self._cached_cert = None
|
||||
if _auth_request is None:
|
||||
raise exceptions.TransportError(
|
||||
"`auth_request` must either be configured or the external package `aiohttp` must be installed to use the default value."
|
||||
)
|
||||
self._auth_request = _auth_request
|
||||
|
||||
async def configure_mtls_channel(self, client_cert_callback=None):
|
||||
"""Configure the client certificate and key for SSL connection.
|
||||
|
||||
The function does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` is
|
||||
explicitly set to `true`. In this case if client certificate and key are
|
||||
successfully obtained (from the given client_cert_callback or from application
|
||||
default SSL credentials), the underlying transport will be reconfigured
|
||||
to use mTLS.
|
||||
Note: This function does nothing if the `aiohttp` library is not
|
||||
installed.
|
||||
Important: Calling this method will close any ongoing API requests associated
|
||||
with the current session. To ensure a smooth transition, it is recommended
|
||||
to call this during session initialization.
|
||||
|
||||
Args:
|
||||
client_cert_callback (Optional[Callable[[], (bytes, bytes)]]):
|
||||
The optional callback returns the client certificate and private
|
||||
key bytes both in PEM format.
|
||||
If the callback is None, application default SSL credentials
|
||||
will be used.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel
|
||||
creation failed for any reason.
|
||||
"""
|
||||
if self._mtls_init_task is None:
|
||||
|
||||
async def _do_configure():
|
||||
# Run the blocking check in an executor
|
||||
use_client_cert = await mtls._run_in_executor(
|
||||
google.auth.transport._mtls_helper.check_use_client_cert
|
||||
)
|
||||
if not use_client_cert:
|
||||
self._is_mtls = False
|
||||
return
|
||||
|
||||
try:
|
||||
(
|
||||
self._is_mtls,
|
||||
cert,
|
||||
key,
|
||||
) = await mtls.get_client_cert_and_key(client_cert_callback)
|
||||
|
||||
if self._is_mtls:
|
||||
self._cached_cert = cert
|
||||
ssl_context = await mtls._run_in_executor(
|
||||
mtls.make_client_cert_ssl_context, cert, key
|
||||
)
|
||||
|
||||
# Re-create the auth request with the new SSL context
|
||||
if AIOHTTP_INSTALLED and isinstance(
|
||||
self._auth_request, AiohttpRequest
|
||||
):
|
||||
connector = aiohttp.TCPConnector(ssl=ssl_context)
|
||||
new_session = aiohttp.ClientSession(connector=connector)
|
||||
|
||||
old_auth_request = self._auth_request
|
||||
self._auth_request = AiohttpRequest(session=new_session)
|
||||
|
||||
await old_auth_request.close()
|
||||
|
||||
except (
|
||||
exceptions.ClientCertError,
|
||||
ImportError,
|
||||
OSError,
|
||||
) as caught_exc:
|
||||
new_exc = exceptions.MutualTLSChannelError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
self._mtls_init_task = asyncio.create_task(_do_configure())
|
||||
|
||||
return await self._mtls_init_task
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
data: Optional[bytes] = None,
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
timeout: Union[float, ClientTimeout] = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
total_attempts: Optional[int] = transport.DEFAULT_MAX_RETRY_ATTEMPTS,
|
||||
**kwargs,
|
||||
) -> transport.Response:
|
||||
"""
|
||||
Args:
|
||||
method (str): The http method used to make the request.
|
||||
url (str): The URI to be requested.
|
||||
data (Optional[bytes]): The payload or body in HTTP request.
|
||||
headers (Optional[Mapping[str, str]]): Request headers.
|
||||
timeout (float, aiohttp.ClientTimeout):
|
||||
The amount of time in seconds to wait for the server response
|
||||
with each individual request.
|
||||
max_allowed_time (float):
|
||||
If the method runs longer than this, a ``Timeout`` exception is
|
||||
automatically raised. Unlike the ``timeout`` parameter, this
|
||||
value applies to the total method execution time, even if
|
||||
multiple requests are made under the hood.
|
||||
total_attempts (int):
|
||||
The total number of retry attempts.
|
||||
|
||||
Mind that it is not guaranteed that the timeout error is raised
|
||||
at ``max_allowed_time``. It might take longer, for example, if
|
||||
an underlying request takes a lot of time, but the request
|
||||
itself does not timeout, e.g. if a large file is being
|
||||
transmitted. The timeout error will be raised after such
|
||||
request completes.
|
||||
|
||||
Returns:
|
||||
google.auth.aio.transport.Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TimeoutError: If the method does not complete within
|
||||
the configured `max_allowed_time` or the request exceeds the configured
|
||||
`timeout`.
|
||||
"""
|
||||
if self._mtls_init_task:
|
||||
try:
|
||||
await self._mtls_init_task
|
||||
except Exception:
|
||||
# Suppress all exceptions from the background mTLS initialization task,
|
||||
# allowing the request to fail naturally elsewhere.
|
||||
pass
|
||||
retries = _exponential_backoff.AsyncExponentialBackoff(
|
||||
total_attempts=total_attempts,
|
||||
)
|
||||
if headers is None:
|
||||
headers = {}
|
||||
async with timeout_guard(max_allowed_time) as with_timeout:
|
||||
await with_timeout(
|
||||
# Note: before_request will attempt to refresh credentials if expired.
|
||||
self._credentials.before_request(
|
||||
self._auth_request, method, url, headers
|
||||
)
|
||||
)
|
||||
actual_timeout: float = 0.0
|
||||
if ClientTimeout is not None and isinstance(timeout, ClientTimeout):
|
||||
actual_timeout = timeout.total if timeout.total is not None else 0.0
|
||||
elif isinstance(timeout, (int, float)):
|
||||
actual_timeout = float(timeout)
|
||||
# Workaround issue in python 3.9 related to code coverage by adding `# pragma: no branch`
|
||||
# See https://github.com/googleapis/gapic-generator-python/pull/1174#issuecomment-1025132372
|
||||
async for _ in retries: # pragma: no branch
|
||||
response = await with_timeout(
|
||||
self._auth_request(
|
||||
url, method, data, headers, actual_timeout, **kwargs
|
||||
)
|
||||
)
|
||||
if response.status_code not in transport.DEFAULT_RETRYABLE_STATUS_CODES:
|
||||
break
|
||||
return response
|
||||
|
||||
@functools.wraps(request)
|
||||
async def get(
|
||||
self,
|
||||
url: str,
|
||||
data: Optional[bytes] = None,
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
timeout: Union[float, ClientTimeout] = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
total_attempts: Optional[int] = transport.DEFAULT_MAX_RETRY_ATTEMPTS,
|
||||
**kwargs,
|
||||
) -> transport.Response:
|
||||
"""
|
||||
Args:
|
||||
url (str): The URI to be requested.
|
||||
data (Optional[bytes]): The payload or body in HTTP request.
|
||||
headers (Optional[Mapping[str, str]]): Request headers.
|
||||
max_allowed_time (float):
|
||||
If the method runs longer than this, a ``Timeout`` exception is
|
||||
automatically raised. Unlike the ``timeout`` parameter, this
|
||||
value applies to the total method execution time, even if
|
||||
multiple requests are made under the hood.
|
||||
timeout (float, aiohttp.ClientTimeout):
|
||||
The amount of time in seconds to wait for the server response
|
||||
with each individual request.
|
||||
total_attempts (int):
|
||||
The total number of retry attempts.
|
||||
|
||||
Mind that it is not guaranteed that the timeout error is raised
|
||||
at ``max_allowed_time``. It might take longer, for example, if
|
||||
an underlying request takes a lot of time, but the request
|
||||
itself does not timeout, e.g. if a large file is being
|
||||
transmitted. The timeout error will be raised after such
|
||||
request completes.
|
||||
|
||||
Returns:
|
||||
google.auth.aio.transport.Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TimeoutError: If the method does not complete within
|
||||
the configured `max_allowed_time` or the request exceeds the configured
|
||||
`timeout`.
|
||||
"""
|
||||
return await self.request(
|
||||
"GET",
|
||||
url,
|
||||
data,
|
||||
headers,
|
||||
max_allowed_time,
|
||||
timeout,
|
||||
total_attempts,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@functools.wraps(request)
|
||||
async def post(
|
||||
self,
|
||||
url: str,
|
||||
data: Optional[bytes] = None,
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
timeout: Union[float, ClientTimeout] = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
total_attempts: Optional[int] = transport.DEFAULT_MAX_RETRY_ATTEMPTS,
|
||||
**kwargs,
|
||||
) -> transport.Response:
|
||||
"""
|
||||
Args:
|
||||
url (str): The URI to be requested.
|
||||
data (Optional[bytes]): The payload or body in HTTP request.
|
||||
headers (Optional[Mapping[str, str]]): Request headers.
|
||||
max_allowed_time (float):
|
||||
If the method runs longer than this, a ``Timeout`` exception is
|
||||
automatically raised. Unlike the ``timeout`` parameter, this
|
||||
value applies to the total method execution time, even if
|
||||
multiple requests are made under the hood.
|
||||
timeout (float, aiohttp.ClientTimeout):
|
||||
The amount of time in seconds to wait for the server response
|
||||
with each individual request.
|
||||
total_attempts (int):
|
||||
The total number of retry attempts.
|
||||
|
||||
Mind that it is not guaranteed that the timeout error is raised
|
||||
at ``max_allowed_time``. It might take longer, for example, if
|
||||
an underlying request takes a lot of time, but the request
|
||||
itself does not timeout, e.g. if a large file is being
|
||||
transmitted. The timeout error will be raised after such
|
||||
request completes.
|
||||
|
||||
Returns:
|
||||
google.auth.aio.transport.Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TimeoutError: If the method does not complete within
|
||||
the configured `max_allowed_time` or the request exceeds the configured
|
||||
`timeout`.
|
||||
"""
|
||||
return await self.request(
|
||||
"POST",
|
||||
url,
|
||||
data,
|
||||
headers,
|
||||
max_allowed_time,
|
||||
timeout,
|
||||
total_attempts,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@functools.wraps(request)
|
||||
async def put(
|
||||
self,
|
||||
url: str,
|
||||
data: Optional[bytes] = None,
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
timeout: Union[float, ClientTimeout] = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
total_attempts: Optional[int] = transport.DEFAULT_MAX_RETRY_ATTEMPTS,
|
||||
**kwargs,
|
||||
) -> transport.Response:
|
||||
"""
|
||||
Args:
|
||||
url (str): The URI to be requested.
|
||||
data (Optional[bytes]): The payload or body in HTTP request.
|
||||
headers (Optional[Mapping[str, str]]): Request headers.
|
||||
max_allowed_time (float):
|
||||
If the method runs longer than this, a ``Timeout`` exception is
|
||||
automatically raised. Unlike the ``timeout`` parameter, this
|
||||
value applies to the total method execution time, even if
|
||||
multiple requests are made under the hood.
|
||||
timeout (float, aiohttp.ClientTimeout):
|
||||
The amount of time in seconds to wait for the server response
|
||||
with each individual request.
|
||||
total_attempts (int):
|
||||
The total number of retry attempts.
|
||||
|
||||
Mind that it is not guaranteed that the timeout error is raised
|
||||
at ``max_allowed_time``. It might take longer, for example, if
|
||||
an underlying request takes a lot of time, but the request
|
||||
itself does not timeout, e.g. if a large file is being
|
||||
transmitted. The timeout error will be raised after such
|
||||
request completes.
|
||||
|
||||
Returns:
|
||||
google.auth.aio.transport.Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TimeoutError: If the method does not complete within
|
||||
the configured `max_allowed_time` or the request exceeds the configured
|
||||
`timeout`.
|
||||
"""
|
||||
return await self.request(
|
||||
"PUT",
|
||||
url,
|
||||
data,
|
||||
headers,
|
||||
max_allowed_time,
|
||||
timeout,
|
||||
total_attempts,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@functools.wraps(request)
|
||||
async def patch(
|
||||
self,
|
||||
url: str,
|
||||
data: Optional[bytes] = None,
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
timeout: Union[float, ClientTimeout] = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
total_attempts: Optional[int] = transport.DEFAULT_MAX_RETRY_ATTEMPTS,
|
||||
**kwargs,
|
||||
) -> transport.Response:
|
||||
"""
|
||||
Args:
|
||||
url (str): The URI to be requested.
|
||||
data (Optional[bytes]): The payload or body in HTTP request.
|
||||
headers (Optional[Mapping[str, str]]): Request headers.
|
||||
max_allowed_time (float):
|
||||
If the method runs longer than this, a ``Timeout`` exception is
|
||||
automatically raised. Unlike the ``timeout`` parameter, this
|
||||
value applies to the total method execution time, even if
|
||||
multiple requests are made under the hood.
|
||||
timeout (float, aiohttp.ClientTimeout):
|
||||
The amount of time in seconds to wait for the server response
|
||||
with each individual request.
|
||||
total_attempts (int):
|
||||
The total number of retry attempts.
|
||||
|
||||
Mind that it is not guaranteed that the timeout error is raised
|
||||
at ``max_allowed_time``. It might take longer, for example, if
|
||||
an underlying request takes a lot of time, but the request
|
||||
itself does not timeout, e.g. if a large file is being
|
||||
transmitted. The timeout error will be raised after such
|
||||
request completes.
|
||||
|
||||
Returns:
|
||||
google.auth.aio.transport.Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TimeoutError: If the method does not complete within
|
||||
the configured `max_allowed_time` or the request exceeds the configured
|
||||
`timeout`.
|
||||
"""
|
||||
return await self.request(
|
||||
"PATCH",
|
||||
url,
|
||||
data,
|
||||
headers,
|
||||
max_allowed_time,
|
||||
timeout,
|
||||
total_attempts,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@functools.wraps(request)
|
||||
async def delete(
|
||||
self,
|
||||
url: str,
|
||||
data: Optional[bytes] = None,
|
||||
headers: Optional[Mapping[str, str]] = None,
|
||||
max_allowed_time: float = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
timeout: Union[float, ClientTimeout] = transport._DEFAULT_TIMEOUT_SECONDS,
|
||||
total_attempts: Optional[int] = transport.DEFAULT_MAX_RETRY_ATTEMPTS,
|
||||
**kwargs,
|
||||
) -> transport.Response:
|
||||
"""
|
||||
Args:
|
||||
url (str): The URI to be requested.
|
||||
data (Optional[bytes]): The payload or body in HTTP request.
|
||||
headers (Optional[Mapping[str, str]]): Request headers.
|
||||
max_allowed_time (float):
|
||||
If the method runs longer than this, a ``Timeout`` exception is
|
||||
automatically raised. Unlike the ``timeout`` parameter, this
|
||||
value applies to the total method execution time, even if
|
||||
multiple requests are made under the hood.
|
||||
timeout (float, aiohttp.ClientTimeout):
|
||||
The amount of time in seconds to wait for the server response
|
||||
with each individual request.
|
||||
total_attempts (int):
|
||||
The total number of retry attempts.
|
||||
|
||||
Mind that it is not guaranteed that the timeout error is raised
|
||||
at ``max_allowed_time``. It might take longer, for example, if
|
||||
an underlying request takes a lot of time, but the request
|
||||
itself does not timeout, e.g. if a large file is being
|
||||
transmitted. The timeout error will be raised after such
|
||||
request completes.
|
||||
|
||||
Returns:
|
||||
google.auth.aio.transport.Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TimeoutError: If the method does not complete within
|
||||
the configured `max_allowed_time` or the request exceeds the configured
|
||||
`timeout`.
|
||||
"""
|
||||
return await self.request(
|
||||
"DELETE",
|
||||
url,
|
||||
data,
|
||||
headers,
|
||||
max_allowed_time,
|
||||
timeout,
|
||||
total_attempts,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_mtls(self):
|
||||
"""Indicates if mutual TLS is enabled."""
|
||||
return self._is_mtls
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Close the underlying auth request session.
|
||||
"""
|
||||
await self._auth_request.close()
|
||||
76
venv/lib/python3.12/site-packages/google/auth/api_key.py
Normal file
76
venv/lib/python3.12/site-packages/google/auth/api_key.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# Copyright 2022 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.
|
||||
|
||||
"""Google API key support.
|
||||
This module provides authentication using the `API key`_.
|
||||
.. _API key:
|
||||
https://cloud.google.com/docs/authentication/api-keys/
|
||||
"""
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import credentials
|
||||
from google.auth import exceptions
|
||||
|
||||
|
||||
class Credentials(credentials.Credentials):
|
||||
"""API key credentials.
|
||||
These credentials use API key to provide authorization to applications.
|
||||
"""
|
||||
|
||||
def __init__(self, token):
|
||||
"""
|
||||
Args:
|
||||
token (str): API key string
|
||||
Raises:
|
||||
ValueError: If the provided API key is not a non-empty string.
|
||||
"""
|
||||
super(Credentials, self).__init__()
|
||||
if not token:
|
||||
raise exceptions.InvalidValue("Token must be a non-empty API key string")
|
||||
self.token = token
|
||||
|
||||
@property
|
||||
def expired(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def valid(self):
|
||||
return True
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
def refresh(self, request):
|
||||
return
|
||||
|
||||
def apply(self, headers, token=None):
|
||||
"""Apply the API key token to the x-goog-api-key header.
|
||||
Args:
|
||||
headers (Mapping): The HTTP request headers.
|
||||
token (Optional[str]): If specified, overrides the current access
|
||||
token.
|
||||
"""
|
||||
headers["x-goog-api-key"] = token or self.token
|
||||
|
||||
def before_request(self, request, method, url, headers):
|
||||
"""Performs credential-specific before request logic.
|
||||
Refreshes the credentials if necessary, then calls :meth:`apply` to
|
||||
apply the token to the x-goog-api-key header.
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
method (str): The request's HTTP method or the RPC method being
|
||||
invoked.
|
||||
url (str): The request's URI or the RPC service's URI.
|
||||
headers (Mapping): The request's headers.
|
||||
"""
|
||||
self.apply(headers)
|
||||
179
venv/lib/python3.12/site-packages/google/auth/app_engine.py
Normal file
179
venv/lib/python3.12/site-packages/google/auth/app_engine.py
Normal file
@@ -0,0 +1,179 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Google App Engine standard environment support.
|
||||
|
||||
This module provides authentication and signing for applications running on App
|
||||
Engine in the standard environment using the `App Identity API`_.
|
||||
|
||||
|
||||
.. _App Identity API:
|
||||
https://cloud.google.com/appengine/docs/python/appidentity/
|
||||
"""
|
||||
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import credentials
|
||||
from google.auth import crypt
|
||||
from google.auth import exceptions
|
||||
|
||||
# pytype: disable=import-error
|
||||
try:
|
||||
from google.appengine.api import app_identity # type: ignore
|
||||
except ImportError:
|
||||
app_identity = None # type: ignore
|
||||
# pytype: enable=import-error
|
||||
|
||||
|
||||
class Signer(crypt.Signer):
|
||||
"""Signs messages using the App Engine App Identity service.
|
||||
|
||||
This can be used in place of :class:`google.auth.crypt.Signer` when
|
||||
running in the App Engine standard environment.
|
||||
"""
|
||||
|
||||
@property
|
||||
def key_id(self):
|
||||
"""Optional[str]: The key ID used to identify this private key.
|
||||
|
||||
.. warning::
|
||||
This is always ``None``. The key ID used by App Engine can not
|
||||
be reliably determined ahead of time.
|
||||
"""
|
||||
return None
|
||||
|
||||
@_helpers.copy_docstring(crypt.Signer)
|
||||
def sign(self, message):
|
||||
message = _helpers.to_bytes(message)
|
||||
_, signature = app_identity.sign_blob(message)
|
||||
return signature
|
||||
|
||||
|
||||
def get_project_id():
|
||||
"""Gets the project ID for the current App Engine application.
|
||||
|
||||
Returns:
|
||||
str: The project ID
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.OSError: If the App Engine APIs are unavailable.
|
||||
"""
|
||||
# pylint: disable=missing-raises-doc
|
||||
# Pylint rightfully thinks google.auth.exceptions.OSError is OSError, but doesn't
|
||||
# realize it's a valid alias.
|
||||
if app_identity is None:
|
||||
raise exceptions.OSError("The App Engine APIs are not available.")
|
||||
return app_identity.get_application_id()
|
||||
|
||||
|
||||
class Credentials(
|
||||
credentials.Scoped, credentials.Signing, credentials.CredentialsWithQuotaProject
|
||||
):
|
||||
"""App Engine standard environment credentials.
|
||||
|
||||
These credentials use the App Engine App Identity API to obtain access
|
||||
tokens.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scopes=None,
|
||||
default_scopes=None,
|
||||
service_account_id=None,
|
||||
quota_project_id=None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
scopes (Sequence[str]): Scopes to request from the App Identity
|
||||
API.
|
||||
default_scopes (Sequence[str]): Default scopes passed by a
|
||||
Google client library. Use 'scopes' for user-defined scopes.
|
||||
service_account_id (str): The service account ID passed into
|
||||
:func:`google.appengine.api.app_identity.get_access_token`.
|
||||
If not specified, the default application service account
|
||||
ID will be used.
|
||||
quota_project_id (Optional[str]): The project ID used for quota
|
||||
and billing.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.OSError: If the App Engine APIs are unavailable.
|
||||
"""
|
||||
# pylint: disable=missing-raises-doc
|
||||
# Pylint rightfully thinks google.auth.exceptions.OSError is OSError, but doesn't
|
||||
# realize it's a valid alias.
|
||||
if app_identity is None:
|
||||
raise exceptions.OSError("The App Engine APIs are not available.")
|
||||
|
||||
super(Credentials, self).__init__()
|
||||
self._scopes = scopes
|
||||
self._default_scopes = default_scopes
|
||||
self._service_account_id = service_account_id
|
||||
self._signer = Signer()
|
||||
self._quota_project_id = quota_project_id
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
def refresh(self, request):
|
||||
scopes = self._scopes if self._scopes is not None else self._default_scopes
|
||||
# pylint: disable=unused-argument
|
||||
token, ttl = app_identity.get_access_token(scopes, self._service_account_id)
|
||||
expiry = _helpers.utcfromtimestamp(ttl)
|
||||
|
||||
self.token, self.expiry = token, expiry
|
||||
|
||||
@property
|
||||
def service_account_email(self):
|
||||
"""The service account email."""
|
||||
if self._service_account_id is None:
|
||||
self._service_account_id = app_identity.get_service_account_name()
|
||||
return self._service_account_id
|
||||
|
||||
@property
|
||||
def requires_scopes(self):
|
||||
"""Checks if the credentials requires scopes.
|
||||
|
||||
Returns:
|
||||
bool: True if there are no scopes set otherwise False.
|
||||
"""
|
||||
return not self._scopes and not self._default_scopes
|
||||
|
||||
@_helpers.copy_docstring(credentials.Scoped)
|
||||
def with_scopes(self, scopes, default_scopes=None):
|
||||
return self.__class__(
|
||||
scopes=scopes,
|
||||
default_scopes=default_scopes,
|
||||
service_account_id=self._service_account_id,
|
||||
quota_project_id=self.quota_project_id,
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
|
||||
def with_quota_project(self, quota_project_id):
|
||||
return self.__class__(
|
||||
scopes=self._scopes,
|
||||
service_account_id=self._service_account_id,
|
||||
quota_project_id=quota_project_id,
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(credentials.Signing)
|
||||
def sign_bytes(self, message):
|
||||
return self._signer.sign(message)
|
||||
|
||||
@property # type: ignore
|
||||
@_helpers.copy_docstring(credentials.Signing)
|
||||
def signer_email(self):
|
||||
return self.service_account_email
|
||||
|
||||
@property # type: ignore
|
||||
@_helpers.copy_docstring(credentials.Signing)
|
||||
def signer(self):
|
||||
return self._signer
|
||||
863
venv/lib/python3.12/site-packages/google/auth/aws.py
Normal file
863
venv/lib/python3.12/site-packages/google/auth/aws.py
Normal file
@@ -0,0 +1,863 @@
|
||||
# Copyright 2020 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.
|
||||
|
||||
"""AWS Credentials and AWS Signature V4 Request Signer.
|
||||
|
||||
This module provides credentials to access Google Cloud resources from Amazon
|
||||
Web Services (AWS) workloads. These credentials are recommended over the
|
||||
use of service account credentials in AWS as they do not involve the management
|
||||
of long-live service account private keys.
|
||||
|
||||
AWS Credentials are initialized using external_account arguments which are
|
||||
typically loaded from the external credentials JSON file.
|
||||
|
||||
This module also provides a definition for an abstract AWS security credentials supplier.
|
||||
This supplier can be implemented to return valid AWS security credentials and an AWS region
|
||||
and used to create AWS credentials. The credentials will then call the
|
||||
supplier instead of using pre-defined methods such as calling the EC2 metadata endpoints.
|
||||
|
||||
This module also provides a basic implementation of the
|
||||
`AWS Signature Version 4`_ request signing algorithm.
|
||||
|
||||
AWS Credentials use serialized signed requests to the
|
||||
`AWS STS GetCallerIdentity`_ API that can be exchanged for Google access tokens
|
||||
via the GCP STS endpoint.
|
||||
|
||||
.. _AWS Signature Version 4: https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
|
||||
.. _AWS STS GetCallerIdentity: https://docs.aws.amazon.com/STS/latest/APIReference/API_GetCallerIdentity.html
|
||||
"""
|
||||
|
||||
import abc
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.client as http_client
|
||||
import json
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
from typing import Optional
|
||||
import urllib
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import environment_vars
|
||||
from google.auth import exceptions
|
||||
from google.auth import external_account
|
||||
|
||||
# AWS Signature Version 4 signing algorithm identifier.
|
||||
_AWS_ALGORITHM = "AWS4-HMAC-SHA256"
|
||||
# The termination string for the AWS credential scope value as defined in
|
||||
# https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
|
||||
_AWS_REQUEST_TYPE = "aws4_request"
|
||||
# The AWS authorization header name for the security session token if available.
|
||||
_AWS_SECURITY_TOKEN_HEADER = "x-amz-security-token"
|
||||
# The AWS authorization header name for the auto-generated date.
|
||||
_AWS_DATE_HEADER = "x-amz-date"
|
||||
# The default AWS regional credential verification URL.
|
||||
_DEFAULT_AWS_REGIONAL_CREDENTIAL_VERIFICATION_URL = (
|
||||
"https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15"
|
||||
)
|
||||
# IMDSV2 session token lifetime. This is set to a low value because the session token is used immediately.
|
||||
_IMDSV2_SESSION_TOKEN_TTL_SECONDS = "300"
|
||||
|
||||
|
||||
class RequestSigner(object):
|
||||
"""Implements an AWS request signer based on the AWS Signature Version 4 signing
|
||||
process.
|
||||
https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
|
||||
"""
|
||||
|
||||
def __init__(self, region_name):
|
||||
"""Instantiates an AWS request signer used to compute authenticated signed
|
||||
requests to AWS APIs based on the AWS Signature Version 4 signing process.
|
||||
|
||||
Args:
|
||||
region_name (str): The AWS region to use.
|
||||
"""
|
||||
|
||||
self._region_name = region_name
|
||||
|
||||
def get_request_options(
|
||||
self,
|
||||
aws_security_credentials,
|
||||
url,
|
||||
method,
|
||||
request_payload="",
|
||||
additional_headers={},
|
||||
):
|
||||
"""Generates the signed request for the provided HTTP request for calling
|
||||
an AWS API. This follows the steps described at:
|
||||
https://docs.aws.amazon.com/general/latest/gr/sigv4_signing.html
|
||||
|
||||
Args:
|
||||
aws_security_credentials (AWSSecurityCredentials): The AWS security credentials.
|
||||
url (str): The AWS service URL containing the canonical URI and
|
||||
query string.
|
||||
method (str): The HTTP method used to call this API.
|
||||
request_payload (Optional[str]): The optional request payload if
|
||||
available.
|
||||
additional_headers (Optional[Mapping[str, str]]): The optional
|
||||
additional headers needed for the requested AWS API.
|
||||
|
||||
Returns:
|
||||
Mapping[str, str]: The AWS signed request dictionary object.
|
||||
"""
|
||||
|
||||
additional_headers = additional_headers or {}
|
||||
|
||||
uri = urllib.parse.urlparse(url)
|
||||
# Normalize the URL path. This is needed for the canonical_uri.
|
||||
# os.path.normpath can't be used since it normalizes "/" paths
|
||||
# to "\\" in Windows OS.
|
||||
normalized_uri = urllib.parse.urlparse(
|
||||
urljoin(url, posixpath.normpath(uri.path))
|
||||
)
|
||||
# Validate provided URL.
|
||||
if not uri.hostname or uri.scheme != "https":
|
||||
raise exceptions.InvalidResource("Invalid AWS service URL")
|
||||
|
||||
header_map = _generate_authentication_header_map(
|
||||
host=uri.hostname,
|
||||
canonical_uri=normalized_uri.path or "/",
|
||||
canonical_querystring=_get_canonical_querystring(uri.query),
|
||||
method=method,
|
||||
region=self._region_name,
|
||||
aws_security_credentials=aws_security_credentials,
|
||||
request_payload=request_payload,
|
||||
additional_headers=additional_headers,
|
||||
)
|
||||
headers = {
|
||||
"Authorization": header_map.get("authorization_header"),
|
||||
"host": uri.hostname,
|
||||
}
|
||||
# Add x-amz-date if available.
|
||||
if "amz_date" in header_map:
|
||||
headers[_AWS_DATE_HEADER] = header_map.get("amz_date")
|
||||
# Append additional optional headers, eg. X-Amz-Target, Content-Type, etc.
|
||||
for key in additional_headers:
|
||||
headers[key] = additional_headers[key]
|
||||
|
||||
# Add session token if available.
|
||||
if aws_security_credentials.session_token is not None:
|
||||
headers[_AWS_SECURITY_TOKEN_HEADER] = aws_security_credentials.session_token
|
||||
|
||||
signed_request = {"url": url, "method": method, "headers": headers}
|
||||
if request_payload:
|
||||
signed_request["data"] = request_payload
|
||||
return signed_request
|
||||
|
||||
|
||||
def _get_canonical_querystring(query):
|
||||
"""Generates the canonical query string given a raw query string.
|
||||
Logic is based on
|
||||
https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
|
||||
|
||||
Args:
|
||||
query (str): The raw query string.
|
||||
|
||||
Returns:
|
||||
str: The canonical query string.
|
||||
"""
|
||||
# Parse raw query string.
|
||||
querystring = urllib.parse.parse_qs(query)
|
||||
querystring_encoded_map = {}
|
||||
for key in querystring:
|
||||
quote_key = urllib.parse.quote(key, safe="-_.~")
|
||||
# URI encode key.
|
||||
querystring_encoded_map[quote_key] = []
|
||||
for item in querystring[key]:
|
||||
# For each key, URI encode all values for that key.
|
||||
querystring_encoded_map[quote_key].append(
|
||||
urllib.parse.quote(item, safe="-_.~")
|
||||
)
|
||||
# Sort values for each key.
|
||||
querystring_encoded_map[quote_key].sort()
|
||||
# Sort keys.
|
||||
sorted_keys = list(querystring_encoded_map.keys())
|
||||
sorted_keys.sort()
|
||||
# Reconstruct the query string. Preserve keys with multiple values.
|
||||
querystring_encoded_pairs = []
|
||||
for key in sorted_keys:
|
||||
for item in querystring_encoded_map[key]:
|
||||
querystring_encoded_pairs.append("{}={}".format(key, item))
|
||||
return "&".join(querystring_encoded_pairs)
|
||||
|
||||
|
||||
def _sign(key, msg):
|
||||
"""Creates the HMAC-SHA256 hash of the provided message using the provided
|
||||
key.
|
||||
|
||||
Args:
|
||||
key (str): The HMAC-SHA256 key to use.
|
||||
msg (str): The message to hash.
|
||||
|
||||
Returns:
|
||||
str: The computed hash bytes.
|
||||
"""
|
||||
return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _get_signing_key(key, date_stamp, region_name, service_name):
|
||||
"""Calculates the signing key used to calculate the signature for
|
||||
AWS Signature Version 4 based on:
|
||||
https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
|
||||
|
||||
Args:
|
||||
key (str): The AWS secret access key.
|
||||
date_stamp (str): The '%Y%m%d' date format.
|
||||
region_name (str): The AWS region.
|
||||
service_name (str): The AWS service name, eg. sts.
|
||||
|
||||
Returns:
|
||||
str: The signing key bytes.
|
||||
"""
|
||||
k_date = _sign(("AWS4" + key).encode("utf-8"), date_stamp)
|
||||
k_region = _sign(k_date, region_name)
|
||||
k_service = _sign(k_region, service_name)
|
||||
k_signing = _sign(k_service, "aws4_request")
|
||||
return k_signing
|
||||
|
||||
|
||||
def _generate_authentication_header_map(
|
||||
host,
|
||||
canonical_uri,
|
||||
canonical_querystring,
|
||||
method,
|
||||
region,
|
||||
aws_security_credentials,
|
||||
request_payload="",
|
||||
additional_headers={},
|
||||
):
|
||||
"""Generates the authentication header map needed for generating the AWS
|
||||
Signature Version 4 signed request.
|
||||
|
||||
Args:
|
||||
host (str): The AWS service URL hostname.
|
||||
canonical_uri (str): The AWS service URL path name.
|
||||
canonical_querystring (str): The AWS service URL query string.
|
||||
method (str): The HTTP method used to call this API.
|
||||
region (str): The AWS region.
|
||||
aws_security_credentials (AWSSecurityCredentials): The AWS security credentials.
|
||||
request_payload (Optional[str]): The optional request payload if
|
||||
available.
|
||||
additional_headers (Optional[Mapping[str, str]]): The optional
|
||||
additional headers needed for the requested AWS API.
|
||||
|
||||
Returns:
|
||||
Mapping[str, str]: The AWS authentication header dictionary object.
|
||||
This contains the x-amz-date and authorization header information.
|
||||
"""
|
||||
# iam.amazonaws.com host => iam service.
|
||||
# sts.us-east-2.amazonaws.com host => sts service.
|
||||
service_name = host.split(".")[0]
|
||||
|
||||
current_time = _helpers.utcnow()
|
||||
amz_date = current_time.strftime("%Y%m%dT%H%M%SZ")
|
||||
date_stamp = current_time.strftime("%Y%m%d")
|
||||
|
||||
# Change all additional headers to be lower case.
|
||||
full_headers = {}
|
||||
for key in additional_headers:
|
||||
full_headers[key.lower()] = additional_headers[key]
|
||||
# Add AWS session token if available.
|
||||
if aws_security_credentials.session_token is not None:
|
||||
full_headers[
|
||||
_AWS_SECURITY_TOKEN_HEADER
|
||||
] = aws_security_credentials.session_token
|
||||
|
||||
# Required headers
|
||||
full_headers["host"] = host
|
||||
# Do not use generated x-amz-date if the date header is provided.
|
||||
# Previously the date was not fixed with x-amz- and could be provided
|
||||
# manually.
|
||||
# https://github.com/boto/botocore/blob/879f8440a4e9ace5d3cf145ce8b3d5e5ffb892ef/tests/unit/auth/aws4_testsuite/get-header-value-trim.req
|
||||
if "date" not in full_headers:
|
||||
full_headers[_AWS_DATE_HEADER] = amz_date
|
||||
|
||||
# Header keys need to be sorted alphabetically.
|
||||
canonical_headers = ""
|
||||
header_keys = list(full_headers.keys())
|
||||
header_keys.sort()
|
||||
for key in header_keys:
|
||||
canonical_headers = "{}{}:{}\n".format(
|
||||
canonical_headers, key, full_headers[key]
|
||||
)
|
||||
signed_headers = ";".join(header_keys)
|
||||
|
||||
payload_hash = hashlib.sha256((request_payload or "").encode("utf-8")).hexdigest()
|
||||
|
||||
# https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
|
||||
canonical_request = "{}\n{}\n{}\n{}\n{}\n{}".format(
|
||||
method,
|
||||
canonical_uri,
|
||||
canonical_querystring,
|
||||
canonical_headers,
|
||||
signed_headers,
|
||||
payload_hash,
|
||||
)
|
||||
|
||||
credential_scope = "{}/{}/{}/{}".format(
|
||||
date_stamp, region, service_name, _AWS_REQUEST_TYPE
|
||||
)
|
||||
|
||||
# https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
|
||||
string_to_sign = "{}\n{}\n{}\n{}".format(
|
||||
_AWS_ALGORITHM,
|
||||
amz_date,
|
||||
credential_scope,
|
||||
hashlib.sha256(canonical_request.encode("utf-8")).hexdigest(),
|
||||
)
|
||||
|
||||
# https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
|
||||
signing_key = _get_signing_key(
|
||||
aws_security_credentials.secret_access_key, date_stamp, region, service_name
|
||||
)
|
||||
signature = hmac.new(
|
||||
signing_key, string_to_sign.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
# https://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html
|
||||
authorization_header = "{} Credential={}/{}, SignedHeaders={}, Signature={}".format(
|
||||
_AWS_ALGORITHM,
|
||||
aws_security_credentials.access_key_id,
|
||||
credential_scope,
|
||||
signed_headers,
|
||||
signature,
|
||||
)
|
||||
|
||||
authentication_header = {"authorization_header": authorization_header}
|
||||
# Do not use generated x-amz-date if the date header is provided.
|
||||
if "date" not in full_headers:
|
||||
authentication_header["amz_date"] = amz_date
|
||||
return authentication_header
|
||||
|
||||
|
||||
@dataclass
|
||||
class AwsSecurityCredentials:
|
||||
"""A class that models AWS security credentials with an optional session token.
|
||||
|
||||
Attributes:
|
||||
access_key_id (str): The AWS security credentials access key id.
|
||||
secret_access_key (str): The AWS security credentials secret access key.
|
||||
session_token (Optional[str]): The optional AWS security credentials session token. This should be set when using temporary credentials.
|
||||
"""
|
||||
|
||||
access_key_id: str
|
||||
secret_access_key: str
|
||||
session_token: Optional[str] = None
|
||||
|
||||
|
||||
class AwsSecurityCredentialsSupplier(metaclass=abc.ABCMeta):
|
||||
"""Base class for AWS security credential suppliers. This can be implemented with custom logic to retrieve
|
||||
AWS security credentials to exchange for a Google Cloud access token. The AWS external account credential does
|
||||
not cache the AWS security credentials, so caching logic should be added in the implementation.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_aws_security_credentials(self, context, request):
|
||||
"""Returns the AWS security credentials for the requested context.
|
||||
|
||||
.. warning: This is not cached by the calling Google credential, so caching logic should be implemented in the supplier.
|
||||
|
||||
Args:
|
||||
context (google.auth.externalaccount.SupplierContext): The context object
|
||||
containing information about the requested audience and subject token type.
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If an error is encountered during
|
||||
security credential retrieval logic.
|
||||
|
||||
Returns:
|
||||
AwsSecurityCredentials: The requested AWS security credentials.
|
||||
"""
|
||||
raise NotImplementedError("")
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_aws_region(self, context, request):
|
||||
"""Returns the AWS region for the requested context.
|
||||
|
||||
Args:
|
||||
context (google.auth.externalaccount.SupplierContext): The context object
|
||||
containing information about the requested audience and subject token type.
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If an error is encountered during
|
||||
region retrieval logic.
|
||||
|
||||
Returns:
|
||||
str: The AWS region.
|
||||
"""
|
||||
raise NotImplementedError("")
|
||||
|
||||
|
||||
class _DefaultAwsSecurityCredentialsSupplier(AwsSecurityCredentialsSupplier):
|
||||
"""Default implementation of AWS security credentials supplier. Supports retrieving
|
||||
credentials and region via EC2 metadata endpoints and environment variables.
|
||||
"""
|
||||
|
||||
def __init__(self, credential_source):
|
||||
self._region_url = credential_source.get("region_url")
|
||||
self._security_credentials_url = credential_source.get("url")
|
||||
self._imdsv2_session_token_url = credential_source.get(
|
||||
"imdsv2_session_token_url"
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(AwsSecurityCredentialsSupplier)
|
||||
def get_aws_security_credentials(self, context, request):
|
||||
# Check environment variables for permanent credentials first.
|
||||
# https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html
|
||||
env_aws_access_key_id = os.environ.get(environment_vars.AWS_ACCESS_KEY_ID)
|
||||
env_aws_secret_access_key = os.environ.get(
|
||||
environment_vars.AWS_SECRET_ACCESS_KEY
|
||||
)
|
||||
# This is normally not available for permanent credentials.
|
||||
env_aws_session_token = os.environ.get(environment_vars.AWS_SESSION_TOKEN)
|
||||
if env_aws_access_key_id and env_aws_secret_access_key:
|
||||
return AwsSecurityCredentials(
|
||||
env_aws_access_key_id, env_aws_secret_access_key, env_aws_session_token
|
||||
)
|
||||
|
||||
imdsv2_session_token = self._get_imdsv2_session_token(request)
|
||||
role_name = self._get_metadata_role_name(request, imdsv2_session_token)
|
||||
|
||||
# Get security credentials.
|
||||
credentials = self._get_metadata_security_credentials(
|
||||
request, role_name, imdsv2_session_token
|
||||
)
|
||||
|
||||
return AwsSecurityCredentials(
|
||||
credentials.get("AccessKeyId"),
|
||||
credentials.get("SecretAccessKey"),
|
||||
credentials.get("Token"),
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(AwsSecurityCredentialsSupplier)
|
||||
def get_aws_region(self, context, request):
|
||||
# The AWS metadata server is not available in some AWS environments
|
||||
# such as AWS lambda. Instead, it is available via environment
|
||||
# variable.
|
||||
env_aws_region = os.environ.get(environment_vars.AWS_REGION)
|
||||
if env_aws_region is not None:
|
||||
return env_aws_region
|
||||
|
||||
env_aws_region = os.environ.get(environment_vars.AWS_DEFAULT_REGION)
|
||||
if env_aws_region is not None:
|
||||
return env_aws_region
|
||||
|
||||
if not self._region_url:
|
||||
raise exceptions.RefreshError("Unable to determine AWS region")
|
||||
|
||||
headers = None
|
||||
imdsv2_session_token = self._get_imdsv2_session_token(request)
|
||||
if imdsv2_session_token is not None:
|
||||
headers = {"X-aws-ec2-metadata-token": imdsv2_session_token}
|
||||
|
||||
response = request(url=self._region_url, method="GET", headers=headers)
|
||||
|
||||
# Support both string and bytes type response.data.
|
||||
response_body = (
|
||||
response.data.decode("utf-8")
|
||||
if hasattr(response.data, "decode")
|
||||
else response.data
|
||||
)
|
||||
|
||||
if response.status != http_client.OK:
|
||||
raise exceptions.RefreshError(
|
||||
"Unable to retrieve AWS region: {}".format(response_body)
|
||||
)
|
||||
|
||||
# This endpoint will return the region in format: us-east-2b.
|
||||
# Only the us-east-2 part should be used.
|
||||
return response_body[:-1]
|
||||
|
||||
def _get_imdsv2_session_token(self, request):
|
||||
if request is not None and self._imdsv2_session_token_url is not None:
|
||||
headers = {
|
||||
"X-aws-ec2-metadata-token-ttl-seconds": _IMDSV2_SESSION_TOKEN_TTL_SECONDS
|
||||
}
|
||||
|
||||
imdsv2_session_token_response = request(
|
||||
url=self._imdsv2_session_token_url, method="PUT", headers=headers
|
||||
)
|
||||
|
||||
if imdsv2_session_token_response.status != http_client.OK:
|
||||
raise exceptions.RefreshError(
|
||||
"Unable to retrieve AWS Session Token: {}".format(
|
||||
imdsv2_session_token_response.data
|
||||
)
|
||||
)
|
||||
|
||||
return imdsv2_session_token_response.data
|
||||
else:
|
||||
return None
|
||||
|
||||
def _get_metadata_security_credentials(
|
||||
self, request, role_name, imdsv2_session_token
|
||||
):
|
||||
"""Retrieves the AWS security credentials required for signing AWS
|
||||
requests from the AWS metadata server.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
role_name (str): The AWS role name required by the AWS metadata
|
||||
server security_credentials endpoint in order to return the
|
||||
credentials.
|
||||
imdsv2_session_token (str): The AWS IMDSv2 session token to be added as a
|
||||
header in the requests to AWS metadata endpoint.
|
||||
|
||||
Returns:
|
||||
Mapping[str, str]: The AWS metadata server security credentials
|
||||
response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If an error occurs while
|
||||
retrieving the AWS security credentials.
|
||||
"""
|
||||
if imdsv2_session_token is not None:
|
||||
headers = {"X-aws-ec2-metadata-token": imdsv2_session_token}
|
||||
else:
|
||||
headers = None
|
||||
|
||||
response = request(
|
||||
url="{}/{}".format(self._security_credentials_url, role_name),
|
||||
method="GET",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# support both string and bytes type response.data
|
||||
response_body = (
|
||||
response.data.decode("utf-8")
|
||||
if hasattr(response.data, "decode")
|
||||
else response.data
|
||||
)
|
||||
|
||||
if response.status != http_client.OK:
|
||||
raise exceptions.RefreshError(
|
||||
"Unable to retrieve AWS security credentials: {}".format(response_body)
|
||||
)
|
||||
|
||||
credentials_response = json.loads(response_body)
|
||||
|
||||
return credentials_response
|
||||
|
||||
def _get_metadata_role_name(self, request, imdsv2_session_token):
|
||||
"""Retrieves the AWS role currently attached to the current AWS
|
||||
workload by querying the AWS metadata server. This is needed for the
|
||||
AWS metadata server security credentials endpoint in order to retrieve
|
||||
the AWS security credentials needed to sign requests to AWS APIs.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
imdsv2_session_token (str): The AWS IMDSv2 session token to be added as a
|
||||
header in the requests to AWS metadata endpoint.
|
||||
|
||||
Returns:
|
||||
str: The AWS role name.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If an error occurs while
|
||||
retrieving the AWS role name.
|
||||
"""
|
||||
if self._security_credentials_url is None:
|
||||
raise exceptions.RefreshError(
|
||||
"Unable to determine the AWS metadata server security credentials endpoint"
|
||||
)
|
||||
|
||||
headers = None
|
||||
if imdsv2_session_token is not None:
|
||||
headers = {"X-aws-ec2-metadata-token": imdsv2_session_token}
|
||||
|
||||
response = request(
|
||||
url=self._security_credentials_url, method="GET", headers=headers
|
||||
)
|
||||
|
||||
# support both string and bytes type response.data
|
||||
response_body = (
|
||||
response.data.decode("utf-8")
|
||||
if hasattr(response.data, "decode")
|
||||
else response.data
|
||||
)
|
||||
|
||||
if response.status != http_client.OK:
|
||||
raise exceptions.RefreshError(
|
||||
"Unable to retrieve AWS role name {}".format(response_body)
|
||||
)
|
||||
|
||||
return response_body
|
||||
|
||||
|
||||
class Credentials(external_account.Credentials):
|
||||
"""AWS external account credentials.
|
||||
This is used to exchange serialized AWS signature v4 signed requests to
|
||||
AWS STS GetCallerIdentity service for Google access tokens.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audience,
|
||||
subject_token_type,
|
||||
token_url=external_account._DEFAULT_TOKEN_URL,
|
||||
credential_source=None,
|
||||
aws_security_credentials_supplier=None,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
"""Instantiates an AWS workload external account credentials object.
|
||||
|
||||
Args:
|
||||
audience (str): The STS audience field.
|
||||
subject_token_type (str): The subject token type based on the Oauth2.0 token exchange spec.
|
||||
Expected values include::
|
||||
|
||||
“urn:ietf:params:aws:token-type:aws4_request”
|
||||
|
||||
token_url (Optional [str]): The STS endpoint URL. If not provided, will default to "https://sts.googleapis.com/v1/token".
|
||||
credential_source (Optional [Mapping]): The credential source dictionary used
|
||||
to provide instructions on how to retrieve external credential to be exchanged for Google access tokens.
|
||||
Either a credential source or an AWS security credentials supplier must be provided.
|
||||
|
||||
Example credential_source for AWS credential::
|
||||
|
||||
{
|
||||
"environment_id": "aws1",
|
||||
"regional_cred_verification_url": "https://sts.{region}.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
|
||||
"region_url": "http://169.254.169.254/latest/meta-data/placement/availability-zone",
|
||||
"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials",
|
||||
imdsv2_session_token_url": "http://169.254.169.254/latest/api/token"
|
||||
}
|
||||
|
||||
aws_security_credentials_supplier (Optional [AwsSecurityCredentialsSupplier]): Optional AWS security credentials supplier.
|
||||
This will be called to supply valid AWS security credentails which will then
|
||||
be exchanged for Google access tokens. Either an AWS security credentials supplier
|
||||
or a credential source must be provided.
|
||||
args (List): Optional positional arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method.
|
||||
kwargs (Mapping): Optional keyword arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If an error is encountered during
|
||||
access token retrieval logic.
|
||||
ValueError: For invalid parameters.
|
||||
|
||||
.. note:: Typically one of the helper constructors
|
||||
:meth:`from_file` or
|
||||
:meth:`from_info` are used instead of calling the constructor directly.
|
||||
"""
|
||||
super(Credentials, self).__init__(
|
||||
audience=audience,
|
||||
subject_token_type=subject_token_type,
|
||||
token_url=token_url,
|
||||
credential_source=credential_source,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
if credential_source is None and aws_security_credentials_supplier is None:
|
||||
raise exceptions.InvalidValue(
|
||||
"A valid credential source or AWS security credentials supplier must be provided."
|
||||
)
|
||||
if (
|
||||
credential_source is not None
|
||||
and aws_security_credentials_supplier is not None
|
||||
):
|
||||
raise exceptions.InvalidValue(
|
||||
"AWS credential cannot have both a credential source and an AWS security credentials supplier."
|
||||
)
|
||||
|
||||
if aws_security_credentials_supplier:
|
||||
self._aws_security_credentials_supplier = aws_security_credentials_supplier
|
||||
# The regional cred verification URL would normally be provided through the credential source. So set it to the default one here.
|
||||
self._cred_verification_url = (
|
||||
_DEFAULT_AWS_REGIONAL_CREDENTIAL_VERIFICATION_URL
|
||||
)
|
||||
else:
|
||||
environment_id = credential_source.get("environment_id") or ""
|
||||
self._aws_security_credentials_supplier = (
|
||||
_DefaultAwsSecurityCredentialsSupplier(credential_source)
|
||||
)
|
||||
self._cred_verification_url = credential_source.get(
|
||||
"regional_cred_verification_url"
|
||||
)
|
||||
|
||||
# Get the environment ID, i.e. "aws1". Currently, only one version supported (1).
|
||||
matches = re.match(r"^(aws)([\d]+)$", environment_id)
|
||||
if matches:
|
||||
env_id, env_version = matches.groups()
|
||||
else:
|
||||
env_id, env_version = (None, None)
|
||||
|
||||
if env_id != "aws" or self._cred_verification_url is None:
|
||||
raise exceptions.InvalidResource(
|
||||
"No valid AWS 'credential_source' provided"
|
||||
)
|
||||
elif env_version is None or int(env_version) != 1:
|
||||
raise exceptions.InvalidValue(
|
||||
"aws version '{}' is not supported in the current build.".format(
|
||||
env_version
|
||||
)
|
||||
)
|
||||
|
||||
self._target_resource = audience
|
||||
self._request_signer = None
|
||||
|
||||
def retrieve_subject_token(self, request):
|
||||
"""Retrieves the subject token using the credential_source object.
|
||||
The subject token is a serialized `AWS GetCallerIdentity signed request`_.
|
||||
|
||||
The logic is summarized as:
|
||||
|
||||
Retrieve the AWS region from the AWS_REGION or AWS_DEFAULT_REGION
|
||||
environment variable or from the AWS metadata server availability-zone
|
||||
if not found in the environment variable.
|
||||
|
||||
Check AWS credentials in environment variables. If not found, retrieve
|
||||
from the AWS metadata server security-credentials endpoint.
|
||||
|
||||
When retrieving AWS credentials from the metadata server
|
||||
security-credentials endpoint, the AWS role needs to be determined by
|
||||
calling the security-credentials endpoint without any argument. Then the
|
||||
credentials can be retrieved via: security-credentials/role_name
|
||||
|
||||
Generate the signed request to AWS STS GetCallerIdentity action.
|
||||
|
||||
Inject x-goog-cloud-target-resource into header and serialize the
|
||||
signed request. This will be the subject-token to pass to GCP STS.
|
||||
|
||||
.. _AWS GetCallerIdentity signed request:
|
||||
https://cloud.google.com/iam/docs/access-resources-aws#exchange-token
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
Returns:
|
||||
str: The retrieved subject token.
|
||||
"""
|
||||
|
||||
# Initialize the request signer if not yet initialized after determining
|
||||
# the current AWS region.
|
||||
if self._request_signer is None:
|
||||
self._region = self._aws_security_credentials_supplier.get_aws_region(
|
||||
self._supplier_context, request
|
||||
)
|
||||
self._request_signer = RequestSigner(self._region)
|
||||
|
||||
# Retrieve the AWS security credentials needed to generate the signed
|
||||
# request.
|
||||
aws_security_credentials = (
|
||||
self._aws_security_credentials_supplier.get_aws_security_credentials(
|
||||
self._supplier_context, request
|
||||
)
|
||||
)
|
||||
# Generate the signed request to AWS STS GetCallerIdentity API.
|
||||
# Use the required regional endpoint. Otherwise, the request will fail.
|
||||
request_options = self._request_signer.get_request_options(
|
||||
aws_security_credentials,
|
||||
self._cred_verification_url.replace("{region}", self._region),
|
||||
"POST",
|
||||
)
|
||||
# The GCP STS endpoint expects the headers to be formatted as:
|
||||
# [
|
||||
# {key: 'x-amz-date', value: '...'},
|
||||
# {key: 'Authorization', value: '...'},
|
||||
# ...
|
||||
# ]
|
||||
# And then serialized as:
|
||||
# quote(json.dumps({
|
||||
# url: '...',
|
||||
# method: 'POST',
|
||||
# headers: [{key: 'x-amz-date', value: '...'}, ...]
|
||||
# }))
|
||||
request_headers = request_options.get("headers")
|
||||
# The full, canonical resource name of the workload identity pool
|
||||
# provider, with or without the HTTPS prefix.
|
||||
# Including this header as part of the signature is recommended to
|
||||
# ensure data integrity.
|
||||
request_headers["x-goog-cloud-target-resource"] = self._target_resource
|
||||
|
||||
# Serialize AWS signed request.
|
||||
aws_signed_req = {}
|
||||
aws_signed_req["url"] = request_options.get("url")
|
||||
aws_signed_req["method"] = request_options.get("method")
|
||||
aws_signed_req["headers"] = []
|
||||
# Reformat header to GCP STS expected format.
|
||||
for key in request_headers.keys():
|
||||
aws_signed_req["headers"].append(
|
||||
{"key": key, "value": request_headers[key]}
|
||||
)
|
||||
|
||||
return urllib.parse.quote(
|
||||
json.dumps(aws_signed_req, separators=(",", ":"), sort_keys=True)
|
||||
)
|
||||
|
||||
def _create_default_metrics_options(self):
|
||||
metrics_options = super(Credentials, self)._create_default_metrics_options()
|
||||
metrics_options["source"] = "aws"
|
||||
if self._has_custom_supplier():
|
||||
metrics_options["source"] = "programmatic"
|
||||
return metrics_options
|
||||
|
||||
def _has_custom_supplier(self):
|
||||
return self._credential_source is None
|
||||
|
||||
def _constructor_args(self):
|
||||
args = super(Credentials, self)._constructor_args()
|
||||
# If a custom supplier was used, append it to the args dict.
|
||||
if self._has_custom_supplier():
|
||||
args.update(
|
||||
{
|
||||
"aws_security_credentials_supplier": self._aws_security_credentials_supplier
|
||||
}
|
||||
)
|
||||
return args
|
||||
|
||||
@classmethod
|
||||
def from_info(cls, info, **kwargs):
|
||||
"""Creates an AWS Credentials instance from parsed external account info.
|
||||
|
||||
Args:
|
||||
info (Mapping[str, str]): The AWS external account info in Google
|
||||
format.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.aws.Credentials: The constructed credentials.
|
||||
|
||||
Raises:
|
||||
ValueError: For invalid parameters.
|
||||
"""
|
||||
aws_security_credentials_supplier = info.get(
|
||||
"aws_security_credentials_supplier"
|
||||
)
|
||||
kwargs.update(
|
||||
{"aws_security_credentials_supplier": aws_security_credentials_supplier}
|
||||
)
|
||||
return super(Credentials, cls).from_info(info, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, filename, **kwargs):
|
||||
"""Creates an AWS Credentials instance from an external account json file.
|
||||
|
||||
Args:
|
||||
filename (str): The path to the AWS external account json file.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.aws.Credentials: The constructed credentials.
|
||||
"""
|
||||
return super(Credentials, cls).from_file(filename, **kwargs)
|
||||
@@ -0,0 +1,22 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Google Compute Engine authentication."""
|
||||
|
||||
from google.auth.compute_engine._metadata import detect_gce_residency_linux
|
||||
from google.auth.compute_engine.credentials import Credentials
|
||||
from google.auth.compute_engine.credentials import IDTokenCredentials
|
||||
|
||||
|
||||
__all__ = ["Credentials", "IDTokenCredentials", "detect_gce_residency_linux"]
|
||||
@@ -0,0 +1,504 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Provides helper methods for talking to the Compute Engine metadata server.
|
||||
|
||||
See https://cloud.google.com/compute/docs/metadata for more details.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import http.client as http_client
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import environment_vars
|
||||
from google.auth import exceptions
|
||||
from google.auth import metrics
|
||||
from google.auth import transport
|
||||
from google.auth._exponential_backoff import ExponentialBackoff
|
||||
from google.auth.compute_engine import _mtls
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_GCE_DEFAULT_MDS_IP = "169.254.169.254"
|
||||
_GCE_DEFAULT_HOST = "metadata.google.internal"
|
||||
_GCE_DEFAULT_MDS_HOSTS = [_GCE_DEFAULT_HOST, _GCE_DEFAULT_MDS_IP]
|
||||
|
||||
# Environment variable GCE_METADATA_HOST is originally named
|
||||
# GCE_METADATA_ROOT. For compatibility reasons, here it checks
|
||||
# the new variable first; if not set, the system falls back
|
||||
# to the old variable.
|
||||
_GCE_METADATA_HOST = os.getenv(environment_vars.GCE_METADATA_HOST, None)
|
||||
if not _GCE_METADATA_HOST:
|
||||
_GCE_METADATA_HOST = os.getenv(
|
||||
environment_vars.GCE_METADATA_ROOT, _GCE_DEFAULT_HOST
|
||||
)
|
||||
|
||||
|
||||
def _validate_gce_mds_configured_environment():
|
||||
"""Validates the GCE metadata server environment configuration for mTLS.
|
||||
|
||||
mTLS is only supported when connecting to the default metadata server hosts.
|
||||
If we are in strict mode (which requires mTLS), ensure that the metadata host
|
||||
has not been overridden to a custom value (which means mTLS will fail).
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.MutualTLSChannelError: if the environment
|
||||
configuration is invalid for mTLS.
|
||||
"""
|
||||
mode = _mtls._parse_mds_mode()
|
||||
if mode == _mtls.MdsMtlsMode.STRICT:
|
||||
# mTLS is only supported when connecting to the default metadata host.
|
||||
# Raise an exception if we are in strict mode (which requires mTLS)
|
||||
# but the metadata host has been overridden to a custom MDS. (which means mTLS will fail)
|
||||
if _GCE_METADATA_HOST not in _GCE_DEFAULT_MDS_HOSTS:
|
||||
raise exceptions.MutualTLSChannelError(
|
||||
"Mutual TLS is required, but the metadata host has been overridden. "
|
||||
"mTLS is only supported when connecting to the default metadata host."
|
||||
)
|
||||
|
||||
|
||||
def _get_metadata_root(use_mtls: bool):
|
||||
"""Returns the metadata server root URL."""
|
||||
|
||||
scheme = "https" if use_mtls else "http"
|
||||
return "{}://{}/computeMetadata/v1/".format(scheme, _GCE_METADATA_HOST)
|
||||
|
||||
|
||||
def _get_metadata_ip_root(use_mtls: bool):
|
||||
"""Returns the metadata server IP root URL."""
|
||||
scheme = "https" if use_mtls else "http"
|
||||
return "{}://{}".format(
|
||||
scheme, os.getenv(environment_vars.GCE_METADATA_IP, _GCE_DEFAULT_MDS_IP)
|
||||
)
|
||||
|
||||
|
||||
_METADATA_FLAVOR_HEADER = "metadata-flavor"
|
||||
_METADATA_FLAVOR_VALUE = "Google"
|
||||
_METADATA_HEADERS = {_METADATA_FLAVOR_HEADER: _METADATA_FLAVOR_VALUE}
|
||||
|
||||
# Timeout in seconds to wait for the GCE metadata server when detecting the
|
||||
# GCE environment.
|
||||
try:
|
||||
_METADATA_DEFAULT_TIMEOUT = int(os.getenv(environment_vars.GCE_METADATA_TIMEOUT, 3))
|
||||
except ValueError: # pragma: NO COVER
|
||||
_METADATA_DEFAULT_TIMEOUT = 3
|
||||
|
||||
# The number of tries to perform when waiting for the GCE metadata server
|
||||
# when detecting the GCE environment.
|
||||
try:
|
||||
_METADATA_DETECT_RETRIES = int(
|
||||
os.getenv(environment_vars.GCE_METADATA_DETECT_RETRIES, 3)
|
||||
)
|
||||
except ValueError: # pragma: NO COVER
|
||||
_METADATA_DETECT_RETRIES = 3
|
||||
|
||||
# This is used to disable checking for the GCE metadata server and directly
|
||||
# assuming it's not available.
|
||||
_NO_GCE_CHECK = os.getenv(environment_vars.NO_GCE_CHECK) == "true"
|
||||
|
||||
# Detect GCE Residency
|
||||
_GOOGLE = "Google"
|
||||
_GCE_PRODUCT_NAME_FILE = "/sys/class/dmi/id/product_name"
|
||||
|
||||
|
||||
def is_on_gce(request):
|
||||
"""Checks to see if the code runs on Google Compute Engine
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
|
||||
Returns:
|
||||
bool: True if the code runs on Google Compute Engine, False otherwise.
|
||||
"""
|
||||
if _NO_GCE_CHECK:
|
||||
return False
|
||||
|
||||
if ping(request):
|
||||
return True
|
||||
|
||||
if os.name == "nt":
|
||||
# TODO: implement GCE residency detection on Windows
|
||||
return False
|
||||
|
||||
# Detect GCE residency on Linux
|
||||
return detect_gce_residency_linux()
|
||||
|
||||
|
||||
def detect_gce_residency_linux():
|
||||
"""Detect Google Compute Engine residency by smbios check on Linux
|
||||
|
||||
Returns:
|
||||
bool: True if the GCE product name file is detected, False otherwise.
|
||||
"""
|
||||
try:
|
||||
with open(_GCE_PRODUCT_NAME_FILE, "r") as file_obj:
|
||||
content = file_obj.read().strip()
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
return content.startswith(_GOOGLE)
|
||||
|
||||
|
||||
def _prepare_request_for_mds(request, use_mtls=False) -> None:
|
||||
"""Prepares a request for the metadata server.
|
||||
|
||||
This will check if mTLS should be used and mount the mTLS adapter if needed.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests. If mTLS is enabled, and the request supports sessions,
|
||||
the request will have the mTLS adapter mounted. Otherwise, there
|
||||
will be no change.
|
||||
use_mtls (bool): Whether to use mTLS for the request.
|
||||
|
||||
|
||||
"""
|
||||
# Only modify the request if mTLS is enabled, and request supports sessions.
|
||||
if use_mtls and hasattr(request, "session"):
|
||||
# Ensure the request has a session to mount the adapter to.
|
||||
if not request.session:
|
||||
request.session = requests.Session()
|
||||
|
||||
adapter = _mtls.MdsMtlsAdapter()
|
||||
# Mount the adapter for all default GCE metadata hosts.
|
||||
for host in _GCE_DEFAULT_MDS_HOSTS:
|
||||
request.session.mount(f"https://{host}/", adapter)
|
||||
|
||||
|
||||
def ping(
|
||||
request, timeout=_METADATA_DEFAULT_TIMEOUT, retry_count=_METADATA_DETECT_RETRIES
|
||||
):
|
||||
"""Checks to see if the metadata server is available.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
timeout (int): How long to wait for the metadata server to respond.
|
||||
retry_count (int): How many times to attempt connecting to metadata
|
||||
server using above timeout.
|
||||
|
||||
Returns:
|
||||
bool: True if the metadata server is reachable, False otherwise.
|
||||
"""
|
||||
use_mtls = _mtls.should_use_mds_mtls()
|
||||
_prepare_request_for_mds(request, use_mtls=use_mtls)
|
||||
# NOTE: The explicit ``timeout`` is a workaround. The underlying
|
||||
# issue is that resolving an unknown host on some networks will take
|
||||
# 20-30 seconds; making this timeout short fixes the issue, but
|
||||
# could lead to false negatives in the event that we are on GCE, but
|
||||
# the metadata resolution was particularly slow. The latter case is
|
||||
# "unlikely".
|
||||
headers = _METADATA_HEADERS.copy()
|
||||
headers[metrics.API_CLIENT_HEADER] = metrics.mds_ping()
|
||||
|
||||
backoff = ExponentialBackoff(total_attempts=retry_count)
|
||||
|
||||
for attempt in backoff:
|
||||
try:
|
||||
response = request(
|
||||
url=_get_metadata_ip_root(use_mtls),
|
||||
method="GET",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
metadata_flavor = response.headers.get(_METADATA_FLAVOR_HEADER)
|
||||
return (
|
||||
response.status == http_client.OK
|
||||
and metadata_flavor == _METADATA_FLAVOR_VALUE
|
||||
)
|
||||
|
||||
except exceptions.TransportError as e:
|
||||
_LOGGER.warning(
|
||||
"Compute Engine Metadata server unavailable on "
|
||||
"attempt %s of %s. Reason: %s",
|
||||
attempt,
|
||||
retry_count,
|
||||
e,
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get(
|
||||
request,
|
||||
path,
|
||||
root=None,
|
||||
params=None,
|
||||
recursive=False,
|
||||
retry_count=5,
|
||||
headers=None,
|
||||
return_none_for_not_found_error=False,
|
||||
timeout=_METADATA_DEFAULT_TIMEOUT,
|
||||
):
|
||||
"""Fetch a resource from the metadata server.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
path (str): The resource to retrieve. For example,
|
||||
``'instance/service-accounts/default'``.
|
||||
root (Optional[str]): The full path to the metadata server root. If not
|
||||
provided, the default root will be used.
|
||||
params (Optional[Mapping[str, str]]): A mapping of query parameter
|
||||
keys to values.
|
||||
recursive (bool): Whether to do a recursive query of metadata. See
|
||||
https://cloud.google.com/compute/docs/metadata#aggcontents for more
|
||||
details.
|
||||
retry_count (int): How many times to attempt connecting to metadata
|
||||
server using above timeout.
|
||||
headers (Optional[Mapping[str, str]]): Headers for the request.
|
||||
return_none_for_not_found_error (Optional[bool]): If True, returns None
|
||||
for 404 error instead of throwing an exception.
|
||||
timeout (int): How long to wait, in seconds for the metadata server to respond.
|
||||
|
||||
Returns:
|
||||
Union[Mapping, str]: If the metadata server returns JSON, a mapping of
|
||||
the decoded JSON is returned. Otherwise, the response content is
|
||||
returned as a string.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: if an error occurred while
|
||||
retrieving metadata.
|
||||
google.auth.exceptions.MutualTLSChannelError: if using mtls and the environment
|
||||
configuration is invalid for mTLS (for example, the metadata host
|
||||
has been overridden in strict mTLS mode).
|
||||
|
||||
"""
|
||||
use_mtls = _mtls.should_use_mds_mtls()
|
||||
# Prepare the request object for mTLS if needed.
|
||||
# This will create a new request object with the mTLS session.
|
||||
_prepare_request_for_mds(request, use_mtls=use_mtls)
|
||||
|
||||
if root is None:
|
||||
root = _get_metadata_root(use_mtls)
|
||||
|
||||
# mTLS is only supported when connecting to the default metadata host.
|
||||
# If we are in strict mode (which requires mTLS), ensure that the metadata host
|
||||
# has not been overridden to a non-default host value (which means mTLS will fail).
|
||||
_validate_gce_mds_configured_environment()
|
||||
|
||||
base_url = urljoin(root, path)
|
||||
query_params = {} if params is None else params
|
||||
|
||||
headers_to_use = _METADATA_HEADERS.copy()
|
||||
if headers:
|
||||
headers_to_use.update(headers)
|
||||
|
||||
if recursive:
|
||||
query_params["recursive"] = "true"
|
||||
|
||||
url = _helpers.update_query(base_url, query_params)
|
||||
|
||||
backoff = ExponentialBackoff(total_attempts=retry_count)
|
||||
last_exception = None
|
||||
for attempt in backoff:
|
||||
try:
|
||||
response = request(
|
||||
url=url, method="GET", headers=headers_to_use, timeout=timeout
|
||||
)
|
||||
if response.status in transport.DEFAULT_RETRYABLE_STATUS_CODES:
|
||||
_LOGGER.warning(
|
||||
"Compute Engine Metadata server unavailable on "
|
||||
"attempt %s of %s. Response status: %s",
|
||||
attempt,
|
||||
retry_count,
|
||||
response.status,
|
||||
)
|
||||
last_exception = None
|
||||
continue
|
||||
else:
|
||||
last_exception = None
|
||||
break
|
||||
|
||||
except exceptions.TransportError as e:
|
||||
_LOGGER.warning(
|
||||
"Compute Engine Metadata server unavailable on "
|
||||
"attempt %s of %s. Reason: %s",
|
||||
attempt,
|
||||
retry_count,
|
||||
e,
|
||||
)
|
||||
last_exception = e
|
||||
else:
|
||||
if last_exception:
|
||||
raise exceptions.TransportError(
|
||||
"Failed to retrieve {} from the Google Compute Engine "
|
||||
"metadata service. Compute Engine Metadata server unavailable. "
|
||||
"Last exception: {}".format(url, last_exception)
|
||||
) from last_exception
|
||||
else:
|
||||
error_details = (
|
||||
response.data.decode("utf-8")
|
||||
if hasattr(response.data, "decode")
|
||||
else response.data
|
||||
)
|
||||
raise exceptions.TransportError(
|
||||
"Failed to retrieve {} from the Google Compute Engine "
|
||||
"metadata service. Compute Engine Metadata server unavailable. "
|
||||
"Response status: {}\nResponse details:\n{}".format(
|
||||
url, response.status, error_details
|
||||
)
|
||||
)
|
||||
|
||||
content = _helpers.from_bytes(response.data)
|
||||
|
||||
if response.status == http_client.NOT_FOUND and return_none_for_not_found_error:
|
||||
return None
|
||||
|
||||
if response.status == http_client.OK:
|
||||
if (
|
||||
_helpers.parse_content_type(response.headers["content-type"])
|
||||
== "application/json"
|
||||
):
|
||||
try:
|
||||
return json.loads(content)
|
||||
except ValueError as caught_exc:
|
||||
new_exc = exceptions.TransportError(
|
||||
"Received invalid JSON from the Google Compute Engine "
|
||||
"metadata service: {:.20}".format(content)
|
||||
)
|
||||
raise new_exc from caught_exc
|
||||
else:
|
||||
return content
|
||||
|
||||
raise exceptions.TransportError(
|
||||
"Failed to retrieve {} from the Google Compute Engine "
|
||||
"metadata service. Status: {} Response:\n{}".format(
|
||||
url, response.status, response.data
|
||||
),
|
||||
response,
|
||||
)
|
||||
|
||||
|
||||
def get_project_id(request):
|
||||
"""Get the Google Cloud Project ID from the metadata server.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
|
||||
Returns:
|
||||
str: The project ID
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: if an error occurred while
|
||||
retrieving metadata.
|
||||
"""
|
||||
return get(request, "project/project-id")
|
||||
|
||||
|
||||
def get_universe_domain(request):
|
||||
"""Get the universe domain value from the metadata server.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
|
||||
Returns:
|
||||
str: The universe domain value. If the universe domain endpoint is not
|
||||
not found, return the default value, which is googleapis.com
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: if an error other than
|
||||
404 occurs while retrieving metadata.
|
||||
"""
|
||||
universe_domain = get(
|
||||
request, "universe/universe-domain", return_none_for_not_found_error=True
|
||||
)
|
||||
if not universe_domain:
|
||||
return "googleapis.com"
|
||||
return universe_domain
|
||||
|
||||
|
||||
def get_service_account_info(request, service_account="default"):
|
||||
"""Get information about a service account from the metadata server.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
service_account (str): The string 'default' or a service account email
|
||||
address. The determines which service account for which to acquire
|
||||
information.
|
||||
|
||||
Returns:
|
||||
Mapping: The service account's information, for example::
|
||||
|
||||
{
|
||||
'email': '...',
|
||||
'scopes': ['scope', ...],
|
||||
'aliases': ['default', '...']
|
||||
}
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: if an error occurred while
|
||||
retrieving metadata.
|
||||
"""
|
||||
path = "instance/service-accounts/{0}/".format(service_account)
|
||||
# See https://cloud.google.com/compute/docs/metadata#aggcontents
|
||||
# for more on the use of 'recursive'.
|
||||
return get(request, path, params={"recursive": "true"})
|
||||
|
||||
|
||||
def get_service_account_token(request, service_account="default", scopes=None):
|
||||
"""Get the OAuth 2.0 access token for a service account.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
service_account (str): The string 'default' or a service account email
|
||||
address. The determines which service account for which to acquire
|
||||
an access token.
|
||||
scopes (Optional[Union[str, List[str]]]): Optional string or list of
|
||||
strings with auth scopes.
|
||||
Returns:
|
||||
Tuple[str, datetime]: The access token and its expiration.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: if an error occurred while
|
||||
retrieving metadata.
|
||||
"""
|
||||
from google.auth import _agent_identity_utils
|
||||
|
||||
params = {}
|
||||
if scopes:
|
||||
if not isinstance(scopes, str):
|
||||
scopes = ",".join(scopes)
|
||||
params["scopes"] = scopes
|
||||
|
||||
cert = _agent_identity_utils.get_and_parse_agent_identity_certificate()
|
||||
if cert:
|
||||
if _agent_identity_utils.should_request_bound_token(cert):
|
||||
fingerprint = _agent_identity_utils.calculate_certificate_fingerprint(cert)
|
||||
params["bindCertificateFingerprint"] = fingerprint
|
||||
|
||||
metrics_header = {
|
||||
metrics.API_CLIENT_HEADER: metrics.token_request_access_token_mds()
|
||||
}
|
||||
|
||||
path = "instance/service-accounts/{0}/token".format(service_account)
|
||||
token_json = get(request, path, params=params, headers=metrics_header)
|
||||
token_expiry = _helpers.utcnow() + datetime.timedelta(
|
||||
seconds=token_json["expires_in"]
|
||||
)
|
||||
return token_json["access_token"], token_expiry
|
||||
@@ -0,0 +1,164 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright 2024 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.
|
||||
#
|
||||
"""Mutual TLS for Google Compute Engine metadata server."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import enum
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import ssl
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
from google.auth import environment_vars, exceptions
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_WINDOWS_OS_NAME = "nt"
|
||||
|
||||
# MDS mTLS certificate paths based on OS.
|
||||
# Documentation to well known locations can be found at:
|
||||
# https://cloud.google.com/compute/docs/metadata/overview#https-mds-certificates
|
||||
_WINDOWS_MTLS_COMPONENTS_BASE_PATH = Path("C:/ProgramData/Google/ComputeEngine")
|
||||
_MTLS_COMPONENTS_BASE_PATH = Path("/run/google-mds-mtls")
|
||||
|
||||
|
||||
def _get_mds_root_crt_path():
|
||||
if os.name == _WINDOWS_OS_NAME:
|
||||
return _WINDOWS_MTLS_COMPONENTS_BASE_PATH / "mds-mtls-root.crt"
|
||||
else:
|
||||
return _MTLS_COMPONENTS_BASE_PATH / "root.crt"
|
||||
|
||||
|
||||
def _get_mds_client_combined_cert_path():
|
||||
if os.name == _WINDOWS_OS_NAME:
|
||||
return _WINDOWS_MTLS_COMPONENTS_BASE_PATH / "mds-mtls-client.key"
|
||||
else:
|
||||
return _MTLS_COMPONENTS_BASE_PATH / "client.key"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MdsMtlsConfig:
|
||||
ca_cert_path: Path = field(
|
||||
default_factory=_get_mds_root_crt_path
|
||||
) # path to CA certificate
|
||||
client_combined_cert_path: Path = field(
|
||||
default_factory=_get_mds_client_combined_cert_path
|
||||
) # path to file containing client certificate and key
|
||||
|
||||
|
||||
def _certs_exist(mds_mtls_config: MdsMtlsConfig):
|
||||
"""Checks if the mTLS certificates exist."""
|
||||
return os.path.exists(mds_mtls_config.ca_cert_path) and os.path.exists(
|
||||
mds_mtls_config.client_combined_cert_path
|
||||
)
|
||||
|
||||
|
||||
class MdsMtlsMode(enum.Enum):
|
||||
"""MDS mTLS mode. Used to configure connection behavior when connecting to MDS.
|
||||
|
||||
STRICT: Always use HTTPS/mTLS. If certificates are not found locally, an error will be returned.
|
||||
NONE: Never use mTLS. Requests will use regular HTTP.
|
||||
DEFAULT: Use mTLS if certificates are found locally, otherwise use regular HTTP.
|
||||
"""
|
||||
|
||||
STRICT = "strict"
|
||||
NONE = "none"
|
||||
DEFAULT = "default"
|
||||
|
||||
|
||||
def _parse_mds_mode():
|
||||
"""Parses the GCE_METADATA_MTLS_MODE environment variable."""
|
||||
mode_str = os.environ.get(
|
||||
environment_vars.GCE_METADATA_MTLS_MODE, "default"
|
||||
).lower()
|
||||
try:
|
||||
return MdsMtlsMode(mode_str)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
"Invalid value for GCE_METADATA_MTLS_MODE. Must be one of 'strict', 'none', or 'default'."
|
||||
)
|
||||
|
||||
|
||||
def should_use_mds_mtls(mds_mtls_config: MdsMtlsConfig = MdsMtlsConfig()):
|
||||
"""Determines if mTLS should be used for the metadata server."""
|
||||
mode = _parse_mds_mode()
|
||||
if mode == MdsMtlsMode.STRICT:
|
||||
if not _certs_exist(mds_mtls_config):
|
||||
raise exceptions.MutualTLSChannelError(
|
||||
"mTLS certificates not found in strict mode."
|
||||
)
|
||||
return True
|
||||
elif mode == MdsMtlsMode.NONE:
|
||||
return False
|
||||
else: # Default mode
|
||||
return _certs_exist(mds_mtls_config)
|
||||
|
||||
|
||||
class MdsMtlsAdapter(HTTPAdapter):
|
||||
"""An HTTP adapter that uses mTLS for the metadata server."""
|
||||
|
||||
def __init__(
|
||||
self, mds_mtls_config: MdsMtlsConfig = MdsMtlsConfig(), *args, **kwargs
|
||||
):
|
||||
self.ssl_context = ssl.create_default_context()
|
||||
self.ssl_context.load_verify_locations(cafile=mds_mtls_config.ca_cert_path)
|
||||
self.ssl_context.load_cert_chain(
|
||||
certfile=mds_mtls_config.client_combined_cert_path
|
||||
)
|
||||
super(MdsMtlsAdapter, self).__init__(*args, **kwargs)
|
||||
|
||||
def init_poolmanager(self, *args, **kwargs):
|
||||
kwargs["ssl_context"] = self.ssl_context
|
||||
return super(MdsMtlsAdapter, self).init_poolmanager(*args, **kwargs)
|
||||
|
||||
def proxy_manager_for(self, *args, **kwargs):
|
||||
kwargs["ssl_context"] = self.ssl_context
|
||||
return super(MdsMtlsAdapter, self).proxy_manager_for(*args, **kwargs)
|
||||
|
||||
def send(self, request, **kwargs):
|
||||
# If we are in strict mode, always use mTLS (no HTTP fallback)
|
||||
if _parse_mds_mode() == MdsMtlsMode.STRICT:
|
||||
return super(MdsMtlsAdapter, self).send(request, **kwargs)
|
||||
|
||||
# In default mode, attempt mTLS first, then fallback to HTTP on failure
|
||||
try:
|
||||
response = super(MdsMtlsAdapter, self).send(request, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except (
|
||||
ssl.SSLError,
|
||||
requests.exceptions.SSLError,
|
||||
requests.exceptions.HTTPError,
|
||||
) as e:
|
||||
_LOGGER.warning(
|
||||
"mTLS connection to Compute Engine Metadata server failed. "
|
||||
"Falling back to standard HTTP. Reason: %s",
|
||||
e,
|
||||
)
|
||||
# Fallback to standard HTTP
|
||||
parsed_original_url = urlparse(request.url)
|
||||
http_fallback_url = urlunparse(parsed_original_url._replace(scheme="http"))
|
||||
request.url = http_fallback_url
|
||||
|
||||
# Use a standard HTTPAdapter for the fallback
|
||||
http_adapter = HTTPAdapter()
|
||||
return http_adapter.send(request, **kwargs)
|
||||
@@ -0,0 +1,556 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Google Compute Engine credentials.
|
||||
|
||||
This module provides authentication for an application running on Google
|
||||
Compute Engine using the Compute Engine metadata server.
|
||||
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import credentials
|
||||
from google.auth import exceptions
|
||||
from google.auth import iam
|
||||
from google.auth import jwt
|
||||
from google.auth import metrics
|
||||
from google.auth.compute_engine import _metadata
|
||||
from google.oauth2 import _client
|
||||
|
||||
_TRUST_BOUNDARY_LOOKUP_ENDPOINT = (
|
||||
"https://iamcredentials.{}/v1/projects/-/serviceAccounts/{}/allowedLocations"
|
||||
)
|
||||
|
||||
|
||||
class Credentials(
|
||||
credentials.Scoped,
|
||||
credentials.CredentialsWithQuotaProject,
|
||||
credentials.CredentialsWithUniverseDomain,
|
||||
credentials.CredentialsWithTrustBoundary,
|
||||
):
|
||||
"""Compute Engine Credentials.
|
||||
|
||||
These credentials use the Google Compute Engine metadata server to obtain
|
||||
OAuth 2.0 access tokens associated with the instance's service account,
|
||||
and are also used for Cloud Run, Flex and App Engine (except for the Python
|
||||
2.7 runtime, which is supported only on older versions of this library).
|
||||
|
||||
For more information about Compute Engine authentication, including how
|
||||
to configure scopes, see the `Compute Engine authentication
|
||||
documentation`_.
|
||||
|
||||
.. note:: On Compute Engine the metadata server ignores requested scopes.
|
||||
On Cloud Run, Flex and App Engine the server honours requested scopes.
|
||||
|
||||
.. _Compute Engine authentication documentation:
|
||||
https://cloud.google.com/compute/docs/authentication#using
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
service_account_email="default",
|
||||
quota_project_id=None,
|
||||
scopes=None,
|
||||
default_scopes=None,
|
||||
universe_domain=None,
|
||||
trust_boundary=None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
service_account_email (str): The service account email to use, or
|
||||
'default'. A Compute Engine instance may have multiple service
|
||||
accounts.
|
||||
quota_project_id (Optional[str]): The project ID used for quota and
|
||||
billing.
|
||||
scopes (Optional[Sequence[str]]): The list of scopes for the credentials.
|
||||
default_scopes (Optional[Sequence[str]]): Default scopes passed by a
|
||||
Google client library. Use 'scopes' for user-defined scopes.
|
||||
universe_domain (Optional[str]): The universe domain. If not
|
||||
provided or None, credential will attempt to fetch the value
|
||||
from metadata server. If metadata server doesn't have universe
|
||||
domain endpoint, then the default googleapis.com will be used.
|
||||
trust_boundary (Mapping[str,str]): A credential trust boundary.
|
||||
"""
|
||||
super(Credentials, self).__init__()
|
||||
self._service_account_email = service_account_email
|
||||
self._quota_project_id = quota_project_id
|
||||
self._scopes = scopes
|
||||
self._default_scopes = default_scopes
|
||||
self._universe_domain_cached = False
|
||||
if universe_domain:
|
||||
self._universe_domain = universe_domain
|
||||
self._universe_domain_cached = True
|
||||
self._trust_boundary = trust_boundary
|
||||
|
||||
def _retrieve_info(self, request):
|
||||
"""Retrieve information about the service account.
|
||||
|
||||
Updates the scopes and retrieves the full service account email.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
"""
|
||||
info = _metadata.get_service_account_info(
|
||||
request, service_account=self._service_account_email
|
||||
)
|
||||
|
||||
if not info or "email" not in info:
|
||||
raise exceptions.RefreshError(
|
||||
"Unexpected response from metadata server: "
|
||||
"service account info is missing 'email' field."
|
||||
)
|
||||
|
||||
self._service_account_email = info["email"]
|
||||
|
||||
# Don't override scopes requested by the user.
|
||||
if self._scopes is None:
|
||||
self._scopes = info.get("scopes")
|
||||
|
||||
def _metric_header_for_usage(self):
|
||||
return metrics.CRED_TYPE_SA_MDS
|
||||
|
||||
def _perform_refresh_token(self, request):
|
||||
"""Refresh the access token and scopes.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the Compute Engine metadata
|
||||
service can't be reached if if the instance has not
|
||||
credentials.
|
||||
"""
|
||||
try:
|
||||
self._retrieve_info(request)
|
||||
scopes = self._scopes if self._scopes is not None else self._default_scopes
|
||||
# Always fetch token with default service account email.
|
||||
self.token, self.expiry = _metadata.get_service_account_token(
|
||||
request, service_account="default", scopes=scopes
|
||||
)
|
||||
except exceptions.TransportError as caught_exc:
|
||||
new_exc = exceptions.RefreshError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
def _build_trust_boundary_lookup_url(self):
|
||||
"""Builds and returns the URL for the trust boundary lookup API for GCE."""
|
||||
# If the service account email is 'default', we need to get the
|
||||
# actual email address from the metadata server.
|
||||
if self._service_account_email == "default":
|
||||
from google.auth.transport import requests as google_auth_requests
|
||||
|
||||
request = google_auth_requests.Request()
|
||||
try:
|
||||
info = _metadata.get_service_account_info(request, "default")
|
||||
if not info or "email" not in info:
|
||||
raise exceptions.RefreshError(
|
||||
"Unexpected response from metadata server: "
|
||||
"service account info is missing 'email' field."
|
||||
)
|
||||
self._service_account_email = info["email"]
|
||||
|
||||
except exceptions.TransportError as e:
|
||||
# If fetching the service account email fails due to a transport error,
|
||||
# it means we cannot build the trust boundary lookup URL.
|
||||
# Wrap this in a RefreshError so it's caught by _refresh_trust_boundary.
|
||||
raise exceptions.RefreshError(
|
||||
"Failed to get service account email for trust boundary lookup: {}".format(
|
||||
e
|
||||
)
|
||||
) from e
|
||||
|
||||
return _TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
|
||||
self.universe_domain, self.service_account_email
|
||||
)
|
||||
|
||||
@property
|
||||
def service_account_email(self):
|
||||
"""The service account email.
|
||||
|
||||
.. note:: This is not guaranteed to be set until :meth:`refresh` has been
|
||||
called.
|
||||
"""
|
||||
return self._service_account_email
|
||||
|
||||
@property
|
||||
def requires_scopes(self):
|
||||
return not self._scopes
|
||||
|
||||
@property
|
||||
def universe_domain(self):
|
||||
if self._universe_domain_cached:
|
||||
return self._universe_domain
|
||||
|
||||
from google.auth.transport import requests as google_auth_requests
|
||||
|
||||
self._universe_domain = _metadata.get_universe_domain(
|
||||
google_auth_requests.Request()
|
||||
)
|
||||
self._universe_domain_cached = True
|
||||
return self._universe_domain
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
def get_cred_info(self):
|
||||
return {
|
||||
"credential_source": "metadata server",
|
||||
"credential_type": "VM credentials",
|
||||
"principal": self.service_account_email,
|
||||
}
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
|
||||
def with_quota_project(self, quota_project_id):
|
||||
creds = self.__class__(
|
||||
service_account_email=self._service_account_email,
|
||||
quota_project_id=quota_project_id,
|
||||
scopes=self._scopes,
|
||||
default_scopes=self._default_scopes,
|
||||
universe_domain=self._universe_domain,
|
||||
trust_boundary=self._trust_boundary,
|
||||
)
|
||||
creds._universe_domain_cached = self._universe_domain_cached
|
||||
return creds
|
||||
|
||||
@_helpers.copy_docstring(credentials.Scoped)
|
||||
def with_scopes(self, scopes, default_scopes=None):
|
||||
# Compute Engine credentials can not be scoped (the metadata service
|
||||
# ignores the scopes parameter). App Engine, Cloud Run and Flex support
|
||||
# requesting scopes.
|
||||
creds = self.__class__(
|
||||
scopes=scopes,
|
||||
default_scopes=default_scopes,
|
||||
service_account_email=self._service_account_email,
|
||||
quota_project_id=self._quota_project_id,
|
||||
universe_domain=self._universe_domain,
|
||||
trust_boundary=self._trust_boundary,
|
||||
)
|
||||
creds._universe_domain_cached = self._universe_domain_cached
|
||||
return creds
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain)
|
||||
def with_universe_domain(self, universe_domain):
|
||||
return self.__class__(
|
||||
scopes=self._scopes,
|
||||
default_scopes=self._default_scopes,
|
||||
service_account_email=self._service_account_email,
|
||||
quota_project_id=self._quota_project_id,
|
||||
trust_boundary=self._trust_boundary,
|
||||
universe_domain=universe_domain,
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary)
|
||||
def with_trust_boundary(self, trust_boundary):
|
||||
creds = self.__class__(
|
||||
service_account_email=self._service_account_email,
|
||||
quota_project_id=self._quota_project_id,
|
||||
scopes=self._scopes,
|
||||
default_scopes=self._default_scopes,
|
||||
universe_domain=self._universe_domain,
|
||||
trust_boundary=trust_boundary,
|
||||
)
|
||||
creds._universe_domain_cached = self._universe_domain_cached
|
||||
return creds
|
||||
|
||||
|
||||
_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds
|
||||
_DEFAULT_TOKEN_URI = "https://www.googleapis.com/oauth2/v4/token"
|
||||
|
||||
|
||||
class IDTokenCredentials(
|
||||
credentials.CredentialsWithQuotaProject,
|
||||
credentials.Signing,
|
||||
credentials.CredentialsWithTokenUri,
|
||||
):
|
||||
"""Open ID Connect ID Token-based service account credentials.
|
||||
|
||||
These credentials relies on the default service account of a GCE instance.
|
||||
|
||||
ID token can be requested from `GCE metadata server identity endpoint`_, IAM
|
||||
token endpoint or other token endpoints you specify. If metadata server
|
||||
identity endpoint is not used, the GCE instance must have been started with
|
||||
a service account that has access to the IAM Cloud API.
|
||||
|
||||
.. _GCE metadata server identity endpoint:
|
||||
https://cloud.google.com/compute/docs/instances/verifying-instance-identity
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request,
|
||||
target_audience,
|
||||
token_uri=None,
|
||||
additional_claims=None,
|
||||
service_account_email=None,
|
||||
signer=None,
|
||||
use_metadata_identity_endpoint=False,
|
||||
quota_project_id=None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
target_audience (str): The intended audience for these credentials,
|
||||
used when requesting the ID Token. The ID Token's ``aud`` claim
|
||||
will be set to this string.
|
||||
token_uri (str): The OAuth 2.0 Token URI.
|
||||
additional_claims (Mapping[str, str]): Any additional claims for
|
||||
the JWT assertion used in the authorization grant.
|
||||
service_account_email (str): Optional explicit service account to
|
||||
use to sign JWT tokens.
|
||||
By default, this is the default GCE service account.
|
||||
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
|
||||
In case the signer is specified, the request argument will be
|
||||
ignored.
|
||||
use_metadata_identity_endpoint (bool): Whether to use GCE metadata
|
||||
identity endpoint. For backward compatibility the default value
|
||||
is False. If set to True, ``token_uri``, ``additional_claims``,
|
||||
``service_account_email``, ``signer`` argument should not be set;
|
||||
otherwise ValueError will be raised.
|
||||
quota_project_id (Optional[str]): The project ID used for quota and
|
||||
billing.
|
||||
|
||||
Raises:
|
||||
ValueError:
|
||||
If ``use_metadata_identity_endpoint`` is set to True, and one of
|
||||
``token_uri``, ``additional_claims``, ``service_account_email``,
|
||||
``signer`` arguments is set.
|
||||
"""
|
||||
super(IDTokenCredentials, self).__init__()
|
||||
|
||||
self._quota_project_id = quota_project_id
|
||||
self._use_metadata_identity_endpoint = use_metadata_identity_endpoint
|
||||
self._target_audience = target_audience
|
||||
|
||||
if use_metadata_identity_endpoint:
|
||||
if token_uri or additional_claims or service_account_email or signer:
|
||||
raise ValueError(
|
||||
"If use_metadata_identity_endpoint is set, token_uri, "
|
||||
"additional_claims, service_account_email, signer arguments"
|
||||
" must not be set"
|
||||
)
|
||||
self._token_uri = None
|
||||
self._additional_claims = None
|
||||
self._signer = None
|
||||
|
||||
if service_account_email is None:
|
||||
sa_info = _metadata.get_service_account_info(request)
|
||||
self._service_account_email = sa_info["email"]
|
||||
else:
|
||||
self._service_account_email = service_account_email
|
||||
|
||||
if not use_metadata_identity_endpoint:
|
||||
if signer is None:
|
||||
signer = iam.Signer(
|
||||
request=request,
|
||||
credentials=Credentials(),
|
||||
service_account_email=self._service_account_email,
|
||||
)
|
||||
self._signer = signer
|
||||
self._token_uri = token_uri or _DEFAULT_TOKEN_URI
|
||||
|
||||
if additional_claims is not None:
|
||||
self._additional_claims = additional_claims
|
||||
else:
|
||||
self._additional_claims = {}
|
||||
|
||||
def with_target_audience(self, target_audience):
|
||||
"""Create a copy of these credentials with the specified target
|
||||
audience.
|
||||
Args:
|
||||
target_audience (str): The intended audience for these credentials,
|
||||
used when requesting the ID Token.
|
||||
Returns:
|
||||
google.auth.service_account.IDTokenCredentials: A new credentials
|
||||
instance.
|
||||
"""
|
||||
# since the signer is already instantiated,
|
||||
# the request is not needed
|
||||
if self._use_metadata_identity_endpoint:
|
||||
return self.__class__(
|
||||
None,
|
||||
target_audience=target_audience,
|
||||
use_metadata_identity_endpoint=True,
|
||||
quota_project_id=self._quota_project_id,
|
||||
)
|
||||
else:
|
||||
return self.__class__(
|
||||
None,
|
||||
service_account_email=self._service_account_email,
|
||||
token_uri=self._token_uri,
|
||||
target_audience=target_audience,
|
||||
additional_claims=self._additional_claims.copy(),
|
||||
signer=self.signer,
|
||||
use_metadata_identity_endpoint=False,
|
||||
quota_project_id=self._quota_project_id,
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
|
||||
def with_quota_project(self, quota_project_id):
|
||||
# since the signer is already instantiated,
|
||||
# the request is not needed
|
||||
if self._use_metadata_identity_endpoint:
|
||||
return self.__class__(
|
||||
None,
|
||||
target_audience=self._target_audience,
|
||||
use_metadata_identity_endpoint=True,
|
||||
quota_project_id=quota_project_id,
|
||||
)
|
||||
else:
|
||||
return self.__class__(
|
||||
None,
|
||||
service_account_email=self._service_account_email,
|
||||
token_uri=self._token_uri,
|
||||
target_audience=self._target_audience,
|
||||
additional_claims=self._additional_claims.copy(),
|
||||
signer=self.signer,
|
||||
use_metadata_identity_endpoint=False,
|
||||
quota_project_id=quota_project_id,
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithTokenUri)
|
||||
def with_token_uri(self, token_uri):
|
||||
# since the signer is already instantiated,
|
||||
# the request is not needed
|
||||
if self._use_metadata_identity_endpoint:
|
||||
raise ValueError(
|
||||
"If use_metadata_identity_endpoint is set, token_uri" " must not be set"
|
||||
)
|
||||
else:
|
||||
return self.__class__(
|
||||
None,
|
||||
service_account_email=self._service_account_email,
|
||||
token_uri=token_uri,
|
||||
target_audience=self._target_audience,
|
||||
additional_claims=self._additional_claims.copy(),
|
||||
signer=self.signer,
|
||||
use_metadata_identity_endpoint=False,
|
||||
quota_project_id=self.quota_project_id,
|
||||
)
|
||||
|
||||
def _make_authorization_grant_assertion(self):
|
||||
"""Create the OAuth 2.0 assertion.
|
||||
This assertion is used during the OAuth 2.0 grant to acquire an
|
||||
ID token.
|
||||
Returns:
|
||||
bytes: The authorization grant assertion.
|
||||
"""
|
||||
now = _helpers.utcnow()
|
||||
lifetime = datetime.timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECS)
|
||||
expiry = now + lifetime
|
||||
|
||||
payload = {
|
||||
"iat": _helpers.datetime_to_secs(now),
|
||||
"exp": _helpers.datetime_to_secs(expiry),
|
||||
# The issuer must be the service account email.
|
||||
"iss": self.service_account_email,
|
||||
# The audience must be the auth token endpoint's URI
|
||||
"aud": self._token_uri,
|
||||
# The target audience specifies which service the ID token is
|
||||
# intended for.
|
||||
"target_audience": self._target_audience,
|
||||
}
|
||||
|
||||
payload.update(self._additional_claims)
|
||||
|
||||
token = jwt.encode(self._signer, payload)
|
||||
|
||||
return token
|
||||
|
||||
def _call_metadata_identity_endpoint(self, request):
|
||||
"""Request ID token from metadata identity endpoint.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Returns:
|
||||
Tuple[str, datetime.datetime]: The ID token and the expiry of the ID token.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the Compute Engine metadata
|
||||
service can't be reached or if the instance has no credentials.
|
||||
ValueError: If extracting expiry from the obtained ID token fails.
|
||||
"""
|
||||
try:
|
||||
path = "instance/service-accounts/default/identity"
|
||||
params = {"audience": self._target_audience, "format": "full"}
|
||||
metrics_header = {
|
||||
metrics.API_CLIENT_HEADER: metrics.token_request_id_token_mds()
|
||||
}
|
||||
id_token = _metadata.get(
|
||||
request, path, params=params, headers=metrics_header
|
||||
)
|
||||
except exceptions.TransportError as caught_exc:
|
||||
new_exc = exceptions.RefreshError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
_, payload, _, _ = jwt._unverified_decode(id_token)
|
||||
return id_token, _helpers.utcfromtimestamp(payload["exp"])
|
||||
|
||||
def refresh(self, request):
|
||||
"""Refreshes the ID token.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the credentials could
|
||||
not be refreshed.
|
||||
ValueError: If extracting expiry from the obtained ID token fails.
|
||||
"""
|
||||
if self._use_metadata_identity_endpoint:
|
||||
self.token, self.expiry = self._call_metadata_identity_endpoint(request)
|
||||
else:
|
||||
assertion = self._make_authorization_grant_assertion()
|
||||
access_token, expiry, _ = _client.id_token_jwt_grant(
|
||||
request, self._token_uri, assertion
|
||||
)
|
||||
self.token = access_token
|
||||
self.expiry = expiry
|
||||
|
||||
@property # type: ignore
|
||||
@_helpers.copy_docstring(credentials.Signing)
|
||||
def signer(self):
|
||||
return self._signer
|
||||
|
||||
def sign_bytes(self, message):
|
||||
"""Signs the given message.
|
||||
|
||||
Args:
|
||||
message (bytes): The message to sign.
|
||||
|
||||
Returns:
|
||||
bytes: The message's cryptographic signature.
|
||||
|
||||
Raises:
|
||||
ValueError:
|
||||
Signer is not available if metadata identity endpoint is used.
|
||||
"""
|
||||
if self._use_metadata_identity_endpoint:
|
||||
raise exceptions.InvalidOperation(
|
||||
"Signer is not available if metadata identity endpoint is used"
|
||||
)
|
||||
return self._signer.sign(message)
|
||||
|
||||
@property
|
||||
def service_account_email(self):
|
||||
"""The service account email."""
|
||||
return self._service_account_email
|
||||
|
||||
@property
|
||||
def signer_email(self):
|
||||
return self._service_account_email
|
||||
667
venv/lib/python3.12/site-packages/google/auth/credentials.py
Normal file
667
venv/lib/python3.12/site-packages/google/auth/credentials.py
Normal file
@@ -0,0 +1,667 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
|
||||
"""Interfaces for credentials."""
|
||||
|
||||
import abc
|
||||
from enum import Enum
|
||||
import logging
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
from google.auth import _helpers, environment_vars
|
||||
from google.auth import exceptions
|
||||
from google.auth import metrics
|
||||
from google.auth._credentials_base import _BaseCredentials
|
||||
from google.auth._refresh_worker import RefreshThreadManager
|
||||
|
||||
DEFAULT_UNIVERSE_DOMAIN = "googleapis.com"
|
||||
NO_OP_TRUST_BOUNDARY_LOCATIONS: List[str] = []
|
||||
NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS = "0x0"
|
||||
|
||||
_LOGGER = logging.getLogger("google.auth._default")
|
||||
|
||||
|
||||
class Credentials(_BaseCredentials):
|
||||
"""Base class for all credentials.
|
||||
|
||||
All credentials have a :attr:`token` that is used for authentication and
|
||||
may also optionally set an :attr:`expiry` to indicate when the token will
|
||||
no longer be valid.
|
||||
|
||||
Most credentials will be :attr:`invalid` until :meth:`refresh` is called.
|
||||
Credentials can do this automatically before the first HTTP request in
|
||||
:meth:`before_request`.
|
||||
|
||||
Although the token and expiration will change as the credentials are
|
||||
:meth:`refreshed <refresh>` and used, credentials should be considered
|
||||
immutable. Various credentials will accept configuration such as private
|
||||
keys, scopes, and other options. These options are not changeable after
|
||||
construction. Some classes will provide mechanisms to copy the credentials
|
||||
with modifications such as :meth:`ScopedCredentials.with_scopes`.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(Credentials, self).__init__()
|
||||
|
||||
self.expiry = None
|
||||
"""Optional[datetime]: When the token expires and is no longer valid.
|
||||
If this is None, the token is assumed to never expire."""
|
||||
self._quota_project_id = None
|
||||
"""Optional[str]: Project to use for quota and billing purposes."""
|
||||
self._trust_boundary = None
|
||||
"""Optional[dict]: Cache of a trust boundary response which has a list
|
||||
of allowed regions and an encoded string representation of credentials
|
||||
trust boundary."""
|
||||
self._universe_domain = DEFAULT_UNIVERSE_DOMAIN
|
||||
"""Optional[str]: The universe domain value, default is googleapis.com
|
||||
"""
|
||||
|
||||
self._use_non_blocking_refresh = False
|
||||
self._refresh_worker = RefreshThreadManager()
|
||||
|
||||
@property
|
||||
def expired(self):
|
||||
"""Checks if the credentials are expired.
|
||||
|
||||
Note that credentials can be invalid but not expired because
|
||||
Credentials with :attr:`expiry` set to None is considered to never
|
||||
expire.
|
||||
|
||||
.. deprecated:: v2.24.0
|
||||
Prefer checking :attr:`token_state` instead.
|
||||
"""
|
||||
if not self.expiry:
|
||||
return False
|
||||
# Remove some threshold from expiry to err on the side of reporting
|
||||
# expiration early so that we avoid the 401-refresh-retry loop.
|
||||
skewed_expiry = self.expiry - _helpers.REFRESH_THRESHOLD
|
||||
return _helpers.utcnow() >= skewed_expiry
|
||||
|
||||
@property
|
||||
def valid(self):
|
||||
"""Checks the validity of the credentials.
|
||||
|
||||
This is True if the credentials have a :attr:`token` and the token
|
||||
is not :attr:`expired`.
|
||||
|
||||
.. deprecated:: v2.24.0
|
||||
Prefer checking :attr:`token_state` instead.
|
||||
"""
|
||||
return self.token is not None and not self.expired
|
||||
|
||||
@property
|
||||
def token_state(self):
|
||||
"""
|
||||
See `:obj:`TokenState`
|
||||
"""
|
||||
if self.token is None:
|
||||
return TokenState.INVALID
|
||||
|
||||
# Credentials that can't expire are always treated as fresh.
|
||||
if self.expiry is None:
|
||||
return TokenState.FRESH
|
||||
|
||||
expired = _helpers.utcnow() >= self.expiry
|
||||
if expired:
|
||||
return TokenState.INVALID
|
||||
|
||||
is_stale = _helpers.utcnow() >= (self.expiry - _helpers.REFRESH_THRESHOLD)
|
||||
if is_stale:
|
||||
return TokenState.STALE
|
||||
|
||||
return TokenState.FRESH
|
||||
|
||||
@property
|
||||
def quota_project_id(self):
|
||||
"""Project to use for quota and billing purposes."""
|
||||
return self._quota_project_id
|
||||
|
||||
@property
|
||||
def universe_domain(self):
|
||||
"""The universe domain value."""
|
||||
return self._universe_domain
|
||||
|
||||
def get_cred_info(self):
|
||||
"""The credential information JSON.
|
||||
|
||||
The credential information will be added to auth related error messages
|
||||
by client library.
|
||||
|
||||
Returns:
|
||||
Mapping[str, str]: The credential information JSON.
|
||||
"""
|
||||
return None
|
||||
|
||||
@abc.abstractmethod
|
||||
def refresh(self, request):
|
||||
"""Refreshes the access token.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the credentials could
|
||||
not be refreshed.
|
||||
"""
|
||||
# pylint: disable=missing-raises-doc
|
||||
# (pylint doesn't recognize that this is abstract)
|
||||
raise NotImplementedError("Refresh must be implemented")
|
||||
|
||||
def _metric_header_for_usage(self):
|
||||
"""The x-goog-api-client header for token usage metric.
|
||||
|
||||
This header will be added to the API service requests in before_request
|
||||
method. For example, "cred-type/sa-jwt" means service account self
|
||||
signed jwt access token is used in the API service request
|
||||
authorization header. Children credentials classes need to override
|
||||
this method to provide the header value, if the token usage metric is
|
||||
needed.
|
||||
|
||||
Returns:
|
||||
str: The x-goog-api-client header value.
|
||||
"""
|
||||
return None
|
||||
|
||||
def apply(self, headers, token=None):
|
||||
"""Apply the token to the authentication header.
|
||||
|
||||
Args:
|
||||
headers (Mapping): The HTTP request headers.
|
||||
token (Optional[str]): If specified, overrides the current access
|
||||
token.
|
||||
"""
|
||||
self._apply(headers, token)
|
||||
if self.quota_project_id:
|
||||
headers["x-goog-user-project"] = self.quota_project_id
|
||||
|
||||
def _blocking_refresh(self, request):
|
||||
if not self.valid:
|
||||
self.refresh(request)
|
||||
|
||||
def _non_blocking_refresh(self, request):
|
||||
use_blocking_refresh_fallback = False
|
||||
|
||||
if self.token_state == TokenState.STALE:
|
||||
use_blocking_refresh_fallback = not self._refresh_worker.start_refresh(
|
||||
self, request
|
||||
)
|
||||
|
||||
if self.token_state == TokenState.INVALID or use_blocking_refresh_fallback:
|
||||
self.refresh(request)
|
||||
# If the blocking refresh succeeds then we can clear the error info
|
||||
# on the background refresh worker, and perform refreshes in a
|
||||
# background thread.
|
||||
self._refresh_worker.clear_error()
|
||||
|
||||
def before_request(self, request, method, url, headers):
|
||||
"""Performs credential-specific before request logic.
|
||||
|
||||
Refreshes the credentials if necessary, then calls :meth:`apply` to
|
||||
apply the token to the authentication header.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
method (str): The request's HTTP method or the RPC method being
|
||||
invoked.
|
||||
url (str): The request's URI or the RPC service's URI.
|
||||
headers (Mapping): The request's headers.
|
||||
"""
|
||||
# pylint: disable=unused-argument
|
||||
# (Subclasses may use these arguments to ascertain information about
|
||||
# the http request.)
|
||||
if self._use_non_blocking_refresh:
|
||||
self._non_blocking_refresh(request)
|
||||
else:
|
||||
self._blocking_refresh(request)
|
||||
|
||||
metrics.add_metric_header(headers, self._metric_header_for_usage())
|
||||
self.apply(headers)
|
||||
|
||||
def with_non_blocking_refresh(self):
|
||||
self._use_non_blocking_refresh = True
|
||||
|
||||
|
||||
class CredentialsWithQuotaProject(Credentials):
|
||||
"""Abstract base for credentials supporting ``with_quota_project`` factory"""
|
||||
|
||||
def with_quota_project(self, quota_project_id):
|
||||
"""Returns a copy of these credentials with a modified quota project.
|
||||
|
||||
Args:
|
||||
quota_project_id (str): The project to use for quota and
|
||||
billing purposes
|
||||
|
||||
Returns:
|
||||
google.auth.credentials.Credentials: A new credentials instance.
|
||||
"""
|
||||
raise NotImplementedError("This credential does not support quota project.")
|
||||
|
||||
def with_quota_project_from_environment(self):
|
||||
quota_from_env = os.environ.get(environment_vars.GOOGLE_CLOUD_QUOTA_PROJECT)
|
||||
if quota_from_env:
|
||||
return self.with_quota_project(quota_from_env)
|
||||
return self
|
||||
|
||||
|
||||
class CredentialsWithTokenUri(Credentials):
|
||||
"""Abstract base for credentials supporting ``with_token_uri`` factory"""
|
||||
|
||||
def with_token_uri(self, token_uri):
|
||||
"""Returns a copy of these credentials with a modified token uri.
|
||||
|
||||
Args:
|
||||
token_uri (str): The uri to use for fetching/exchanging tokens
|
||||
|
||||
Returns:
|
||||
google.auth.credentials.Credentials: A new credentials instance.
|
||||
"""
|
||||
raise NotImplementedError("This credential does not use token uri.")
|
||||
|
||||
|
||||
class CredentialsWithUniverseDomain(Credentials):
|
||||
"""Abstract base for credentials supporting ``with_universe_domain`` factory"""
|
||||
|
||||
def with_universe_domain(self, universe_domain):
|
||||
"""Returns a copy of these credentials with a modified universe domain.
|
||||
|
||||
Args:
|
||||
universe_domain (str): The universe domain to use
|
||||
|
||||
Returns:
|
||||
google.auth.credentials.Credentials: A new credentials instance.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"This credential does not support with_universe_domain."
|
||||
)
|
||||
|
||||
|
||||
class CredentialsWithTrustBoundary(Credentials):
|
||||
"""Abstract base for credentials supporting ``with_trust_boundary`` factory"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def _perform_refresh_token(self, request):
|
||||
"""Refreshes the access token.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the credentials could
|
||||
not be refreshed.
|
||||
"""
|
||||
raise NotImplementedError("_perform_refresh_token must be implemented")
|
||||
|
||||
def with_trust_boundary(self, trust_boundary):
|
||||
"""Returns a copy of these credentials with a modified trust boundary.
|
||||
|
||||
Args:
|
||||
trust_boundary Mapping[str, str]: The trust boundary to use for the
|
||||
credential. This should be a map with a "locations" key that maps to
|
||||
a list of GCP regions, and a "encodedLocations" key that maps to a
|
||||
hex string.
|
||||
|
||||
Returns:
|
||||
google.auth.credentials.Credentials: A new credentials instance.
|
||||
"""
|
||||
raise NotImplementedError("This credential does not support trust boundaries.")
|
||||
|
||||
def _is_trust_boundary_lookup_required(self):
|
||||
"""Checks if a trust boundary lookup is required.
|
||||
|
||||
A lookup is required if the feature is enabled via an environment
|
||||
variable, the universe domain is supported, and a no-op boundary
|
||||
is not already cached.
|
||||
|
||||
Returns:
|
||||
bool: True if a trust boundary lookup is required, False otherwise.
|
||||
"""
|
||||
# 1. Check if the feature is enabled via environment variable.
|
||||
if not _helpers.get_bool_from_env(
|
||||
environment_vars.GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED, default=False
|
||||
):
|
||||
return False
|
||||
|
||||
# 2. Skip trust boundary flow for non-default universe domains.
|
||||
if self.universe_domain != DEFAULT_UNIVERSE_DOMAIN:
|
||||
return False
|
||||
|
||||
# 3. Do not trigger refresh if credential has a cached no-op trust boundary.
|
||||
return not self._has_no_op_trust_boundary()
|
||||
|
||||
def _get_trust_boundary_header(self):
|
||||
if self._trust_boundary is not None:
|
||||
if self._has_no_op_trust_boundary():
|
||||
# STS expects an empty string if the trust boundary value is no-op.
|
||||
return {"x-allowed-locations": ""}
|
||||
else:
|
||||
return {"x-allowed-locations": self._trust_boundary["encodedLocations"]}
|
||||
return {}
|
||||
|
||||
def apply(self, headers, token=None):
|
||||
"""Apply the token to the authentication header."""
|
||||
super().apply(headers, token)
|
||||
headers.update(self._get_trust_boundary_header())
|
||||
|
||||
def refresh(self, request):
|
||||
"""Refreshes the access token and the trust boundary.
|
||||
|
||||
This method calls the subclass's token refresh logic and then
|
||||
refreshes the trust boundary if applicable.
|
||||
"""
|
||||
self._perform_refresh_token(request)
|
||||
self._refresh_trust_boundary(request)
|
||||
|
||||
def _refresh_trust_boundary(self, request):
|
||||
"""Triggers a refresh of the trust boundary and updates the cache if necessary.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the trust boundary could
|
||||
not be refreshed and no cached value is available.
|
||||
"""
|
||||
if not self._is_trust_boundary_lookup_required():
|
||||
return
|
||||
try:
|
||||
self._trust_boundary = self._lookup_trust_boundary(request)
|
||||
except exceptions.RefreshError as error:
|
||||
# If the call to the lookup API failed, check if there is a trust boundary
|
||||
# already cached. If there is, do nothing. If not, then throw the error.
|
||||
if self._trust_boundary is None:
|
||||
raise error
|
||||
if _helpers.is_logging_enabled(_LOGGER):
|
||||
_LOGGER.debug(
|
||||
"Using cached trust boundary due to refresh error: %s", error
|
||||
)
|
||||
return
|
||||
|
||||
def _lookup_trust_boundary(self, request):
|
||||
"""Calls the trust boundary lookup API to refresh the trust boundary cache.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Returns:
|
||||
trust_boundary (dict): The trust boundary object returned by the lookup API.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the trust boundary could not be
|
||||
retrieved.
|
||||
"""
|
||||
from google.oauth2 import _client
|
||||
|
||||
url = self._build_trust_boundary_lookup_url()
|
||||
if not url:
|
||||
raise exceptions.InvalidValue("Failed to build trust boundary lookup URL.")
|
||||
|
||||
headers = {}
|
||||
self._apply(headers)
|
||||
headers.update(self._get_trust_boundary_header())
|
||||
return _client._lookup_trust_boundary(request, url, headers=headers)
|
||||
|
||||
@abc.abstractmethod
|
||||
def _build_trust_boundary_lookup_url(self):
|
||||
"""
|
||||
Builds and returns the URL for the trust boundary lookup API.
|
||||
|
||||
This method should be implemented by subclasses to provide the
|
||||
specific URL based on the credential type and its properties.
|
||||
|
||||
Returns:
|
||||
str: The URL for the trust boundary lookup endpoint, or None
|
||||
if lookup should be skipped (e.g., for non-applicable universe domains).
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"_build_trust_boundary_lookup_url must be implemented"
|
||||
)
|
||||
|
||||
def _has_no_op_trust_boundary(self):
|
||||
# A no-op trust boundary is indicated by encodedLocations being "0x0".
|
||||
# The "locations" list may or may not be present as an empty list.
|
||||
if self._trust_boundary is None:
|
||||
return False
|
||||
return (
|
||||
self._trust_boundary.get("encodedLocations")
|
||||
== NO_OP_TRUST_BOUNDARY_ENCODED_LOCATIONS
|
||||
)
|
||||
|
||||
|
||||
class AnonymousCredentials(Credentials):
|
||||
"""Credentials that do not provide any authentication information.
|
||||
|
||||
These are useful in the case of services that support anonymous access or
|
||||
local service emulators that do not use credentials.
|
||||
"""
|
||||
|
||||
@property
|
||||
def expired(self):
|
||||
"""Returns `False`, anonymous credentials never expire."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def valid(self):
|
||||
"""Returns `True`, anonymous credentials are always valid."""
|
||||
return True
|
||||
|
||||
def refresh(self, request):
|
||||
"""Raises :class:``InvalidOperation``, anonymous credentials cannot be
|
||||
refreshed."""
|
||||
raise exceptions.InvalidOperation("Anonymous credentials cannot be refreshed.")
|
||||
|
||||
def apply(self, headers, token=None):
|
||||
"""Anonymous credentials do nothing to the request.
|
||||
|
||||
The optional ``token`` argument is not supported.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidValue: If a token was specified.
|
||||
"""
|
||||
if token is not None:
|
||||
raise exceptions.InvalidValue("Anonymous credentials don't support tokens.")
|
||||
|
||||
def before_request(self, request, method, url, headers):
|
||||
"""Anonymous credentials do nothing to the request."""
|
||||
|
||||
|
||||
class ReadOnlyScoped(metaclass=abc.ABCMeta):
|
||||
"""Interface for credentials whose scopes can be queried.
|
||||
|
||||
OAuth 2.0-based credentials allow limiting access using scopes as described
|
||||
in `RFC6749 Section 3.3`_.
|
||||
If a credential class implements this interface then the credentials either
|
||||
use scopes in their implementation.
|
||||
|
||||
Some credentials require scopes in order to obtain a token. You can check
|
||||
if scoping is necessary with :attr:`requires_scopes`::
|
||||
|
||||
if credentials.requires_scopes:
|
||||
# Scoping is required.
|
||||
credentials = credentials.with_scopes(scopes=['one', 'two'])
|
||||
|
||||
Credentials that require scopes must either be constructed with scopes::
|
||||
|
||||
credentials = SomeScopedCredentials(scopes=['one', 'two'])
|
||||
|
||||
Or must copy an existing instance using :meth:`with_scopes`::
|
||||
|
||||
scoped_credentials = credentials.with_scopes(scopes=['one', 'two'])
|
||||
|
||||
Some credentials have scopes but do not allow or require scopes to be set,
|
||||
these credentials can be used as-is.
|
||||
|
||||
.. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super(ReadOnlyScoped, self).__init__()
|
||||
self._scopes = None
|
||||
self._default_scopes = None
|
||||
|
||||
@property
|
||||
def scopes(self):
|
||||
"""Sequence[str]: the credentials' current set of scopes."""
|
||||
return self._scopes
|
||||
|
||||
@property
|
||||
def default_scopes(self):
|
||||
"""Sequence[str]: the credentials' current set of default scopes."""
|
||||
return self._default_scopes
|
||||
|
||||
@abc.abstractproperty
|
||||
def requires_scopes(self):
|
||||
"""True if these credentials require scopes to obtain an access token."""
|
||||
return False
|
||||
|
||||
def has_scopes(self, scopes):
|
||||
"""Checks if the credentials have the given scopes.
|
||||
|
||||
.. warning: This method is not guaranteed to be accurate if the
|
||||
credentials are :attr:`~Credentials.invalid`.
|
||||
|
||||
Args:
|
||||
scopes (Sequence[str]): The list of scopes to check.
|
||||
|
||||
Returns:
|
||||
bool: True if the credentials have the given scopes.
|
||||
"""
|
||||
credential_scopes = (
|
||||
self._scopes if self._scopes is not None else self._default_scopes
|
||||
)
|
||||
return set(scopes).issubset(set(credential_scopes or []))
|
||||
|
||||
|
||||
class Scoped(ReadOnlyScoped):
|
||||
"""Interface for credentials whose scopes can be replaced while copying.
|
||||
|
||||
OAuth 2.0-based credentials allow limiting access using scopes as described
|
||||
in `RFC6749 Section 3.3`_.
|
||||
If a credential class implements this interface then the credentials either
|
||||
use scopes in their implementation.
|
||||
|
||||
Some credentials require scopes in order to obtain a token. You can check
|
||||
if scoping is necessary with :attr:`requires_scopes`::
|
||||
|
||||
if credentials.requires_scopes:
|
||||
# Scoping is required.
|
||||
credentials = credentials.create_scoped(['one', 'two'])
|
||||
|
||||
Credentials that require scopes must either be constructed with scopes::
|
||||
|
||||
credentials = SomeScopedCredentials(scopes=['one', 'two'])
|
||||
|
||||
Or must copy an existing instance using :meth:`with_scopes`::
|
||||
|
||||
scoped_credentials = credentials.with_scopes(scopes=['one', 'two'])
|
||||
|
||||
Some credentials have scopes but do not allow or require scopes to be set,
|
||||
these credentials can be used as-is.
|
||||
|
||||
.. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def with_scopes(self, scopes, default_scopes=None):
|
||||
"""Create a copy of these credentials with the specified scopes.
|
||||
|
||||
Args:
|
||||
scopes (Sequence[str]): The list of scopes to attach to the
|
||||
current credentials.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the credentials' scopes can not be changed.
|
||||
This can be avoided by checking :attr:`requires_scopes` before
|
||||
calling this method.
|
||||
"""
|
||||
raise NotImplementedError("This class does not require scoping.")
|
||||
|
||||
|
||||
def with_scopes_if_required(credentials, scopes, default_scopes=None):
|
||||
"""Creates a copy of the credentials with scopes if scoping is required.
|
||||
|
||||
This helper function is useful when you do not know (or care to know) the
|
||||
specific type of credentials you are using (such as when you use
|
||||
:func:`google.auth.default`). This function will call
|
||||
:meth:`Scoped.with_scopes` if the credentials are scoped credentials and if
|
||||
the credentials require scoping. Otherwise, it will return the credentials
|
||||
as-is.
|
||||
|
||||
Args:
|
||||
credentials (google.auth.credentials.Credentials): The credentials to
|
||||
scope if necessary.
|
||||
scopes (Sequence[str]): The list of scopes to use.
|
||||
default_scopes (Sequence[str]): Default scopes passed by a
|
||||
Google client library. Use 'scopes' for user-defined scopes.
|
||||
|
||||
Returns:
|
||||
google.auth.credentials.Credentials: Either a new set of scoped
|
||||
credentials, or the passed in credentials instance if no scoping
|
||||
was required.
|
||||
"""
|
||||
if isinstance(credentials, Scoped) and credentials.requires_scopes:
|
||||
return credentials.with_scopes(scopes, default_scopes=default_scopes)
|
||||
else:
|
||||
return credentials
|
||||
|
||||
|
||||
class Signing(metaclass=abc.ABCMeta):
|
||||
"""Interface for credentials that can cryptographically sign messages."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def sign_bytes(self, message):
|
||||
"""Signs the given message.
|
||||
|
||||
Args:
|
||||
message (bytes): The message to sign.
|
||||
|
||||
Returns:
|
||||
bytes: The message's cryptographic signature.
|
||||
"""
|
||||
# pylint: disable=missing-raises-doc,redundant-returns-doc
|
||||
# (pylint doesn't recognize that this is abstract)
|
||||
raise NotImplementedError("Sign bytes must be implemented.")
|
||||
|
||||
@abc.abstractproperty
|
||||
def signer_email(self):
|
||||
"""Optional[str]: An email address that identifies the signer."""
|
||||
# pylint: disable=missing-raises-doc
|
||||
# (pylint doesn't recognize that this is abstract)
|
||||
raise NotImplementedError("Signer email must be implemented.")
|
||||
|
||||
@abc.abstractproperty
|
||||
def signer(self):
|
||||
"""google.auth.crypt.Signer: The signer used to sign bytes."""
|
||||
# pylint: disable=missing-raises-doc
|
||||
# (pylint doesn't recognize that this is abstract)
|
||||
raise NotImplementedError("Signer must be implemented.")
|
||||
|
||||
|
||||
class TokenState(Enum):
|
||||
"""
|
||||
Tracks the state of a token.
|
||||
FRESH: The token is valid. It is not expired or close to expired, or the token has no expiry.
|
||||
STALE: The token is close to expired, and should be refreshed. The token can be used normally.
|
||||
INVALID: The token is expired or invalid. The token cannot be used for a normal operation.
|
||||
"""
|
||||
|
||||
FRESH = 1
|
||||
STALE = 2
|
||||
INVALID = 3
|
||||
@@ -0,0 +1,96 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Cryptography helpers for verifying and signing messages.
|
||||
|
||||
The simplest way to verify signatures is using :func:`verify_signature`::
|
||||
|
||||
cert = open('certs.pem').read()
|
||||
valid = crypt.verify_signature(message, signature, cert)
|
||||
|
||||
If you're going to verify many messages with the same certificate, you can use
|
||||
:class:`RSAVerifier`::
|
||||
|
||||
cert = open('certs.pem').read()
|
||||
verifier = crypt.RSAVerifier.from_string(cert)
|
||||
valid = verifier.verify(message, signature)
|
||||
|
||||
To sign messages use :class:`RSASigner` with a private key::
|
||||
|
||||
private_key = open('private_key.pem').read()
|
||||
signer = crypt.RSASigner.from_string(private_key)
|
||||
signature = signer.sign(message)
|
||||
|
||||
The code above also works for :class:`ES256Signer` and :class:`ES256Verifier`.
|
||||
Note that these two classes are only available if your `cryptography` dependency
|
||||
version is at least 1.4.0.
|
||||
"""
|
||||
|
||||
from google.auth.crypt import base
|
||||
from google.auth.crypt import es
|
||||
from google.auth.crypt import es256
|
||||
from google.auth.crypt import rsa
|
||||
|
||||
EsSigner = es.EsSigner
|
||||
EsVerifier = es.EsVerifier
|
||||
ES256Signer = es256.ES256Signer
|
||||
ES256Verifier = es256.ES256Verifier
|
||||
|
||||
|
||||
# Aliases to maintain the v1.0.0 interface, as the crypt module was split
|
||||
# into submodules.
|
||||
Signer = base.Signer
|
||||
Verifier = base.Verifier
|
||||
RSASigner = rsa.RSASigner
|
||||
RSAVerifier = rsa.RSAVerifier
|
||||
|
||||
|
||||
def verify_signature(message, signature, certs, verifier_cls=rsa.RSAVerifier):
|
||||
"""Verify an RSA or ECDSA cryptographic signature.
|
||||
|
||||
Checks that the provided ``signature`` was generated from ``bytes`` using
|
||||
the private key associated with the ``cert``.
|
||||
|
||||
Args:
|
||||
message (Union[str, bytes]): The plaintext message.
|
||||
signature (Union[str, bytes]): The cryptographic signature to check.
|
||||
certs (Union[Sequence, str, bytes]): The certificate or certificates
|
||||
to use to check the signature.
|
||||
verifier_cls (Optional[~google.auth.crypt.base.Signer]): Which verifier
|
||||
class to use for verification. This can be used to select different
|
||||
algorithms, such as RSA or ECDSA. Default value is :class:`RSAVerifier`.
|
||||
|
||||
Returns:
|
||||
bool: True if the signature is valid, otherwise False.
|
||||
"""
|
||||
if isinstance(certs, (str, bytes)):
|
||||
certs = [certs]
|
||||
|
||||
for cert in certs:
|
||||
verifier = verifier_cls.from_string(cert)
|
||||
if verifier.verify(message, signature):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EsSigner",
|
||||
"EsVerifier",
|
||||
"ES256Signer",
|
||||
"ES256Verifier",
|
||||
"RSASigner",
|
||||
"RSAVerifier",
|
||||
"Signer",
|
||||
"Verifier",
|
||||
]
|
||||
@@ -0,0 +1,151 @@
|
||||
# Copyright 2017 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.
|
||||
|
||||
"""RSA verifier and signer that use the ``cryptography`` library.
|
||||
|
||||
This is a much faster implementation than the default (in
|
||||
``google.auth.crypt._python_rsa``), which depends on the pure-Python
|
||||
``rsa`` library.
|
||||
"""
|
||||
|
||||
import cryptography.exceptions
|
||||
from cryptography.hazmat import backends
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
import cryptography.x509
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth.crypt import base
|
||||
|
||||
_CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----"
|
||||
_BACKEND = backends.default_backend()
|
||||
_PADDING = padding.PKCS1v15()
|
||||
_SHA256 = hashes.SHA256()
|
||||
|
||||
|
||||
class RSAVerifier(base.Verifier):
|
||||
"""Verifies RSA cryptographic signatures using public keys.
|
||||
|
||||
Args:
|
||||
public_key (
|
||||
cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey):
|
||||
The public key used to verify signatures.
|
||||
"""
|
||||
|
||||
def __init__(self, public_key):
|
||||
self._pubkey = public_key
|
||||
|
||||
@_helpers.copy_docstring(base.Verifier)
|
||||
def verify(self, message, signature):
|
||||
message = _helpers.to_bytes(message)
|
||||
try:
|
||||
self._pubkey.verify(signature, message, _PADDING, _SHA256)
|
||||
return True
|
||||
except (ValueError, cryptography.exceptions.InvalidSignature):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, public_key):
|
||||
"""Construct an Verifier instance from a public key or public
|
||||
certificate string.
|
||||
|
||||
Args:
|
||||
public_key (Union[str, bytes]): The public key in PEM format or the
|
||||
x509 public key certificate.
|
||||
|
||||
Returns:
|
||||
Verifier: The constructed verifier.
|
||||
|
||||
Raises:
|
||||
ValueError: If the public key can't be parsed.
|
||||
"""
|
||||
public_key_data = _helpers.to_bytes(public_key)
|
||||
|
||||
if _CERTIFICATE_MARKER in public_key_data:
|
||||
cert = cryptography.x509.load_pem_x509_certificate(
|
||||
public_key_data, _BACKEND
|
||||
)
|
||||
pubkey = cert.public_key()
|
||||
|
||||
else:
|
||||
pubkey = serialization.load_pem_public_key(public_key_data, _BACKEND)
|
||||
|
||||
return cls(pubkey)
|
||||
|
||||
|
||||
class RSASigner(base.Signer, base.FromServiceAccountMixin):
|
||||
"""Signs messages with an RSA private key.
|
||||
|
||||
Args:
|
||||
private_key (
|
||||
cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey):
|
||||
The private key to sign with.
|
||||
key_id (str): Optional key ID used to identify this private key. This
|
||||
can be useful to associate the private key with its associated
|
||||
public key or certificate.
|
||||
"""
|
||||
|
||||
def __init__(self, private_key, key_id=None):
|
||||
self._key = private_key
|
||||
self._key_id = key_id
|
||||
|
||||
@property # type: ignore
|
||||
@_helpers.copy_docstring(base.Signer)
|
||||
def key_id(self):
|
||||
return self._key_id
|
||||
|
||||
@_helpers.copy_docstring(base.Signer)
|
||||
def sign(self, message):
|
||||
message = _helpers.to_bytes(message)
|
||||
return self._key.sign(message, _PADDING, _SHA256)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, key, key_id=None):
|
||||
"""Construct a RSASigner from a private key in PEM format.
|
||||
|
||||
Args:
|
||||
key (Union[bytes, str]): Private key in PEM format.
|
||||
key_id (str): An optional key id used to identify the private key.
|
||||
|
||||
Returns:
|
||||
google.auth.crypt._cryptography_rsa.RSASigner: The
|
||||
constructed signer.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``key`` is not ``bytes`` or ``str`` (unicode).
|
||||
UnicodeDecodeError: If ``key`` is ``bytes`` but cannot be decoded
|
||||
into a UTF-8 ``str``.
|
||||
ValueError: If ``cryptography`` "Could not deserialize key data."
|
||||
"""
|
||||
key = _helpers.to_bytes(key)
|
||||
private_key = serialization.load_pem_private_key(
|
||||
key, password=None, backend=_BACKEND
|
||||
)
|
||||
return cls(private_key, key_id=key_id)
|
||||
|
||||
def __getstate__(self):
|
||||
"""Pickle helper that serializes the _key attribute."""
|
||||
state = self.__dict__.copy()
|
||||
state["_key"] = self._key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
return state
|
||||
|
||||
def __setstate__(self, state):
|
||||
"""Pickle helper that deserializes the _key attribute."""
|
||||
state["_key"] = serialization.load_pem_private_key(state["_key"], None)
|
||||
self.__dict__.update(state)
|
||||
@@ -0,0 +1,199 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Pure-Python RSA cryptography implementation.
|
||||
|
||||
Uses the ``rsa``, ``pyasn1`` and ``pyasn1_modules`` packages
|
||||
to parse PEM files storing PKCS#1 or PKCS#8 keys as well as
|
||||
certificates. There is no support for p12 files.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import io
|
||||
import warnings
|
||||
|
||||
from pyasn1.codec.der import decoder # type: ignore
|
||||
from pyasn1_modules import pem # type: ignore
|
||||
from pyasn1_modules.rfc2459 import Certificate # type: ignore
|
||||
from pyasn1_modules.rfc5208 import PrivateKeyInfo # type: ignore
|
||||
import rsa # type: ignore
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import exceptions
|
||||
from google.auth.crypt import base
|
||||
|
||||
_POW2 = (128, 64, 32, 16, 8, 4, 2, 1)
|
||||
_CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----"
|
||||
_PKCS1_MARKER = ("-----BEGIN RSA PRIVATE KEY-----", "-----END RSA PRIVATE KEY-----")
|
||||
_PKCS8_MARKER = ("-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----")
|
||||
_PKCS8_SPEC = PrivateKeyInfo()
|
||||
|
||||
_warning_msg = (
|
||||
"The 'rsa' library is deprecated and will be removed in a future release. "
|
||||
"Please migrate to 'cryptography'."
|
||||
)
|
||||
|
||||
|
||||
def _bit_list_to_bytes(bit_list):
|
||||
"""Converts an iterable of 1s and 0s to bytes.
|
||||
|
||||
Combines the list 8 at a time, treating each group of 8 bits
|
||||
as a single byte.
|
||||
|
||||
Args:
|
||||
bit_list (Sequence): Sequence of 1s and 0s.
|
||||
|
||||
Returns:
|
||||
bytes: The decoded bytes.
|
||||
"""
|
||||
num_bits = len(bit_list)
|
||||
byte_vals = bytearray()
|
||||
for start in range(0, num_bits, 8):
|
||||
curr_bits = bit_list[start : start + 8]
|
||||
char_val = sum(val * digit for val, digit in zip(_POW2, curr_bits))
|
||||
byte_vals.append(char_val)
|
||||
return bytes(byte_vals)
|
||||
|
||||
|
||||
class RSAVerifier(base.Verifier):
|
||||
"""Verifies RSA cryptographic signatures using public keys.
|
||||
|
||||
.. deprecated::
|
||||
The `rsa` library has been archived. Please migrate to
|
||||
`cryptography`.
|
||||
|
||||
Args:
|
||||
public_key (rsa.key.PublicKey): The public key used to verify
|
||||
signatures.
|
||||
"""
|
||||
|
||||
def __init__(self, public_key):
|
||||
warnings.warn(
|
||||
_warning_msg,
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._pubkey = public_key
|
||||
|
||||
@_helpers.copy_docstring(base.Verifier)
|
||||
def verify(self, message, signature):
|
||||
message = _helpers.to_bytes(message)
|
||||
try:
|
||||
return rsa.pkcs1.verify(message, signature, self._pubkey)
|
||||
except (ValueError, rsa.pkcs1.VerificationError):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, public_key):
|
||||
"""Construct an Verifier instance from a public key or public
|
||||
certificate string.
|
||||
|
||||
Args:
|
||||
public_key (Union[str, bytes]): The public key in PEM format or the
|
||||
x509 public key certificate.
|
||||
|
||||
Returns:
|
||||
google.auth.crypt._python_rsa.RSAVerifier: The constructed verifier.
|
||||
|
||||
Raises:
|
||||
ValueError: If the public_key can't be parsed.
|
||||
"""
|
||||
public_key = _helpers.to_bytes(public_key)
|
||||
is_x509_cert = _CERTIFICATE_MARKER in public_key
|
||||
|
||||
# If this is a certificate, extract the public key info.
|
||||
if is_x509_cert:
|
||||
der = rsa.pem.load_pem(public_key, "CERTIFICATE")
|
||||
asn1_cert, remaining = decoder.decode(der, asn1Spec=Certificate())
|
||||
if remaining != b"":
|
||||
raise exceptions.InvalidValue("Unused bytes", remaining)
|
||||
|
||||
cert_info = asn1_cert["tbsCertificate"]["subjectPublicKeyInfo"]
|
||||
key_bytes = _bit_list_to_bytes(cert_info["subjectPublicKey"])
|
||||
pubkey = rsa.PublicKey.load_pkcs1(key_bytes, "DER")
|
||||
else:
|
||||
pubkey = rsa.PublicKey.load_pkcs1(public_key, "PEM")
|
||||
return cls(pubkey)
|
||||
|
||||
|
||||
class RSASigner(base.Signer, base.FromServiceAccountMixin):
|
||||
"""Signs messages with an RSA private key.
|
||||
|
||||
.. deprecated::
|
||||
The `rsa` library has been archived. Please migrate to
|
||||
`cryptography`.
|
||||
|
||||
Args:
|
||||
private_key (rsa.key.PrivateKey): The private key to sign with.
|
||||
key_id (str): Optional key ID used to identify this private key. This
|
||||
can be useful to associate the private key with its associated
|
||||
public key or certificate.
|
||||
"""
|
||||
|
||||
def __init__(self, private_key, key_id=None):
|
||||
warnings.warn(
|
||||
_warning_msg,
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
self._key = private_key
|
||||
self._key_id = key_id
|
||||
|
||||
@property # type: ignore
|
||||
@_helpers.copy_docstring(base.Signer)
|
||||
def key_id(self):
|
||||
return self._key_id
|
||||
|
||||
@_helpers.copy_docstring(base.Signer)
|
||||
def sign(self, message):
|
||||
message = _helpers.to_bytes(message)
|
||||
return rsa.pkcs1.sign(message, self._key, "SHA-256")
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, key, key_id=None):
|
||||
"""Construct an Signer instance from a private key in PEM format.
|
||||
|
||||
Args:
|
||||
key (str): Private key in PEM format.
|
||||
key_id (str): An optional key id used to identify the private key.
|
||||
|
||||
Returns:
|
||||
google.auth.crypt.Signer: The constructed signer.
|
||||
|
||||
Raises:
|
||||
ValueError: If the key cannot be parsed as PKCS#1 or PKCS#8 in
|
||||
PEM format.
|
||||
"""
|
||||
key = _helpers.from_bytes(key) # PEM expects str in Python 3
|
||||
marker_id, key_bytes = pem.readPemBlocksFromFile(
|
||||
io.StringIO(key), _PKCS1_MARKER, _PKCS8_MARKER
|
||||
)
|
||||
|
||||
# Key is in pkcs1 format.
|
||||
if marker_id == 0:
|
||||
private_key = rsa.key.PrivateKey.load_pkcs1(key_bytes, format="DER")
|
||||
# Key is in pkcs8.
|
||||
elif marker_id == 1:
|
||||
key_info, remaining = decoder.decode(key_bytes, asn1Spec=_PKCS8_SPEC)
|
||||
if remaining != b"":
|
||||
raise exceptions.InvalidValue("Unused bytes", remaining)
|
||||
private_key_info = key_info.getComponentByName("privateKey")
|
||||
private_key = rsa.key.PrivateKey.load_pkcs1(
|
||||
private_key_info.asOctets(), format="DER"
|
||||
)
|
||||
else:
|
||||
raise exceptions.MalformedError("No key could be detected.")
|
||||
|
||||
return cls(private_key, key_id=key_id)
|
||||
127
venv/lib/python3.12/site-packages/google/auth/crypt/base.py
Normal file
127
venv/lib/python3.12/site-packages/google/auth/crypt/base.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Base classes for cryptographic signers and verifiers."""
|
||||
|
||||
import abc
|
||||
import io
|
||||
import json
|
||||
|
||||
from google.auth import exceptions
|
||||
|
||||
_JSON_FILE_PRIVATE_KEY = "private_key"
|
||||
_JSON_FILE_PRIVATE_KEY_ID = "private_key_id"
|
||||
|
||||
|
||||
class Verifier(metaclass=abc.ABCMeta):
|
||||
"""Abstract base class for crytographic signature verifiers."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def verify(self, message, signature):
|
||||
"""Verifies a message against a cryptographic signature.
|
||||
|
||||
Args:
|
||||
message (Union[str, bytes]): The message to verify.
|
||||
signature (Union[str, bytes]): The cryptography signature to check.
|
||||
|
||||
Returns:
|
||||
bool: True if message was signed by the private key associated
|
||||
with the public key that this object was constructed with.
|
||||
"""
|
||||
# pylint: disable=missing-raises-doc,redundant-returns-doc
|
||||
# (pylint doesn't recognize that this is abstract)
|
||||
raise NotImplementedError("Verify must be implemented")
|
||||
|
||||
|
||||
class Signer(metaclass=abc.ABCMeta):
|
||||
"""Abstract base class for cryptographic signers."""
|
||||
|
||||
@abc.abstractproperty
|
||||
def key_id(self):
|
||||
"""Optional[str]: The key ID used to identify this private key."""
|
||||
raise NotImplementedError("Key id must be implemented")
|
||||
|
||||
@abc.abstractmethod
|
||||
def sign(self, message):
|
||||
"""Signs a message.
|
||||
|
||||
Args:
|
||||
message (Union[str, bytes]): The message to be signed.
|
||||
|
||||
Returns:
|
||||
bytes: The signature of the message.
|
||||
"""
|
||||
# pylint: disable=missing-raises-doc,redundant-returns-doc
|
||||
# (pylint doesn't recognize that this is abstract)
|
||||
raise NotImplementedError("Sign must be implemented")
|
||||
|
||||
|
||||
class FromServiceAccountMixin(metaclass=abc.ABCMeta):
|
||||
"""Mix-in to enable factory constructors for a Signer."""
|
||||
|
||||
@abc.abstractmethod
|
||||
def from_string(cls, key, key_id=None):
|
||||
"""Construct an Signer instance from a private key string.
|
||||
|
||||
Args:
|
||||
key (str): Private key as a string.
|
||||
key_id (str): An optional key id used to identify the private key.
|
||||
|
||||
Returns:
|
||||
google.auth.crypt.Signer: The constructed signer.
|
||||
|
||||
Raises:
|
||||
ValueError: If the key cannot be parsed.
|
||||
"""
|
||||
raise NotImplementedError("from_string must be implemented")
|
||||
|
||||
@classmethod
|
||||
def from_service_account_info(cls, info):
|
||||
"""Creates a Signer instance instance from a dictionary containing
|
||||
service account info in Google format.
|
||||
|
||||
Args:
|
||||
info (Mapping[str, str]): The service account info in Google
|
||||
format.
|
||||
|
||||
Returns:
|
||||
google.auth.crypt.Signer: The constructed signer.
|
||||
|
||||
Raises:
|
||||
ValueError: If the info is not in the expected format.
|
||||
"""
|
||||
if _JSON_FILE_PRIVATE_KEY not in info:
|
||||
raise exceptions.MalformedError(
|
||||
"The private_key field was not found in the service account " "info."
|
||||
)
|
||||
|
||||
return cls.from_string(
|
||||
info[_JSON_FILE_PRIVATE_KEY], info.get(_JSON_FILE_PRIVATE_KEY_ID)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_service_account_file(cls, filename):
|
||||
"""Creates a Signer instance from a service account .json file
|
||||
in Google format.
|
||||
|
||||
Args:
|
||||
filename (str): The path to the service account .json file.
|
||||
|
||||
Returns:
|
||||
google.auth.crypt.Signer: The constructed signer.
|
||||
"""
|
||||
with io.open(filename, "r", encoding="utf-8") as json_file:
|
||||
data = json.load(json_file)
|
||||
|
||||
return cls.from_service_account_info(data)
|
||||
221
venv/lib/python3.12/site-packages/google/auth/crypt/es.py
Normal file
221
venv/lib/python3.12/site-packages/google/auth/crypt/es.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# 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.
|
||||
|
||||
"""ECDSA verifier and signer that use the ``cryptography`` library.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
import cryptography.exceptions
|
||||
from cryptography.hazmat import backends
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
||||
import cryptography.x509
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth.crypt import base
|
||||
|
||||
|
||||
_CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----"
|
||||
_BACKEND = backends.default_backend()
|
||||
_PADDING = padding.PKCS1v15()
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ESAttributes:
|
||||
"""A class that models ECDSA attributes.
|
||||
|
||||
Attributes:
|
||||
rs_size (int): Size for ASN.1 r and s size.
|
||||
sha_algo (hashes.HashAlgorithm): Hash algorithm.
|
||||
algorithm (str): Algorithm name.
|
||||
"""
|
||||
|
||||
rs_size: int
|
||||
sha_algo: hashes.HashAlgorithm
|
||||
algorithm: str
|
||||
|
||||
@classmethod
|
||||
def from_key(
|
||||
cls, key: Union[ec.EllipticCurvePublicKey, ec.EllipticCurvePrivateKey]
|
||||
):
|
||||
return cls.from_curve(key.curve)
|
||||
|
||||
@classmethod
|
||||
def from_curve(cls, curve: ec.EllipticCurve):
|
||||
# ECDSA raw signature has (r||s) format where r,s are two
|
||||
# integers of size 32 bytes for P-256 curve and 48 bytes
|
||||
# for P-384 curve. For P-256 curve, we use SHA256 hash algo,
|
||||
# and for P-384 curve we use SHA384 algo.
|
||||
if isinstance(curve, ec.SECP384R1):
|
||||
return cls(48, hashes.SHA384(), "ES384")
|
||||
else:
|
||||
# default to ES256
|
||||
return cls(32, hashes.SHA256(), "ES256")
|
||||
|
||||
|
||||
class EsVerifier(base.Verifier):
|
||||
"""Verifies ECDSA cryptographic signatures using public keys.
|
||||
|
||||
Args:
|
||||
public_key (
|
||||
cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey):
|
||||
The public key used to verify signatures.
|
||||
"""
|
||||
|
||||
def __init__(self, public_key: ec.EllipticCurvePublicKey) -> None:
|
||||
self._pubkey = public_key
|
||||
self._attributes = _ESAttributes.from_key(public_key)
|
||||
|
||||
@_helpers.copy_docstring(base.Verifier)
|
||||
def verify(self, message: bytes, signature: bytes) -> bool:
|
||||
# First convert (r||s) raw signature to ASN1 encoded signature.
|
||||
sig_bytes = _helpers.to_bytes(signature)
|
||||
if len(sig_bytes) != self._attributes.rs_size * 2:
|
||||
return False
|
||||
r = int.from_bytes(sig_bytes[: self._attributes.rs_size], byteorder="big")
|
||||
s = int.from_bytes(sig_bytes[self._attributes.rs_size :], byteorder="big")
|
||||
asn1_sig = encode_dss_signature(r, s)
|
||||
|
||||
message = _helpers.to_bytes(message)
|
||||
try:
|
||||
self._pubkey.verify(asn1_sig, message, ec.ECDSA(self._attributes.sha_algo))
|
||||
return True
|
||||
except (ValueError, cryptography.exceptions.InvalidSignature):
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, public_key: Union[str, bytes]) -> "EsVerifier":
|
||||
"""Construct a Verifier instance from a public key or public
|
||||
certificate string.
|
||||
|
||||
Args:
|
||||
public_key (Union[str, bytes]): The public key in PEM format or the
|
||||
x509 public key certificate.
|
||||
|
||||
Returns:
|
||||
google.auth.crypt.Verifier: The constructed verifier.
|
||||
|
||||
Raises:
|
||||
ValueError: If the public key can't be parsed.
|
||||
"""
|
||||
public_key_data = _helpers.to_bytes(public_key)
|
||||
|
||||
if _CERTIFICATE_MARKER in public_key_data:
|
||||
cert = cryptography.x509.load_pem_x509_certificate(
|
||||
public_key_data, _BACKEND
|
||||
)
|
||||
pubkey = cert.public_key() # type: Any
|
||||
|
||||
else:
|
||||
pubkey = serialization.load_pem_public_key(public_key_data, _BACKEND)
|
||||
|
||||
if not isinstance(pubkey, ec.EllipticCurvePublicKey):
|
||||
raise TypeError("Expected public key of type EllipticCurvePublicKey")
|
||||
|
||||
return cls(pubkey)
|
||||
|
||||
|
||||
class EsSigner(base.Signer, base.FromServiceAccountMixin):
|
||||
"""Signs messages with an ECDSA private key.
|
||||
|
||||
Args:
|
||||
private_key (
|
||||
cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey):
|
||||
The private key to sign with.
|
||||
key_id (str): Optional key ID used to identify this private key. This
|
||||
can be useful to associate the private key with its associated
|
||||
public key or certificate.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, private_key: ec.EllipticCurvePrivateKey, key_id: Optional[str] = None
|
||||
) -> None:
|
||||
self._key = private_key
|
||||
self._key_id = key_id
|
||||
self._attributes = _ESAttributes.from_key(private_key)
|
||||
|
||||
@property
|
||||
def algorithm(self) -> str:
|
||||
"""Name of the algorithm used to sign messages.
|
||||
Returns:
|
||||
str: The algorithm name.
|
||||
"""
|
||||
return self._attributes.algorithm
|
||||
|
||||
@property # type: ignore
|
||||
@_helpers.copy_docstring(base.Signer)
|
||||
def key_id(self) -> Optional[str]:
|
||||
return self._key_id
|
||||
|
||||
@_helpers.copy_docstring(base.Signer)
|
||||
def sign(self, message: bytes) -> bytes:
|
||||
message = _helpers.to_bytes(message)
|
||||
asn1_signature = self._key.sign(message, ec.ECDSA(self._attributes.sha_algo))
|
||||
|
||||
# Convert ASN1 encoded signature to (r||s) raw signature.
|
||||
(r, s) = decode_dss_signature(asn1_signature)
|
||||
return r.to_bytes(self._attributes.rs_size, byteorder="big") + s.to_bytes(
|
||||
self._attributes.rs_size, byteorder="big"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_string(
|
||||
cls, key: Union[bytes, str], key_id: Optional[str] = None
|
||||
) -> "EsSigner":
|
||||
"""Construct a RSASigner from a private key in PEM format.
|
||||
|
||||
Args:
|
||||
key (Union[bytes, str]): Private key in PEM format.
|
||||
key_id (str): An optional key id used to identify the private key.
|
||||
|
||||
Returns:
|
||||
google.auth.crypt._cryptography_rsa.RSASigner: The
|
||||
constructed signer.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``key`` is not ``bytes`` or ``str`` (unicode).
|
||||
UnicodeDecodeError: If ``key`` is ``bytes`` but cannot be decoded
|
||||
into a UTF-8 ``str``.
|
||||
ValueError: If ``cryptography`` "Could not deserialize key data."
|
||||
"""
|
||||
key_bytes = _helpers.to_bytes(key)
|
||||
private_key = serialization.load_pem_private_key(
|
||||
key_bytes, password=None, backend=_BACKEND
|
||||
)
|
||||
|
||||
if not isinstance(private_key, ec.EllipticCurvePrivateKey):
|
||||
raise TypeError("Expected private key of type EllipticCurvePrivateKey")
|
||||
|
||||
return cls(private_key, key_id=key_id)
|
||||
|
||||
def __getstate__(self) -> Dict[str, Any]:
|
||||
"""Pickle helper that serializes the _key attribute."""
|
||||
state = self.__dict__.copy()
|
||||
state["_key"] = self._key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
return state
|
||||
|
||||
def __setstate__(self, state: Dict[str, Any]) -> None:
|
||||
"""Pickle helper that deserializes the _key attribute."""
|
||||
state["_key"] = serialization.load_pem_private_key(state["_key"], None)
|
||||
self.__dict__.update(state)
|
||||
45
venv/lib/python3.12/site-packages/google/auth/crypt/es256.py
Normal file
45
venv/lib/python3.12/site-packages/google/auth/crypt/es256.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# 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.
|
||||
|
||||
"""ECDSA (ES256) verifier and signer that use the ``cryptography`` library.
|
||||
"""
|
||||
|
||||
from google.auth.crypt.es import EsSigner
|
||||
from google.auth.crypt.es import EsVerifier
|
||||
|
||||
|
||||
class ES256Verifier(EsVerifier):
|
||||
"""Verifies ECDSA cryptographic signatures using public keys.
|
||||
|
||||
Args:
|
||||
public_key (cryptography.hazmat.primitives.asymmetric.ec.ECDSAPublicKey): The public key used to verify
|
||||
signatures.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ES256Signer(EsSigner):
|
||||
"""Signs messages with an ECDSA private key.
|
||||
|
||||
Args:
|
||||
private_key (
|
||||
cryptography.hazmat.primitives.asymmetric.ec.ECDSAPrivateKey):
|
||||
The private key to sign with.
|
||||
key_id (str): Optional key ID used to identify this private key. This
|
||||
can be useful to associate the private key with its associated
|
||||
public key or certificate.
|
||||
"""
|
||||
|
||||
pass
|
||||
132
venv/lib/python3.12/site-packages/google/auth/crypt/rsa.py
Normal file
132
venv/lib/python3.12/site-packages/google/auth/crypt/rsa.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# Copyright 2017 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.
|
||||
|
||||
"""
|
||||
RSA cryptography signer and verifier.
|
||||
|
||||
This file provides a shared wrapper, that defers to _python_rsa or _cryptography_rsa
|
||||
for implmentations using different third party libraries
|
||||
"""
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth.crypt import _cryptography_rsa
|
||||
from google.auth.crypt import base
|
||||
|
||||
RSA_KEY_MODULE_PREFIX = "rsa.key"
|
||||
|
||||
|
||||
class RSAVerifier(base.Verifier):
|
||||
"""Verifies RSA cryptographic signatures using public keys.
|
||||
|
||||
Args:
|
||||
public_key (Union["rsa.key.PublicKey", cryptography.hazmat.primitives.asymmetric.rsa.RSAPublicKey]):
|
||||
The public key used to verify signatures.
|
||||
Raises:
|
||||
ImportError: if called with an rsa.key.PublicKey, when the rsa library is not installed
|
||||
ValueError: if an unrecognized public key is provided
|
||||
"""
|
||||
|
||||
def __init__(self, public_key):
|
||||
module_str = public_key.__class__.__module__
|
||||
if isinstance(public_key, RSAPublicKey):
|
||||
impl_lib = _cryptography_rsa
|
||||
elif module_str.startswith(RSA_KEY_MODULE_PREFIX):
|
||||
from google.auth.crypt import _python_rsa
|
||||
|
||||
impl_lib = _python_rsa
|
||||
else:
|
||||
raise ValueError(f"unrecognized public key type: {type(public_key)}")
|
||||
self._impl = impl_lib.RSAVerifier(public_key)
|
||||
|
||||
@_helpers.copy_docstring(base.Verifier)
|
||||
def verify(self, message, signature):
|
||||
return self._impl.verify(message, signature)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, public_key):
|
||||
"""Construct a Verifier instance from a public key or public
|
||||
certificate string.
|
||||
|
||||
Args:
|
||||
public_key (Union[str, bytes]): The public key in PEM format or the
|
||||
x509 public key certificate.
|
||||
|
||||
Returns:
|
||||
google.auth.crypt.Verifier: The constructed verifier.
|
||||
|
||||
Raises:
|
||||
ValueError: If the public_key can't be parsed.
|
||||
"""
|
||||
instance = cls.__new__(cls)
|
||||
instance._impl = _cryptography_rsa.RSAVerifier.from_string(public_key)
|
||||
return instance
|
||||
|
||||
|
||||
class RSASigner(base.Signer, base.FromServiceAccountMixin):
|
||||
"""Signs messages with an RSA private key.
|
||||
|
||||
Args:
|
||||
private_key (Union["rsa.key.PrivateKey", cryptography.hazmat.primitives.asymmetric.rsa.RSAPrivateKey]):
|
||||
The private key to sign with.
|
||||
key_id (str): Optional key ID used to identify this private key. This
|
||||
can be useful to associate the private key with its associated
|
||||
public key or certificate.
|
||||
|
||||
Raises:
|
||||
ImportError: if called with an rsa.key.PrivateKey, when the rsa library is not installed
|
||||
ValueError: if an unrecognized public key is provided
|
||||
"""
|
||||
|
||||
def __init__(self, private_key, key_id=None):
|
||||
module_str = private_key.__class__.__module__
|
||||
if isinstance(private_key, RSAPrivateKey):
|
||||
impl_lib = _cryptography_rsa
|
||||
elif module_str.startswith(RSA_KEY_MODULE_PREFIX):
|
||||
from google.auth.crypt import _python_rsa
|
||||
|
||||
impl_lib = _python_rsa
|
||||
else:
|
||||
raise ValueError(f"unrecognized private key type: {type(private_key)}")
|
||||
self._impl = impl_lib.RSASigner(private_key, key_id=key_id)
|
||||
|
||||
@property # type: ignore
|
||||
@_helpers.copy_docstring(base.Signer)
|
||||
def key_id(self):
|
||||
return self._impl.key_id
|
||||
|
||||
@_helpers.copy_docstring(base.Signer)
|
||||
def sign(self, message):
|
||||
return self._impl.sign(message)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, key, key_id=None):
|
||||
"""Construct a Signer instance from a private key in PEM format.
|
||||
|
||||
Args:
|
||||
key (str): Private key in PEM format.
|
||||
key_id (str): An optional key id used to identify the private key.
|
||||
|
||||
Returns:
|
||||
google.auth.crypt.Signer: The constructed signer.
|
||||
|
||||
Raises:
|
||||
ValueError: If the key cannot be parsed as PKCS#1 or PKCS#8 in
|
||||
PEM format.
|
||||
"""
|
||||
instance = cls.__new__(cls)
|
||||
instance._impl = _cryptography_rsa.RSASigner.from_string(key, key_id=key_id)
|
||||
return instance
|
||||
512
venv/lib/python3.12/site-packages/google/auth/downscoped.py
Normal file
512
venv/lib/python3.12/site-packages/google/auth/downscoped.py
Normal file
@@ -0,0 +1,512 @@
|
||||
# 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.
|
||||
|
||||
"""Downscoping with Credential Access Boundaries
|
||||
|
||||
This module provides the ability to downscope credentials using
|
||||
`Downscoping with Credential Access Boundaries`_. This is useful to restrict the
|
||||
Identity and Access Management (IAM) permissions that a short-lived credential
|
||||
can use.
|
||||
|
||||
To downscope permissions of a source credential, a Credential Access Boundary
|
||||
that specifies which resources the new credential can access, as well as
|
||||
an upper bound on the permissions that are available on each resource, has to
|
||||
be defined. A downscoped credential can then be instantiated using the source
|
||||
credential and the Credential Access Boundary.
|
||||
|
||||
The common pattern of usage is to have a token broker with elevated access
|
||||
generate these downscoped credentials from higher access source credentials and
|
||||
pass the downscoped short-lived access tokens to a token consumer via some
|
||||
secure authenticated channel for limited access to Google Cloud Storage
|
||||
resources.
|
||||
|
||||
For example, a token broker can be set up on a server in a private network.
|
||||
Various workloads (token consumers) in the same network will send authenticated
|
||||
requests to that broker for downscoped tokens to access or modify specific google
|
||||
cloud storage buckets.
|
||||
|
||||
The broker will instantiate downscoped credentials instances that can be used to
|
||||
generate short lived downscoped access tokens that can be passed to the token
|
||||
consumer. These downscoped access tokens can be injected by the consumer into
|
||||
google.oauth2.Credentials and used to initialize a storage client instance to
|
||||
access Google Cloud Storage resources with restricted access.
|
||||
|
||||
Note: Only Cloud Storage supports Credential Access Boundaries. Other Google
|
||||
Cloud services do not support this feature.
|
||||
|
||||
.. _Downscoping with Credential Access Boundaries: https://cloud.google.com/iam/docs/downscoping-short-lived-credentials
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import credentials
|
||||
from google.auth import exceptions
|
||||
from google.oauth2 import sts
|
||||
|
||||
# The maximum number of access boundary rules a Credential Access Boundary can
|
||||
# contain.
|
||||
_MAX_ACCESS_BOUNDARY_RULES_COUNT = 10
|
||||
# The token exchange grant_type used for exchanging credentials.
|
||||
_STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
# The token exchange requested_token_type. This is always an access_token.
|
||||
_STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
|
||||
# The STS token URL used to exchanged a short lived access token for a downscoped one.
|
||||
_STS_TOKEN_URL_PATTERN = "https://sts.{}/v1/token"
|
||||
# The subject token type to use when exchanging a short lived access token for a
|
||||
# downscoped token.
|
||||
_STS_SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
|
||||
|
||||
|
||||
class CredentialAccessBoundary(object):
|
||||
"""Defines a Credential Access Boundary which contains a list of access boundary
|
||||
rules. Each rule contains information on the resource that the rule applies to,
|
||||
the upper bound of the permissions that are available on that resource and an
|
||||
optional condition to further restrict permissions.
|
||||
"""
|
||||
|
||||
def __init__(self, rules=[]):
|
||||
"""Instantiates a Credential Access Boundary. A Credential Access Boundary
|
||||
can contain up to 10 access boundary rules.
|
||||
|
||||
Args:
|
||||
rules (Sequence[google.auth.downscoped.AccessBoundaryRule]): The list of
|
||||
access boundary rules limiting the access that a downscoped credential
|
||||
will have.
|
||||
Raises:
|
||||
InvalidType: If any of the rules are not a valid type.
|
||||
InvalidValue: If the provided rules exceed the maximum allowed.
|
||||
"""
|
||||
self.rules = rules
|
||||
|
||||
@property
|
||||
def rules(self):
|
||||
"""Returns the list of access boundary rules defined on the Credential
|
||||
Access Boundary.
|
||||
|
||||
Returns:
|
||||
Tuple[google.auth.downscoped.AccessBoundaryRule, ...]: The list of access
|
||||
boundary rules defined on the Credential Access Boundary. These are returned
|
||||
as an immutable tuple to prevent modification.
|
||||
"""
|
||||
return tuple(self._rules)
|
||||
|
||||
@rules.setter
|
||||
def rules(self, value):
|
||||
"""Updates the current rules on the Credential Access Boundary. This will overwrite
|
||||
the existing set of rules.
|
||||
|
||||
Args:
|
||||
value (Sequence[google.auth.downscoped.AccessBoundaryRule]): The list of
|
||||
access boundary rules limiting the access that a downscoped credential
|
||||
will have.
|
||||
Raises:
|
||||
InvalidType: If any of the rules are not a valid type.
|
||||
InvalidValue: If the provided rules exceed the maximum allowed.
|
||||
"""
|
||||
if len(value) > _MAX_ACCESS_BOUNDARY_RULES_COUNT:
|
||||
raise exceptions.InvalidValue(
|
||||
"Credential access boundary rules can have a maximum of {} rules.".format(
|
||||
_MAX_ACCESS_BOUNDARY_RULES_COUNT
|
||||
)
|
||||
)
|
||||
for access_boundary_rule in value:
|
||||
if not isinstance(access_boundary_rule, AccessBoundaryRule):
|
||||
raise exceptions.InvalidType(
|
||||
"List of rules provided do not contain a valid 'google.auth.downscoped.AccessBoundaryRule'."
|
||||
)
|
||||
# Make a copy of the original list.
|
||||
self._rules = list(value)
|
||||
|
||||
def add_rule(self, rule):
|
||||
"""Adds a single access boundary rule to the existing rules.
|
||||
|
||||
Args:
|
||||
rule (google.auth.downscoped.AccessBoundaryRule): The access boundary rule,
|
||||
limiting the access that a downscoped credential will have, to be added to
|
||||
the existing rules.
|
||||
Raises:
|
||||
InvalidType: If any of the rules are not a valid type.
|
||||
InvalidValue: If the provided rules exceed the maximum allowed.
|
||||
"""
|
||||
if len(self.rules) == _MAX_ACCESS_BOUNDARY_RULES_COUNT:
|
||||
raise exceptions.InvalidValue(
|
||||
"Credential access boundary rules can have a maximum of {} rules.".format(
|
||||
_MAX_ACCESS_BOUNDARY_RULES_COUNT
|
||||
)
|
||||
)
|
||||
if not isinstance(rule, AccessBoundaryRule):
|
||||
raise exceptions.InvalidType(
|
||||
"The provided rule does not contain a valid 'google.auth.downscoped.AccessBoundaryRule'."
|
||||
)
|
||||
self._rules.append(rule)
|
||||
|
||||
def to_json(self):
|
||||
"""Generates the dictionary representation of the Credential Access Boundary.
|
||||
This uses the format expected by the Security Token Service API as documented in
|
||||
`Defining a Credential Access Boundary`_.
|
||||
|
||||
.. _Defining a Credential Access Boundary:
|
||||
https://cloud.google.com/iam/docs/downscoping-short-lived-credentials#define-boundary
|
||||
|
||||
Returns:
|
||||
Mapping: Credential Access Boundary Rule represented in a dictionary object.
|
||||
"""
|
||||
rules = []
|
||||
for access_boundary_rule in self.rules:
|
||||
rules.append(access_boundary_rule.to_json())
|
||||
|
||||
return {"accessBoundary": {"accessBoundaryRules": rules}}
|
||||
|
||||
|
||||
class AccessBoundaryRule(object):
|
||||
"""Defines an access boundary rule which contains information on the resource that
|
||||
the rule applies to, the upper bound of the permissions that are available on that
|
||||
resource and an optional condition to further restrict permissions.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, available_resource, available_permissions, availability_condition=None
|
||||
):
|
||||
"""Instantiates a single access boundary rule.
|
||||
|
||||
Args:
|
||||
available_resource (str): The full resource name of the Cloud Storage bucket
|
||||
that the rule applies to. Use the format
|
||||
"//storage.googleapis.com/projects/_/buckets/bucket-name".
|
||||
available_permissions (Sequence[str]): A list defining the upper bound that
|
||||
the downscoped token will have on the available permissions for the
|
||||
resource. Each value is the identifier for an IAM predefined role or
|
||||
custom role, with the prefix "inRole:". For example:
|
||||
"inRole:roles/storage.objectViewer".
|
||||
Only the permissions in these roles will be available.
|
||||
availability_condition (Optional[google.auth.downscoped.AvailabilityCondition]):
|
||||
Optional condition that restricts the availability of permissions to
|
||||
specific Cloud Storage objects.
|
||||
|
||||
Raises:
|
||||
InvalidType: If any of the parameters are not of the expected types.
|
||||
InvalidValue: If any of the parameters are not of the expected values.
|
||||
"""
|
||||
self.available_resource = available_resource
|
||||
self.available_permissions = available_permissions
|
||||
self.availability_condition = availability_condition
|
||||
|
||||
@property
|
||||
def available_resource(self):
|
||||
"""Returns the current available resource.
|
||||
|
||||
Returns:
|
||||
str: The current available resource.
|
||||
"""
|
||||
return self._available_resource
|
||||
|
||||
@available_resource.setter
|
||||
def available_resource(self, value):
|
||||
"""Updates the current available resource.
|
||||
|
||||
Args:
|
||||
value (str): The updated value of the available resource.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidType: If the value is not a string.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise exceptions.InvalidType(
|
||||
"The provided available_resource is not a string."
|
||||
)
|
||||
self._available_resource = value
|
||||
|
||||
@property
|
||||
def available_permissions(self):
|
||||
"""Returns the current available permissions.
|
||||
|
||||
Returns:
|
||||
Tuple[str, ...]: The current available permissions. These are returned
|
||||
as an immutable tuple to prevent modification.
|
||||
"""
|
||||
return tuple(self._available_permissions)
|
||||
|
||||
@available_permissions.setter
|
||||
def available_permissions(self, value):
|
||||
"""Updates the current available permissions.
|
||||
|
||||
Args:
|
||||
value (Sequence[str]): The updated value of the available permissions.
|
||||
|
||||
Raises:
|
||||
InvalidType: If the value is not a list of strings.
|
||||
InvalidValue: If the value is not valid.
|
||||
"""
|
||||
for available_permission in value:
|
||||
if not isinstance(available_permission, str):
|
||||
raise exceptions.InvalidType(
|
||||
"Provided available_permissions are not a list of strings."
|
||||
)
|
||||
if available_permission.find("inRole:") != 0:
|
||||
raise exceptions.InvalidValue(
|
||||
"available_permissions must be prefixed with 'inRole:'."
|
||||
)
|
||||
# Make a copy of the original list.
|
||||
self._available_permissions = list(value)
|
||||
|
||||
@property
|
||||
def availability_condition(self):
|
||||
"""Returns the current availability condition.
|
||||
|
||||
Returns:
|
||||
Optional[google.auth.downscoped.AvailabilityCondition]: The current
|
||||
availability condition.
|
||||
"""
|
||||
return self._availability_condition
|
||||
|
||||
@availability_condition.setter
|
||||
def availability_condition(self, value):
|
||||
"""Updates the current availability condition.
|
||||
|
||||
Args:
|
||||
value (Optional[google.auth.downscoped.AvailabilityCondition]): The updated
|
||||
value of the availability condition.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidType: If the value is not of type google.auth.downscoped.AvailabilityCondition
|
||||
or None.
|
||||
"""
|
||||
if not isinstance(value, AvailabilityCondition) and value is not None:
|
||||
raise exceptions.InvalidType(
|
||||
"The provided availability_condition is not a 'google.auth.downscoped.AvailabilityCondition' or None."
|
||||
)
|
||||
self._availability_condition = value
|
||||
|
||||
def to_json(self):
|
||||
"""Generates the dictionary representation of the access boundary rule.
|
||||
This uses the format expected by the Security Token Service API as documented in
|
||||
`Defining a Credential Access Boundary`_.
|
||||
|
||||
.. _Defining a Credential Access Boundary:
|
||||
https://cloud.google.com/iam/docs/downscoping-short-lived-credentials#define-boundary
|
||||
|
||||
Returns:
|
||||
Mapping: The access boundary rule represented in a dictionary object.
|
||||
"""
|
||||
json = {
|
||||
"availablePermissions": list(self.available_permissions),
|
||||
"availableResource": self.available_resource,
|
||||
}
|
||||
if self.availability_condition:
|
||||
json["availabilityCondition"] = self.availability_condition.to_json()
|
||||
return json
|
||||
|
||||
|
||||
class AvailabilityCondition(object):
|
||||
"""An optional condition that can be used as part of a Credential Access Boundary
|
||||
to further restrict permissions."""
|
||||
|
||||
def __init__(self, expression, title=None, description=None):
|
||||
"""Instantiates an availability condition using the provided expression and
|
||||
optional title or description.
|
||||
|
||||
Args:
|
||||
expression (str): A condition expression that specifies the Cloud Storage
|
||||
objects where permissions are available. For example, this expression
|
||||
makes permissions available for objects whose name starts with "customer-a":
|
||||
"resource.name.startsWith('projects/_/buckets/example-bucket/objects/customer-a')"
|
||||
title (Optional[str]): An optional short string that identifies the purpose of
|
||||
the condition.
|
||||
description (Optional[str]): Optional details about the purpose of the condition.
|
||||
|
||||
Raises:
|
||||
InvalidType: If any of the parameters are not of the expected types.
|
||||
InvalidValue: If any of the parameters are not of the expected values.
|
||||
"""
|
||||
self.expression = expression
|
||||
self.title = title
|
||||
self.description = description
|
||||
|
||||
@property
|
||||
def expression(self):
|
||||
"""Returns the current condition expression.
|
||||
|
||||
Returns:
|
||||
str: The current conditon expression.
|
||||
"""
|
||||
return self._expression
|
||||
|
||||
@expression.setter
|
||||
def expression(self, value):
|
||||
"""Updates the current condition expression.
|
||||
|
||||
Args:
|
||||
value (str): The updated value of the condition expression.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidType: If the value is not of type string.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
raise exceptions.InvalidType("The provided expression is not a string.")
|
||||
self._expression = value
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
"""Returns the current title.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The current title.
|
||||
"""
|
||||
return self._title
|
||||
|
||||
@title.setter
|
||||
def title(self, value):
|
||||
"""Updates the current title.
|
||||
|
||||
Args:
|
||||
value (Optional[str]): The updated value of the title.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidType: If the value is not of type string or None.
|
||||
"""
|
||||
if not isinstance(value, str) and value is not None:
|
||||
raise exceptions.InvalidType("The provided title is not a string or None.")
|
||||
self._title = value
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""Returns the current description.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The current description.
|
||||
"""
|
||||
return self._description
|
||||
|
||||
@description.setter
|
||||
def description(self, value):
|
||||
"""Updates the current description.
|
||||
|
||||
Args:
|
||||
value (Optional[str]): The updated value of the description.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidType: If the value is not of type string or None.
|
||||
"""
|
||||
if not isinstance(value, str) and value is not None:
|
||||
raise exceptions.InvalidType(
|
||||
"The provided description is not a string or None."
|
||||
)
|
||||
self._description = value
|
||||
|
||||
def to_json(self):
|
||||
"""Generates the dictionary representation of the availability condition.
|
||||
This uses the format expected by the Security Token Service API as documented in
|
||||
`Defining a Credential Access Boundary`_.
|
||||
|
||||
.. _Defining a Credential Access Boundary:
|
||||
https://cloud.google.com/iam/docs/downscoping-short-lived-credentials#define-boundary
|
||||
|
||||
Returns:
|
||||
Mapping[str, str]: The availability condition represented in a dictionary
|
||||
object.
|
||||
"""
|
||||
json = {"expression": self.expression}
|
||||
if self.title:
|
||||
json["title"] = self.title
|
||||
if self.description:
|
||||
json["description"] = self.description
|
||||
return json
|
||||
|
||||
|
||||
class Credentials(credentials.CredentialsWithQuotaProject):
|
||||
"""Defines a set of Google credentials that are downscoped from an existing set
|
||||
of Google OAuth2 credentials. This is useful to restrict the Identity and Access
|
||||
Management (IAM) permissions that a short-lived credential can use.
|
||||
The common pattern of usage is to have a token broker with elevated access
|
||||
generate these downscoped credentials from higher access source credentials and
|
||||
pass the downscoped short-lived access tokens to a token consumer via some
|
||||
secure authenticated channel for limited access to Google Cloud Storage
|
||||
resources.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_credentials,
|
||||
credential_access_boundary,
|
||||
quota_project_id=None,
|
||||
universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN,
|
||||
):
|
||||
"""Instantiates a downscoped credentials object using the provided source
|
||||
credentials and credential access boundary rules.
|
||||
To downscope permissions of a source credential, a Credential Access Boundary
|
||||
that specifies which resources the new credential can access, as well as an
|
||||
upper bound on the permissions that are available on each resource, has to be
|
||||
defined. A downscoped credential can then be instantiated using the source
|
||||
credential and the Credential Access Boundary.
|
||||
|
||||
Args:
|
||||
source_credentials (google.auth.credentials.Credentials): The source credentials
|
||||
to be downscoped based on the provided Credential Access Boundary rules.
|
||||
credential_access_boundary (google.auth.downscoped.CredentialAccessBoundary):
|
||||
The Credential Access Boundary which contains a list of access boundary
|
||||
rules. Each rule contains information on the resource that the rule applies to,
|
||||
the upper bound of the permissions that are available on that resource and an
|
||||
optional condition to further restrict permissions.
|
||||
quota_project_id (Optional[str]): The optional quota project ID.
|
||||
universe_domain (Optional[str]): The universe domain value, default is googleapis.com
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the source credentials
|
||||
return an error on token refresh.
|
||||
google.auth.exceptions.OAuthError: If the STS token exchange
|
||||
endpoint returned an error during downscoped token generation.
|
||||
"""
|
||||
|
||||
super(Credentials, self).__init__()
|
||||
self._source_credentials = source_credentials
|
||||
self._credential_access_boundary = credential_access_boundary
|
||||
self._quota_project_id = quota_project_id
|
||||
self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN
|
||||
self._sts_client = sts.Client(
|
||||
_STS_TOKEN_URL_PATTERN.format(self.universe_domain)
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
def refresh(self, request):
|
||||
# Generate an access token from the source credentials.
|
||||
self._source_credentials.refresh(request)
|
||||
now = _helpers.utcnow()
|
||||
# Exchange the access token for a downscoped access token.
|
||||
response_data = self._sts_client.exchange_token(
|
||||
request=request,
|
||||
grant_type=_STS_GRANT_TYPE,
|
||||
subject_token=self._source_credentials.token,
|
||||
subject_token_type=_STS_SUBJECT_TOKEN_TYPE,
|
||||
requested_token_type=_STS_REQUESTED_TOKEN_TYPE,
|
||||
additional_options=self._credential_access_boundary.to_json(),
|
||||
)
|
||||
self.token = response_data.get("access_token")
|
||||
# For downscoping CAB flow, the STS endpoint may not return the expiration
|
||||
# field for some flows. The generated downscoped token should always have
|
||||
# the same expiration time as the source credentials. When no expires_in
|
||||
# field is returned in the response, we can just get the expiration time
|
||||
# from the source credentials.
|
||||
if response_data.get("expires_in"):
|
||||
lifetime = datetime.timedelta(seconds=response_data.get("expires_in"))
|
||||
self.expiry = now + lifetime
|
||||
else:
|
||||
self.expiry = self._source_credentials.expiry
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
|
||||
def with_quota_project(self, quota_project_id):
|
||||
return self.__class__(
|
||||
self._source_credentials,
|
||||
self._credential_access_boundary,
|
||||
quota_project_id=quota_project_id,
|
||||
)
|
||||
@@ -0,0 +1,131 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Environment variables used by :mod:`google.auth`."""
|
||||
|
||||
|
||||
PROJECT = "GOOGLE_CLOUD_PROJECT"
|
||||
"""Environment variable defining default project.
|
||||
|
||||
This used by :func:`google.auth.default` to explicitly set a project ID. This
|
||||
environment variable is also used by the Google Cloud Python Library.
|
||||
"""
|
||||
|
||||
LEGACY_PROJECT = "GCLOUD_PROJECT"
|
||||
"""Previously used environment variable defining the default project.
|
||||
|
||||
This environment variable is used instead of the current one in some
|
||||
situations (such as Google App Engine).
|
||||
"""
|
||||
|
||||
GOOGLE_CLOUD_QUOTA_PROJECT = "GOOGLE_CLOUD_QUOTA_PROJECT"
|
||||
"""Environment variable defining the project to be used for
|
||||
quota and billing."""
|
||||
|
||||
CREDENTIALS = "GOOGLE_APPLICATION_CREDENTIALS"
|
||||
"""Environment variable defining the location of Google application default
|
||||
credentials."""
|
||||
|
||||
# The environment variable name which can replace ~/.config if set.
|
||||
CLOUD_SDK_CONFIG_DIR = "CLOUDSDK_CONFIG"
|
||||
"""Environment variable defines the location of Google Cloud SDK's config
|
||||
files."""
|
||||
|
||||
# These two variables allow for customization of the addresses used when
|
||||
# contacting the GCE metadata service.
|
||||
GCE_METADATA_HOST = "GCE_METADATA_HOST"
|
||||
"""Environment variable providing an alternate hostname or host:port to be
|
||||
used for GCE metadata requests.
|
||||
|
||||
This environment variable was originally named GCE_METADATA_ROOT. The system will
|
||||
check this environemnt variable first; should there be no value present,
|
||||
the system will fall back to the old variable.
|
||||
"""
|
||||
|
||||
GCE_METADATA_ROOT = "GCE_METADATA_ROOT"
|
||||
"""Old environment variable for GCE_METADATA_HOST."""
|
||||
|
||||
GCE_METADATA_IP = "GCE_METADATA_IP"
|
||||
"""Environment variable providing an alternate ip:port to be used for ip-only
|
||||
GCE metadata requests."""
|
||||
|
||||
GCE_METADATA_TIMEOUT = "GCE_METADATA_TIMEOUT"
|
||||
"""Environment variable defining the timeout in seconds to wait for the
|
||||
GCE metadata server when detecting the GCE environment.
|
||||
"""
|
||||
|
||||
GCE_METADATA_DETECT_RETRIES = "GCE_METADATA_DETECT_RETRIES"
|
||||
"""Environment variable representing the number of retries that should be
|
||||
attempted on metadata lookup.
|
||||
"""
|
||||
|
||||
NO_GCE_CHECK = "NO_GCE_CHECK"
|
||||
"""Environment variable controlling whether to check if running on GCE or not.
|
||||
|
||||
The default value is false. Users have to explicitly set this value to true
|
||||
in order to disable the GCE check."""
|
||||
|
||||
GCE_METADATA_MTLS_MODE = "GCE_METADATA_MTLS_MODE"
|
||||
"""Environment variable controlling the mTLS behavior for GCE metadata requests.
|
||||
|
||||
Can be one of "strict", "none", or "default".
|
||||
"""
|
||||
|
||||
GOOGLE_API_USE_CLIENT_CERTIFICATE = "GOOGLE_API_USE_CLIENT_CERTIFICATE"
|
||||
"""Environment variable controlling whether to use client certificate or not.
|
||||
|
||||
The default value is false. Users have to explicitly set this value to true
|
||||
in order to use client certificate to establish a mutual TLS channel."""
|
||||
|
||||
LEGACY_APPENGINE_RUNTIME = "APPENGINE_RUNTIME"
|
||||
"""Gen1 environment variable defining the App Engine Runtime.
|
||||
|
||||
Used to distinguish between GAE gen1 and GAE gen2+.
|
||||
"""
|
||||
|
||||
# AWS environment variables used with AWS workload identity pools to retrieve
|
||||
# AWS security credentials and the AWS region needed to create a serialized
|
||||
# signed requests to the AWS STS GetCalledIdentity API that can be exchanged
|
||||
# for a Google access tokens via the GCP STS endpoint.
|
||||
# When not available the AWS metadata server is used to retrieve these values.
|
||||
AWS_ACCESS_KEY_ID = "AWS_ACCESS_KEY_ID"
|
||||
AWS_SECRET_ACCESS_KEY = "AWS_SECRET_ACCESS_KEY"
|
||||
AWS_SESSION_TOKEN = "AWS_SESSION_TOKEN"
|
||||
AWS_REGION = "AWS_REGION"
|
||||
AWS_DEFAULT_REGION = "AWS_DEFAULT_REGION"
|
||||
|
||||
GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED = "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED"
|
||||
"""Environment variable controlling whether to enable trust boundary feature.
|
||||
The default value is false. Users have to explicitly set this value to true."""
|
||||
|
||||
GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"
|
||||
"""Environment variable defining the location of Google API certificate config
|
||||
file."""
|
||||
|
||||
CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE = (
|
||||
"CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE"
|
||||
)
|
||||
"""Environment variable controlling whether to use client certificate or not.
|
||||
This variable is the fallback of GOOGLE_API_USE_CLIENT_CERTIFICATE."""
|
||||
|
||||
CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH = (
|
||||
"CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH"
|
||||
)
|
||||
"""Environment variable defining the location of Google API certificate config
|
||||
file. This variable is the fallback of GOOGLE_API_CERTIFICATE_CONFIG."""
|
||||
|
||||
GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES = (
|
||||
"GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES"
|
||||
)
|
||||
"""Environment variable to prevent agent token sharing for GCP services."""
|
||||
108
venv/lib/python3.12/site-packages/google/auth/exceptions.py
Normal file
108
venv/lib/python3.12/site-packages/google/auth/exceptions.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Exceptions used in the google.auth package."""
|
||||
|
||||
|
||||
class GoogleAuthError(Exception):
|
||||
"""Base class for all google.auth errors."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(GoogleAuthError, self).__init__(*args)
|
||||
retryable = kwargs.get("retryable", False)
|
||||
self._retryable = retryable
|
||||
|
||||
@property
|
||||
def retryable(self):
|
||||
return self._retryable
|
||||
|
||||
|
||||
class TransportError(GoogleAuthError):
|
||||
"""Used to indicate an error occurred during an HTTP request."""
|
||||
|
||||
|
||||
class RefreshError(GoogleAuthError):
|
||||
"""Used to indicate that an refreshing the credentials' access token
|
||||
failed."""
|
||||
|
||||
|
||||
class UserAccessTokenError(GoogleAuthError):
|
||||
"""Used to indicate ``gcloud auth print-access-token`` command failed."""
|
||||
|
||||
|
||||
class DefaultCredentialsError(GoogleAuthError):
|
||||
"""Used to indicate that acquiring default credentials failed."""
|
||||
|
||||
|
||||
class MutualTLSChannelError(GoogleAuthError):
|
||||
"""Used to indicate that mutual TLS channel creation is failed, or mutual
|
||||
TLS channel credentials is missing or invalid."""
|
||||
|
||||
|
||||
class ClientCertError(GoogleAuthError):
|
||||
"""Used to indicate that client certificate is missing or invalid."""
|
||||
|
||||
@property
|
||||
def retryable(self):
|
||||
return False
|
||||
|
||||
|
||||
class OAuthError(GoogleAuthError):
|
||||
"""Used to indicate an error occurred during an OAuth related HTTP
|
||||
request."""
|
||||
|
||||
|
||||
class ReauthFailError(RefreshError):
|
||||
"""An exception for when reauth failed."""
|
||||
|
||||
def __init__(self, message=None, **kwargs):
|
||||
super(ReauthFailError, self).__init__(
|
||||
"Reauthentication failed. {0}".format(message), **kwargs
|
||||
)
|
||||
|
||||
|
||||
class ReauthSamlChallengeFailError(ReauthFailError):
|
||||
"""An exception for SAML reauth challenge failures."""
|
||||
|
||||
|
||||
class MalformedError(DefaultCredentialsError, ValueError):
|
||||
"""An exception for malformed data."""
|
||||
|
||||
|
||||
class InvalidResource(DefaultCredentialsError, ValueError):
|
||||
"""An exception for URL error."""
|
||||
|
||||
|
||||
class InvalidOperation(DefaultCredentialsError, ValueError):
|
||||
"""An exception for invalid operation."""
|
||||
|
||||
|
||||
class InvalidValue(DefaultCredentialsError, ValueError):
|
||||
"""Used to wrap general ValueError of python."""
|
||||
|
||||
|
||||
class InvalidType(DefaultCredentialsError, TypeError):
|
||||
"""Used to wrap general TypeError of python."""
|
||||
|
||||
|
||||
class OSError(DefaultCredentialsError, EnvironmentError):
|
||||
"""Used to wrap EnvironmentError(OSError after python3.3)."""
|
||||
|
||||
|
||||
class TimeoutError(GoogleAuthError):
|
||||
"""Used to indicate a timeout error occurred during an HTTP request."""
|
||||
|
||||
|
||||
class ResponseError(GoogleAuthError):
|
||||
"""Used to indicate an error occurred when reading an HTTP response."""
|
||||
@@ -0,0 +1,716 @@
|
||||
# Copyright 2020 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.
|
||||
|
||||
"""External Account Credentials.
|
||||
|
||||
This module provides credentials that exchange workload identity pool external
|
||||
credentials for Google access tokens. This facilitates accessing Google Cloud
|
||||
Platform resources from on-prem and non-Google Cloud platforms (e.g. AWS,
|
||||
Microsoft Azure, OIDC identity providers), using native credentials retrieved
|
||||
from the current environment without the need to copy, save and manage
|
||||
long-lived service account credentials.
|
||||
|
||||
Specifically, this is intended to use access tokens acquired using the GCP STS
|
||||
token exchange endpoint following the `OAuth 2.0 Token Exchange`_ spec.
|
||||
|
||||
.. _OAuth 2.0 Token Exchange: https://tools.ietf.org/html/rfc8693
|
||||
"""
|
||||
|
||||
import abc
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
import datetime
|
||||
import functools
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
|
||||
from google.auth import _constants
|
||||
from google.auth import _helpers
|
||||
from google.auth import credentials
|
||||
from google.auth import exceptions
|
||||
from google.auth import impersonated_credentials
|
||||
from google.auth import metrics
|
||||
from google.oauth2 import sts
|
||||
from google.oauth2 import utils
|
||||
|
||||
# External account JSON type identifier.
|
||||
_EXTERNAL_ACCOUNT_JSON_TYPE = "external_account"
|
||||
# The token exchange grant_type used for exchanging credentials.
|
||||
_STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
|
||||
# The token exchange requested_token_type. This is always an access_token.
|
||||
_STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
|
||||
# Cloud resource manager URL used to retrieve project information.
|
||||
_CLOUD_RESOURCE_MANAGER = "https://cloudresourcemanager.googleapis.com/v1/projects/"
|
||||
# Default Google sts token url.
|
||||
_DEFAULT_TOKEN_URL = "https://sts.{universe_domain}/v1/token"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SupplierContext:
|
||||
"""A context class that contains information about the requested third party credential that is passed
|
||||
to AWS security credential and subject token suppliers.
|
||||
|
||||
Attributes:
|
||||
subject_token_type (str): The requested subject token type based on the Oauth2.0 token exchange spec.
|
||||
Expected values include::
|
||||
|
||||
“urn:ietf:params:oauth:token-type:jwt”
|
||||
“urn:ietf:params:oauth:token-type:id-token”
|
||||
“urn:ietf:params:oauth:token-type:saml2”
|
||||
“urn:ietf:params:aws:token-type:aws4_request”
|
||||
|
||||
audience (str): The requested audience for the subject token.
|
||||
"""
|
||||
|
||||
subject_token_type: str
|
||||
audience: str
|
||||
|
||||
|
||||
class Credentials(
|
||||
credentials.Scoped,
|
||||
credentials.CredentialsWithQuotaProject,
|
||||
credentials.CredentialsWithTokenUri,
|
||||
credentials.CredentialsWithTrustBoundary,
|
||||
metaclass=abc.ABCMeta,
|
||||
):
|
||||
"""Base class for all external account credentials.
|
||||
|
||||
This is used to instantiate Credentials for exchanging external account
|
||||
credentials for Google access token and authorizing requests to Google APIs.
|
||||
The base class implements the common logic for exchanging external account
|
||||
credentials for Google access tokens.
|
||||
|
||||
**IMPORTANT**:
|
||||
This class does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audience,
|
||||
subject_token_type,
|
||||
token_url,
|
||||
credential_source,
|
||||
service_account_impersonation_url=None,
|
||||
service_account_impersonation_options=None,
|
||||
client_id=None,
|
||||
client_secret=None,
|
||||
token_info_url=None,
|
||||
quota_project_id=None,
|
||||
scopes=None,
|
||||
default_scopes=None,
|
||||
workforce_pool_user_project=None,
|
||||
universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN,
|
||||
trust_boundary=None,
|
||||
):
|
||||
"""Instantiates an external account credentials object.
|
||||
|
||||
Args:
|
||||
audience (str): The STS audience field.
|
||||
subject_token_type (str): The subject token type based on the Oauth2.0 token exchange spec.
|
||||
Expected values include::
|
||||
|
||||
“urn:ietf:params:oauth:token-type:jwt”
|
||||
“urn:ietf:params:oauth:token-type:id-token”
|
||||
“urn:ietf:params:oauth:token-type:saml2”
|
||||
“urn:ietf:params:aws:token-type:aws4_request”
|
||||
|
||||
token_url (str): The STS endpoint URL.
|
||||
credential_source (Mapping): The credential source dictionary.
|
||||
service_account_impersonation_url (Optional[str]): The optional service account
|
||||
impersonation generateAccessToken URL.
|
||||
client_id (Optional[str]): The optional client ID.
|
||||
client_secret (Optional[str]): The optional client secret.
|
||||
token_info_url (str): The optional STS endpoint URL for token introspection.
|
||||
quota_project_id (Optional[str]): The optional quota project ID.
|
||||
scopes (Optional[Sequence[str]]): Optional scopes to request during the
|
||||
authorization grant.
|
||||
default_scopes (Optional[Sequence[str]]): Default scopes passed by a
|
||||
Google client library. Use 'scopes' for user-defined scopes.
|
||||
workforce_pool_user_project (Optona[str]): The optional workforce pool user
|
||||
project number when the credential corresponds to a workforce pool and not
|
||||
a workload identity pool. The underlying principal must still have
|
||||
serviceusage.services.use IAM permission to use the project for
|
||||
billing/quota.
|
||||
universe_domain (str): The universe domain. The default universe
|
||||
domain is googleapis.com.
|
||||
trust_boundary (str): String representation of trust boundary meta.
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the generateAccessToken
|
||||
endpoint returned an error.
|
||||
"""
|
||||
super(Credentials, self).__init__()
|
||||
self._audience = audience
|
||||
self._subject_token_type = subject_token_type
|
||||
self._universe_domain = universe_domain
|
||||
self._token_url = token_url
|
||||
if self._token_url == _DEFAULT_TOKEN_URL:
|
||||
self._token_url = self._token_url.replace(
|
||||
"{universe_domain}", self._universe_domain
|
||||
)
|
||||
self._token_info_url = token_info_url
|
||||
self._credential_source = credential_source
|
||||
self._service_account_impersonation_url = service_account_impersonation_url
|
||||
self._service_account_impersonation_options = (
|
||||
service_account_impersonation_options or {}
|
||||
)
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._quota_project_id = quota_project_id
|
||||
self._scopes = scopes
|
||||
self._default_scopes = default_scopes
|
||||
self._workforce_pool_user_project = workforce_pool_user_project
|
||||
self._trust_boundary = trust_boundary
|
||||
|
||||
if self._client_id:
|
||||
self._client_auth = utils.ClientAuthentication(
|
||||
utils.ClientAuthType.basic, self._client_id, self._client_secret
|
||||
)
|
||||
else:
|
||||
self._client_auth = None
|
||||
self._sts_client = sts.Client(self._token_url, self._client_auth)
|
||||
|
||||
self._metrics_options = self._create_default_metrics_options()
|
||||
|
||||
self._impersonated_credentials = None
|
||||
self._project_id = None
|
||||
self._supplier_context = SupplierContext(
|
||||
self._subject_token_type, self._audience
|
||||
)
|
||||
self._cred_file_path = None
|
||||
|
||||
if not self.is_workforce_pool and self._workforce_pool_user_project:
|
||||
# Workload identity pools do not support workforce pool user projects.
|
||||
raise exceptions.InvalidValue(
|
||||
"workforce_pool_user_project should not be set for non-workforce pool "
|
||||
"credentials"
|
||||
)
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
"""Generates the dictionary representation of the current credentials.
|
||||
|
||||
Returns:
|
||||
Mapping: The dictionary representation of the credentials. This is the
|
||||
reverse of "from_info" defined on the subclasses of this class. It is
|
||||
useful for serializing the current credentials so it can deserialized
|
||||
later.
|
||||
"""
|
||||
config_info = self._constructor_args()
|
||||
config_info.update(
|
||||
type=_EXTERNAL_ACCOUNT_JSON_TYPE,
|
||||
service_account_impersonation=config_info.pop(
|
||||
"service_account_impersonation_options", None
|
||||
),
|
||||
)
|
||||
config_info.pop("scopes", None)
|
||||
config_info.pop("default_scopes", None)
|
||||
return {key: value for key, value in config_info.items() if value is not None}
|
||||
|
||||
def _constructor_args(self):
|
||||
args = {
|
||||
"audience": self._audience,
|
||||
"subject_token_type": self._subject_token_type,
|
||||
"token_url": self._token_url,
|
||||
"token_info_url": self._token_info_url,
|
||||
"service_account_impersonation_url": self._service_account_impersonation_url,
|
||||
"service_account_impersonation_options": copy.deepcopy(
|
||||
self._service_account_impersonation_options
|
||||
)
|
||||
or None,
|
||||
"credential_source": copy.deepcopy(self._credential_source),
|
||||
"quota_project_id": self._quota_project_id,
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
"workforce_pool_user_project": self._workforce_pool_user_project,
|
||||
"scopes": self._scopes,
|
||||
"default_scopes": self._default_scopes,
|
||||
"universe_domain": self._universe_domain,
|
||||
"trust_boundary": self._trust_boundary,
|
||||
}
|
||||
if not self.is_workforce_pool:
|
||||
args.pop("workforce_pool_user_project")
|
||||
return args
|
||||
|
||||
@property
|
||||
def service_account_email(self):
|
||||
"""Returns the service account email if service account impersonation is used.
|
||||
|
||||
Returns:
|
||||
Optional[str]: The service account email if impersonation is used. Otherwise
|
||||
None is returned.
|
||||
"""
|
||||
if self._service_account_impersonation_url:
|
||||
# Parse email from URL. The formal looks as follows:
|
||||
# https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
|
||||
url = self._service_account_impersonation_url
|
||||
start_index = url.rfind("/")
|
||||
end_index = url.find(":generateAccessToken")
|
||||
if start_index != -1 and end_index != -1 and start_index < end_index:
|
||||
start_index = start_index + 1
|
||||
return url[start_index:end_index]
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_user(self):
|
||||
"""Returns whether the credentials represent a user (True) or workload (False).
|
||||
Workloads behave similarly to service accounts. Currently workloads will use
|
||||
service account impersonation but will eventually not require impersonation.
|
||||
As a result, this property is more reliable than the service account email
|
||||
property in determining if the credentials represent a user or workload.
|
||||
|
||||
Returns:
|
||||
bool: True if the credentials represent a user. False if they represent a
|
||||
workload.
|
||||
"""
|
||||
# If service account impersonation is used, the credentials will always represent a
|
||||
# service account.
|
||||
if self._service_account_impersonation_url:
|
||||
return False
|
||||
return self.is_workforce_pool
|
||||
|
||||
@property
|
||||
def is_workforce_pool(self):
|
||||
"""Returns whether the credentials represent a workforce pool (True) or
|
||||
workload (False) based on the credentials' audience.
|
||||
|
||||
This will also return True for impersonated workforce pool credentials.
|
||||
|
||||
Returns:
|
||||
bool: True if the credentials represent a workforce pool. False if they
|
||||
represent a workload.
|
||||
"""
|
||||
# Workforce pools representing users have the following audience format:
|
||||
# //iam.googleapis.com/locations/$location/workforcePools/$poolId/providers/$providerId
|
||||
p = re.compile(r"//iam\.googleapis\.com/locations/[^/]+/workforcePools/")
|
||||
return p.match(self._audience or "") is not None
|
||||
|
||||
@property
|
||||
def requires_scopes(self):
|
||||
"""Checks if the credentials requires scopes.
|
||||
|
||||
Returns:
|
||||
bool: True if there are no scopes set otherwise False.
|
||||
"""
|
||||
return not self._scopes and not self._default_scopes
|
||||
|
||||
@property
|
||||
def project_number(self):
|
||||
"""Optional[str]: The project number corresponding to the workload identity pool."""
|
||||
|
||||
# STS audience pattern:
|
||||
# //iam.googleapis.com/projects/$PROJECT_NUMBER/locations/...
|
||||
components = self._audience.split("/")
|
||||
try:
|
||||
project_index = components.index("projects")
|
||||
if project_index + 1 < len(components):
|
||||
return components[project_index + 1] or None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def token_info_url(self):
|
||||
"""Optional[str]: The STS token introspection endpoint."""
|
||||
|
||||
return self._token_info_url
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
def get_cred_info(self):
|
||||
if self._cred_file_path:
|
||||
cred_info_json = {
|
||||
"credential_source": self._cred_file_path,
|
||||
"credential_type": "external account credentials",
|
||||
}
|
||||
if self.service_account_email:
|
||||
cred_info_json["principal"] = self.service_account_email
|
||||
return cred_info_json
|
||||
return None
|
||||
|
||||
@_helpers.copy_docstring(credentials.Scoped)
|
||||
def with_scopes(self, scopes, default_scopes=None):
|
||||
kwargs = self._constructor_args()
|
||||
kwargs.update(scopes=scopes, default_scopes=default_scopes)
|
||||
scoped = self.__class__(**kwargs)
|
||||
scoped._cred_file_path = self._cred_file_path
|
||||
scoped._metrics_options = self._metrics_options
|
||||
return scoped
|
||||
|
||||
@abc.abstractmethod
|
||||
def retrieve_subject_token(self, request):
|
||||
"""Retrieves the subject token using the credential_source object.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
Returns:
|
||||
str: The retrieved subject token.
|
||||
"""
|
||||
# pylint: disable=missing-raises-doc
|
||||
# (pylint doesn't recognize that this is abstract)
|
||||
raise NotImplementedError("retrieve_subject_token must be implemented")
|
||||
|
||||
def get_project_id(self, request):
|
||||
"""Retrieves the project ID corresponding to the workload identity or workforce pool.
|
||||
For workforce pool credentials, it returns the project ID corresponding to
|
||||
the workforce_pool_user_project.
|
||||
|
||||
When not determinable, None is returned.
|
||||
|
||||
This is introduced to support the current pattern of using the Auth library:
|
||||
|
||||
credentials, project_id = google.auth.default()
|
||||
|
||||
The resource may not have permission (resourcemanager.projects.get) to
|
||||
call this API or the required scopes may not be selected:
|
||||
https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
Returns:
|
||||
Optional[str]: The project ID corresponding to the workload identity pool
|
||||
or workforce pool if determinable.
|
||||
"""
|
||||
if self._project_id:
|
||||
# If already retrieved, return the cached project ID value.
|
||||
return self._project_id
|
||||
scopes = self._scopes if self._scopes is not None else self._default_scopes
|
||||
# Scopes are required in order to retrieve a valid access token.
|
||||
project_number = self.project_number or self._workforce_pool_user_project
|
||||
if project_number and scopes:
|
||||
headers = {}
|
||||
url = _CLOUD_RESOURCE_MANAGER + project_number
|
||||
self.before_request(request, "GET", url, headers)
|
||||
response = request(url=url, method="GET", headers=headers)
|
||||
|
||||
response_body = (
|
||||
response.data.decode("utf-8")
|
||||
if hasattr(response.data, "decode")
|
||||
else response.data
|
||||
)
|
||||
response_data = json.loads(response_body)
|
||||
|
||||
if response.status == 200:
|
||||
# Cache result as this field is immutable.
|
||||
self._project_id = response_data.get("projectId")
|
||||
return self._project_id
|
||||
|
||||
return None
|
||||
|
||||
def refresh(self, request):
|
||||
"""Refreshes the access token.
|
||||
|
||||
For impersonated credentials, this method will refresh the underlying
|
||||
source credentials and the impersonated credentials. For non-impersonated
|
||||
credentials, it will refresh the access token and the trust boundary.
|
||||
"""
|
||||
self._perform_refresh_token(request)
|
||||
self._handle_trust_boundary(request)
|
||||
|
||||
def _handle_trust_boundary(self, request):
|
||||
# If we are impersonating, the trust boundary is handled by the
|
||||
# impersonated credentials object. We need to get it from there.
|
||||
if self._service_account_impersonation_url:
|
||||
self._trust_boundary = self._impersonated_credentials._trust_boundary
|
||||
else:
|
||||
# Otherwise, refresh the trust boundary for the external account.
|
||||
self._refresh_trust_boundary(request)
|
||||
|
||||
def _perform_refresh_token(self, request, cert_fingerprint=None):
|
||||
scopes = self._scopes if self._scopes is not None else self._default_scopes
|
||||
|
||||
# Inject client certificate into request.
|
||||
if self._mtls_required():
|
||||
request = functools.partial(
|
||||
request, cert=self._get_mtls_cert_and_key_paths()
|
||||
)
|
||||
|
||||
if self._should_initialize_impersonated_credentials():
|
||||
self._impersonated_credentials = self._initialize_impersonated_credentials()
|
||||
|
||||
if self._impersonated_credentials:
|
||||
self._impersonated_credentials.refresh(request)
|
||||
self.token = self._impersonated_credentials.token
|
||||
self.expiry = self._impersonated_credentials.expiry
|
||||
else:
|
||||
now = _helpers.utcnow()
|
||||
additional_options = {}
|
||||
# Do not pass workforce_pool_user_project when client authentication
|
||||
# is used. The client ID is sufficient for determining the user project.
|
||||
if self._workforce_pool_user_project and not self._client_id:
|
||||
additional_options["userProject"] = self._workforce_pool_user_project
|
||||
|
||||
if cert_fingerprint:
|
||||
additional_options["bindCertFingerprint"] = cert_fingerprint
|
||||
|
||||
additional_headers = {
|
||||
metrics.API_CLIENT_HEADER: metrics.byoid_metrics_header(
|
||||
self._metrics_options
|
||||
)
|
||||
}
|
||||
response_data = self._sts_client.exchange_token(
|
||||
request=request,
|
||||
grant_type=_STS_GRANT_TYPE,
|
||||
subject_token=self.retrieve_subject_token(request),
|
||||
subject_token_type=self._subject_token_type,
|
||||
audience=self._audience,
|
||||
scopes=scopes,
|
||||
requested_token_type=_STS_REQUESTED_TOKEN_TYPE,
|
||||
additional_options=additional_options if additional_options else None,
|
||||
additional_headers=additional_headers,
|
||||
)
|
||||
self.token = response_data.get("access_token")
|
||||
expires_in = response_data.get("expires_in")
|
||||
# Some services do not respect the OAUTH2.0 RFC and send expires_in as a
|
||||
# JSON String.
|
||||
if isinstance(expires_in, str):
|
||||
expires_in = int(expires_in)
|
||||
|
||||
lifetime = datetime.timedelta(seconds=expires_in)
|
||||
|
||||
self.expiry = now + lifetime
|
||||
|
||||
def _build_trust_boundary_lookup_url(self):
|
||||
"""Builds and returns the URL for the trust boundary lookup API."""
|
||||
url = None
|
||||
# Try to parse as a workload identity pool.
|
||||
# Audience format: //iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID
|
||||
workload_match = re.search(
|
||||
r"projects/([^/]+)/locations/global/workloadIdentityPools/([^/]+)",
|
||||
self._audience,
|
||||
)
|
||||
if workload_match:
|
||||
project_number, pool_id = workload_match.groups()
|
||||
url = _constants._WORKLOAD_IDENTITY_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
|
||||
universe_domain=self._universe_domain,
|
||||
project_number=project_number,
|
||||
pool_id=pool_id,
|
||||
)
|
||||
else:
|
||||
# If that fails, try to parse as a workforce pool.
|
||||
# Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID
|
||||
workforce_match = re.search(
|
||||
r"locations/[^/]+/workforcePools/([^/]+)", self._audience
|
||||
)
|
||||
if workforce_match:
|
||||
pool_id = workforce_match.groups()[0]
|
||||
url = _constants._WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
|
||||
universe_domain=self._universe_domain, pool_id=pool_id
|
||||
)
|
||||
|
||||
if url:
|
||||
return url
|
||||
else:
|
||||
# If both fail, the audience format is invalid.
|
||||
raise exceptions.InvalidValue("Invalid audience format.")
|
||||
|
||||
def _make_copy(self):
|
||||
kwargs = self._constructor_args()
|
||||
new_cred = self.__class__(**kwargs)
|
||||
new_cred._cred_file_path = self._cred_file_path
|
||||
new_cred._metrics_options = self._metrics_options
|
||||
return new_cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
|
||||
def with_quota_project(self, quota_project_id):
|
||||
# Return copy of instance with the provided quota project ID.
|
||||
cred = self._make_copy()
|
||||
cred._quota_project_id = quota_project_id
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithTokenUri)
|
||||
def with_token_uri(self, token_uri):
|
||||
cred = self._make_copy()
|
||||
cred._token_url = token_uri
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain)
|
||||
def with_universe_domain(self, universe_domain):
|
||||
cred = self._make_copy()
|
||||
cred._universe_domain = universe_domain
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary)
|
||||
def with_trust_boundary(self, trust_boundary):
|
||||
cred = self._make_copy()
|
||||
cred._trust_boundary = trust_boundary
|
||||
return cred
|
||||
|
||||
def _should_initialize_impersonated_credentials(self):
|
||||
return (
|
||||
self._service_account_impersonation_url is not None
|
||||
and self._impersonated_credentials is None
|
||||
)
|
||||
|
||||
def _initialize_impersonated_credentials(self):
|
||||
"""Generates an impersonated credentials.
|
||||
|
||||
For more details, see `projects.serviceAccounts.generateAccessToken`_.
|
||||
|
||||
.. _projects.serviceAccounts.generateAccessToken: https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken
|
||||
|
||||
Returns:
|
||||
impersonated_credentials.Credential: The impersonated credentials
|
||||
object.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the generateAccessToken
|
||||
endpoint returned an error.
|
||||
"""
|
||||
# Return copy of instance with no service account impersonation.
|
||||
kwargs = self._constructor_args()
|
||||
kwargs.update(
|
||||
service_account_impersonation_url=None,
|
||||
service_account_impersonation_options={},
|
||||
)
|
||||
source_credentials = self.__class__(**kwargs)
|
||||
source_credentials._metrics_options = self._metrics_options
|
||||
|
||||
# Determine target_principal.
|
||||
target_principal = self.service_account_email
|
||||
if not target_principal:
|
||||
raise exceptions.RefreshError(
|
||||
"Unable to determine target principal from service account impersonation URL."
|
||||
)
|
||||
|
||||
scopes = self._scopes if self._scopes is not None else self._default_scopes
|
||||
# Initialize and return impersonated credentials.
|
||||
return impersonated_credentials.Credentials(
|
||||
source_credentials=source_credentials,
|
||||
target_principal=target_principal,
|
||||
target_scopes=scopes,
|
||||
quota_project_id=self._quota_project_id,
|
||||
iam_endpoint_override=self._service_account_impersonation_url,
|
||||
lifetime=self._service_account_impersonation_options.get(
|
||||
"token_lifetime_seconds"
|
||||
),
|
||||
trust_boundary=self._trust_boundary,
|
||||
)
|
||||
|
||||
def _create_default_metrics_options(self):
|
||||
metrics_options = {}
|
||||
if self._service_account_impersonation_url:
|
||||
metrics_options["sa-impersonation"] = "true"
|
||||
else:
|
||||
metrics_options["sa-impersonation"] = "false"
|
||||
if self._service_account_impersonation_options.get("token_lifetime_seconds"):
|
||||
metrics_options["config-lifetime"] = "true"
|
||||
else:
|
||||
metrics_options["config-lifetime"] = "false"
|
||||
|
||||
return metrics_options
|
||||
|
||||
def _mtls_required(self):
|
||||
"""Returns a boolean representing whether the current credential is configured
|
||||
for mTLS and should add a certificate to the outgoing calls to the sts and service
|
||||
account impersonation endpoint.
|
||||
|
||||
Returns:
|
||||
bool: True if the credential is configured for mTLS, False if it is not.
|
||||
"""
|
||||
return False
|
||||
|
||||
def _get_mtls_cert_and_key_paths(self):
|
||||
"""Gets the file locations for a certificate and private key file
|
||||
to be used for configuring mTLS for the sts and service account
|
||||
impersonation calls. Currently only expected to return a value when using
|
||||
X509 workload identity federation.
|
||||
|
||||
Returns:
|
||||
Tuple[str, str]: The cert and key file locations as strings in a tuple.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: When the current credential is not configured for
|
||||
mTLS.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"_get_mtls_cert_and_key_location must be implemented."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_info(cls, info, **kwargs):
|
||||
"""Creates a Credentials instance from parsed external account info.
|
||||
|
||||
**IMPORTANT**:
|
||||
This method does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using with this method.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
|
||||
Args:
|
||||
info (Mapping[str, str]): The external account info in Google
|
||||
format.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.identity_pool.Credentials: The constructed
|
||||
credentials.
|
||||
|
||||
Raises:
|
||||
InvalidValue: For invalid parameters.
|
||||
"""
|
||||
return cls(
|
||||
audience=info.get("audience"),
|
||||
subject_token_type=info.get("subject_token_type"),
|
||||
token_url=info.get("token_url"),
|
||||
token_info_url=info.get("token_info_url"),
|
||||
service_account_impersonation_url=info.get(
|
||||
"service_account_impersonation_url"
|
||||
),
|
||||
service_account_impersonation_options=info.get(
|
||||
"service_account_impersonation"
|
||||
)
|
||||
or {},
|
||||
client_id=info.get("client_id"),
|
||||
client_secret=info.get("client_secret"),
|
||||
credential_source=info.get("credential_source"),
|
||||
quota_project_id=info.get("quota_project_id"),
|
||||
workforce_pool_user_project=info.get("workforce_pool_user_project"),
|
||||
universe_domain=info.get(
|
||||
"universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN
|
||||
),
|
||||
trust_boundary=info.get("trust_boundary"),
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, filename, **kwargs):
|
||||
"""Creates a Credentials instance from an external account json file.
|
||||
|
||||
**IMPORTANT**:
|
||||
This method does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using with this method.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
|
||||
Args:
|
||||
filename (str): The path to the external account json file.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.identity_pool.Credentials: The constructed
|
||||
credentials.
|
||||
"""
|
||||
with io.open(filename, "r", encoding="utf-8") as json_file:
|
||||
data = json.load(json_file)
|
||||
return cls.from_info(data, **kwargs)
|
||||
@@ -0,0 +1,458 @@
|
||||
# Copyright 2022 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.
|
||||
|
||||
"""External Account Authorized User Credentials.
|
||||
This module provides credentials based on OAuth 2.0 access and refresh tokens.
|
||||
These credentials usually access resources on behalf of a user (resource
|
||||
owner).
|
||||
|
||||
Specifically, these are sourced using external identities via Workforce Identity Federation.
|
||||
|
||||
Obtaining the initial access and refresh token can be done through the Google Cloud CLI.
|
||||
|
||||
Example credential:
|
||||
{
|
||||
"type": "external_account_authorized_user",
|
||||
"audience": "//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID",
|
||||
"refresh_token": "refreshToken",
|
||||
"token_url": "https://sts.googleapis.com/v1/oauth/token",
|
||||
"token_info_url": "https://sts.googleapis.com/v1/instrospect",
|
||||
"client_id": "clientId",
|
||||
"client_secret": "clientSecret"
|
||||
}
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
|
||||
from google.auth import _constants
|
||||
from google.auth import _helpers
|
||||
from google.auth import credentials
|
||||
from google.auth import exceptions
|
||||
from google.oauth2 import sts
|
||||
from google.oauth2 import utils
|
||||
|
||||
_EXTERNAL_ACCOUNT_AUTHORIZED_USER_JSON_TYPE = "external_account_authorized_user"
|
||||
|
||||
|
||||
class Credentials(
|
||||
credentials.CredentialsWithQuotaProject,
|
||||
credentials.ReadOnlyScoped,
|
||||
credentials.CredentialsWithTokenUri,
|
||||
credentials.CredentialsWithTrustBoundary,
|
||||
):
|
||||
"""Credentials for External Account Authorized Users.
|
||||
|
||||
This is used to instantiate Credentials for exchanging refresh tokens from
|
||||
authorized users for Google access token and authorizing requests to Google
|
||||
APIs.
|
||||
|
||||
The credentials are considered immutable. If you want to modify the
|
||||
quota project, use `with_quota_project` and if you want to modify the token
|
||||
uri, use `with_token_uri`.
|
||||
|
||||
**IMPORTANT**:
|
||||
This class does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
token=None,
|
||||
expiry=None,
|
||||
refresh_token=None,
|
||||
audience=None,
|
||||
client_id=None,
|
||||
client_secret=None,
|
||||
token_url=None,
|
||||
token_info_url=None,
|
||||
revoke_url=None,
|
||||
scopes=None,
|
||||
quota_project_id=None,
|
||||
universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN,
|
||||
trust_boundary=None,
|
||||
):
|
||||
"""Instantiates a external account authorized user credentials object.
|
||||
|
||||
Args:
|
||||
token (str): The OAuth 2.0 access token. Can be None if refresh information
|
||||
is provided.
|
||||
expiry (datetime.datetime): The optional expiration datetime of the OAuth 2.0 access
|
||||
token.
|
||||
refresh_token (str): The optional OAuth 2.0 refresh token. If specified,
|
||||
credentials can be refreshed.
|
||||
audience (str): The optional STS audience which contains the resource name for the workforce
|
||||
pool and the provider identifier in that pool.
|
||||
client_id (str): The OAuth 2.0 client ID. Must be specified for refresh, can be left as
|
||||
None if the token can not be refreshed.
|
||||
client_secret (str): The OAuth 2.0 client secret. Must be specified for refresh, can be
|
||||
left as None if the token can not be refreshed.
|
||||
token_url (str): The optional STS token exchange endpoint for refresh. Must be specified for
|
||||
refresh, can be left as None if the token can not be refreshed.
|
||||
token_info_url (str): The optional STS endpoint URL for token introspection.
|
||||
revoke_url (str): The optional STS endpoint URL for revoking tokens.
|
||||
quota_project_id (str): The optional project ID used for quota and billing.
|
||||
This project may be different from the project used to
|
||||
create the credentials.
|
||||
universe_domain (Optional[str]): The universe domain. The default value
|
||||
is googleapis.com.
|
||||
trust_boundary (Mapping[str,str]): A credential trust boundary.
|
||||
|
||||
Returns:
|
||||
google.auth.external_account_authorized_user.Credentials: The
|
||||
constructed credentials.
|
||||
"""
|
||||
super(Credentials, self).__init__()
|
||||
|
||||
self.token = token
|
||||
self.expiry = expiry
|
||||
self._audience = audience
|
||||
self._refresh_token = refresh_token
|
||||
self._token_url = token_url
|
||||
self._token_info_url = token_info_url
|
||||
self._client_id = client_id
|
||||
self._client_secret = client_secret
|
||||
self._revoke_url = revoke_url
|
||||
self._quota_project_id = quota_project_id
|
||||
self._scopes = scopes
|
||||
self._universe_domain = universe_domain or credentials.DEFAULT_UNIVERSE_DOMAIN
|
||||
self._cred_file_path = None
|
||||
self._trust_boundary = trust_boundary
|
||||
|
||||
if not self.valid and not self.can_refresh:
|
||||
raise exceptions.InvalidOperation(
|
||||
"Token should be created with fields to make it valid (`token` and "
|
||||
"`expiry`), or fields to allow it to refresh (`refresh_token`, "
|
||||
"`token_url`, `client_id`, `client_secret`)."
|
||||
)
|
||||
|
||||
self._client_auth = None
|
||||
if self._client_id:
|
||||
self._client_auth = utils.ClientAuthentication(
|
||||
utils.ClientAuthType.basic, self._client_id, self._client_secret
|
||||
)
|
||||
self._sts_client = sts.Client(self._token_url, self._client_auth)
|
||||
|
||||
@property
|
||||
def info(self):
|
||||
"""Generates the serializable dictionary representation of the current
|
||||
credentials.
|
||||
|
||||
Returns:
|
||||
Mapping: The dictionary representation of the credentials. This is the
|
||||
reverse of the "from_info" method defined in this class. It is
|
||||
useful for serializing the current credentials so it can deserialized
|
||||
later.
|
||||
"""
|
||||
config_info = self.constructor_args()
|
||||
config_info.update(type=_EXTERNAL_ACCOUNT_AUTHORIZED_USER_JSON_TYPE)
|
||||
if config_info["expiry"]:
|
||||
config_info["expiry"] = config_info["expiry"].isoformat() + "Z"
|
||||
|
||||
return {key: value for key, value in config_info.items() if value is not None}
|
||||
|
||||
def constructor_args(self):
|
||||
return {
|
||||
"audience": self._audience,
|
||||
"refresh_token": self._refresh_token,
|
||||
"token_url": self._token_url,
|
||||
"token_info_url": self._token_info_url,
|
||||
"client_id": self._client_id,
|
||||
"client_secret": self._client_secret,
|
||||
"token": self.token,
|
||||
"expiry": self.expiry,
|
||||
"revoke_url": self._revoke_url,
|
||||
"scopes": self._scopes,
|
||||
"quota_project_id": self._quota_project_id,
|
||||
"universe_domain": self._universe_domain,
|
||||
"trust_boundary": self._trust_boundary,
|
||||
}
|
||||
|
||||
@property
|
||||
def scopes(self):
|
||||
"""Optional[str]: The OAuth 2.0 permission scopes."""
|
||||
return self._scopes
|
||||
|
||||
@property
|
||||
def requires_scopes(self):
|
||||
"""False: OAuth 2.0 credentials have their scopes set when
|
||||
the initial token is requested and can not be changed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def client_id(self):
|
||||
"""Optional[str]: The OAuth 2.0 client ID."""
|
||||
return self._client_id
|
||||
|
||||
@property
|
||||
def client_secret(self):
|
||||
"""Optional[str]: The OAuth 2.0 client secret."""
|
||||
return self._client_secret
|
||||
|
||||
@property
|
||||
def audience(self):
|
||||
"""Optional[str]: The STS audience which contains the resource name for the
|
||||
workforce pool and the provider identifier in that pool."""
|
||||
return self._audience
|
||||
|
||||
@property
|
||||
def refresh_token(self):
|
||||
"""Optional[str]: The OAuth 2.0 refresh token."""
|
||||
return self._refresh_token
|
||||
|
||||
@property
|
||||
def token_url(self):
|
||||
"""Optional[str]: The STS token exchange endpoint for refresh."""
|
||||
return self._token_url
|
||||
|
||||
@property
|
||||
def token_info_url(self):
|
||||
"""Optional[str]: The STS endpoint for token info."""
|
||||
return self._token_info_url
|
||||
|
||||
@property
|
||||
def revoke_url(self):
|
||||
"""Optional[str]: The STS endpoint for token revocation."""
|
||||
return self._revoke_url
|
||||
|
||||
@property
|
||||
def is_user(self):
|
||||
"""True: This credential always represents a user."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def can_refresh(self):
|
||||
return all(
|
||||
(
|
||||
self._refresh_token,
|
||||
self._token_url,
|
||||
self._client_id,
|
||||
self._client_secret,
|
||||
)
|
||||
)
|
||||
|
||||
def get_project_id(self, request=None):
|
||||
"""Retrieves the project ID corresponding to the workload identity or workforce pool.
|
||||
For workforce pool credentials, it returns the project ID corresponding to
|
||||
the workforce_pool_user_project.
|
||||
|
||||
When not determinable, None is returned.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.requests.Request): Request object.
|
||||
Unused here, but passed from _default.default().
|
||||
|
||||
Return:
|
||||
str: project ID is not determinable for this credential type so it returns None
|
||||
"""
|
||||
|
||||
return None
|
||||
|
||||
def to_json(self, strip=None):
|
||||
"""Utility function that creates a JSON representation of this
|
||||
credential.
|
||||
Args:
|
||||
strip (Sequence[str]): Optional list of members to exclude from the
|
||||
generated JSON.
|
||||
Returns:
|
||||
str: A JSON representation of this instance. When converted into
|
||||
a dictionary, it can be passed to from_info()
|
||||
to create a new instance.
|
||||
"""
|
||||
strip = strip if strip else []
|
||||
return json.dumps({k: v for (k, v) in self.info.items() if k not in strip})
|
||||
|
||||
def _perform_refresh_token(self, request):
|
||||
"""Refreshes the access token.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the credentials could
|
||||
not be refreshed.
|
||||
"""
|
||||
if not self.can_refresh:
|
||||
raise exceptions.RefreshError(
|
||||
"The credentials do not contain the necessary fields need to "
|
||||
"refresh the access token. You must specify refresh_token, "
|
||||
"token_url, client_id, and client_secret."
|
||||
)
|
||||
|
||||
now = _helpers.utcnow()
|
||||
response_data = self._sts_client.refresh_token(request, self._refresh_token)
|
||||
|
||||
self.token = response_data.get("access_token")
|
||||
|
||||
lifetime = datetime.timedelta(seconds=response_data.get("expires_in"))
|
||||
self.expiry = now + lifetime
|
||||
|
||||
if "refresh_token" in response_data:
|
||||
self._refresh_token = response_data["refresh_token"]
|
||||
|
||||
def _build_trust_boundary_lookup_url(self):
|
||||
"""Builds and returns the URL for the trust boundary lookup API."""
|
||||
# Audience format: //iam.googleapis.com/locations/global/workforcePools/POOL_ID/providers/PROVIDER_ID
|
||||
match = re.search(r"locations/[^/]+/workforcePools/([^/]+)", self._audience)
|
||||
|
||||
if not match:
|
||||
raise exceptions.InvalidValue("Invalid workforce pool audience format.")
|
||||
|
||||
pool_id = match.groups()[0]
|
||||
|
||||
return _constants._WORKFORCE_POOL_TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
|
||||
universe_domain=self._universe_domain, pool_id=pool_id
|
||||
)
|
||||
|
||||
def revoke(self, request):
|
||||
"""Revokes the refresh token.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.OAuthError: If the token could not be
|
||||
revoked.
|
||||
"""
|
||||
if not self._revoke_url or not self._refresh_token:
|
||||
raise exceptions.OAuthError(
|
||||
"The credentials do not contain the necessary fields to "
|
||||
"revoke the refresh token. You must specify revoke_url and "
|
||||
"refresh_token."
|
||||
)
|
||||
|
||||
self._sts_client.revoke_token(
|
||||
request, self._refresh_token, "refresh_token", self._revoke_url
|
||||
)
|
||||
self.token = None
|
||||
self._refresh_token = None
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
def get_cred_info(self):
|
||||
if self._cred_file_path:
|
||||
return {
|
||||
"credential_source": self._cred_file_path,
|
||||
"credential_type": "external account authorized user credentials",
|
||||
}
|
||||
return None
|
||||
|
||||
def _make_copy(self):
|
||||
kwargs = self.constructor_args()
|
||||
cred = self.__class__(**kwargs)
|
||||
cred._cred_file_path = self._cred_file_path
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
|
||||
def with_quota_project(self, quota_project_id):
|
||||
cred = self._make_copy()
|
||||
cred._quota_project_id = quota_project_id
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithTokenUri)
|
||||
def with_token_uri(self, token_uri):
|
||||
cred = self._make_copy()
|
||||
cred._token_url = token_uri
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithUniverseDomain)
|
||||
def with_universe_domain(self, universe_domain):
|
||||
cred = self._make_copy()
|
||||
cred._universe_domain = universe_domain
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary)
|
||||
def with_trust_boundary(self, trust_boundary):
|
||||
cred = self._make_copy()
|
||||
cred._trust_boundary = trust_boundary
|
||||
return cred
|
||||
|
||||
@classmethod
|
||||
def from_info(cls, info, **kwargs):
|
||||
"""Creates a Credentials instance from parsed external account info.
|
||||
|
||||
**IMPORTANT**:
|
||||
This method does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using with this method.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
|
||||
Args:
|
||||
info (Mapping[str, str]): The external account info in Google
|
||||
format.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.external_account_authorized_user.Credentials: The
|
||||
constructed credentials.
|
||||
|
||||
Raises:
|
||||
ValueError: For invalid parameters.
|
||||
"""
|
||||
expiry = info.get("expiry")
|
||||
if expiry:
|
||||
expiry = datetime.datetime.strptime(
|
||||
expiry.rstrip("Z").split(".")[0], "%Y-%m-%dT%H:%M:%S"
|
||||
)
|
||||
return cls(
|
||||
audience=info.get("audience"),
|
||||
refresh_token=info.get("refresh_token"),
|
||||
token_url=info.get("token_url"),
|
||||
token_info_url=info.get("token_info_url"),
|
||||
client_id=info.get("client_id"),
|
||||
client_secret=info.get("client_secret"),
|
||||
token=info.get("token"),
|
||||
expiry=expiry,
|
||||
revoke_url=info.get("revoke_url"),
|
||||
quota_project_id=info.get("quota_project_id"),
|
||||
scopes=info.get("scopes"),
|
||||
universe_domain=info.get(
|
||||
"universe_domain", credentials.DEFAULT_UNIVERSE_DOMAIN
|
||||
),
|
||||
trust_boundary=info.get("trust_boundary"),
|
||||
**kwargs
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, filename, **kwargs):
|
||||
"""Creates a Credentials instance from an external account json file.
|
||||
|
||||
**IMPORTANT**:
|
||||
This method does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using with this method.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
|
||||
Args:
|
||||
filename (str): The path to the external account json file.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.external_account_authorized_user.Credentials: The
|
||||
constructed credentials.
|
||||
"""
|
||||
with io.open(filename, "r", encoding="utf-8") as json_file:
|
||||
data = json.load(json_file)
|
||||
return cls.from_info(data, **kwargs)
|
||||
137
venv/lib/python3.12/site-packages/google/auth/iam.py
Normal file
137
venv/lib/python3.12/site-packages/google/auth/iam.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# Copyright 2017 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.
|
||||
|
||||
"""Tools for using the Google `Cloud Identity and Access Management (IAM)
|
||||
API`_'s auth-related functionality.
|
||||
|
||||
.. _Cloud Identity and Access Management (IAM) API:
|
||||
https://cloud.google.com/iam/docs/
|
||||
"""
|
||||
|
||||
import base64
|
||||
import http.client as http_client
|
||||
import json
|
||||
|
||||
from google.auth import _exponential_backoff
|
||||
from google.auth import _helpers
|
||||
from google.auth import credentials
|
||||
from google.auth import crypt
|
||||
from google.auth import exceptions
|
||||
from google.auth.transport import _mtls_helper
|
||||
|
||||
IAM_RETRY_CODES = {
|
||||
http_client.INTERNAL_SERVER_ERROR,
|
||||
http_client.BAD_GATEWAY,
|
||||
http_client.SERVICE_UNAVAILABLE,
|
||||
http_client.GATEWAY_TIMEOUT,
|
||||
}
|
||||
|
||||
_IAM_SCOPE = ["https://www.googleapis.com/auth/iam"]
|
||||
|
||||
# Determine if we should use mTLS.
|
||||
if (
|
||||
hasattr(_mtls_helper, "check_use_client_cert")
|
||||
and _mtls_helper.check_use_client_cert()
|
||||
):
|
||||
# Construct the template domain using the library's DEFAULT_UNIVERSE_DOMAIN constant.
|
||||
_IAM_DOMAIN = f"iamcredentials.mtls.{credentials.DEFAULT_UNIVERSE_DOMAIN}"
|
||||
else:
|
||||
_IAM_DOMAIN = f"iamcredentials.{credentials.DEFAULT_UNIVERSE_DOMAIN}"
|
||||
|
||||
# 3. Create the common base URL template
|
||||
# We use double brackets {{}} so .format() can be called later for the email.
|
||||
_IAM_BASE_URL = f"https://{_IAM_DOMAIN}/v1/projects/-/serviceAccounts/{{}}"
|
||||
|
||||
# 4. Define the endpoints as templates
|
||||
_IAM_ENDPOINT = _IAM_BASE_URL + ":generateAccessToken"
|
||||
_IAM_SIGN_ENDPOINT = _IAM_BASE_URL + ":signBlob"
|
||||
_IAM_SIGNJWT_ENDPOINT = _IAM_BASE_URL + ":signJwt"
|
||||
_IAM_IDTOKEN_ENDPOINT = _IAM_BASE_URL + ":generateIdToken"
|
||||
|
||||
|
||||
class Signer(crypt.Signer):
|
||||
"""Signs messages using the IAM `signBlob API`_.
|
||||
|
||||
This is useful when you need to sign bytes but do not have access to the
|
||||
credential's private key file.
|
||||
|
||||
.. _signBlob API:
|
||||
https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts
|
||||
/signBlob
|
||||
"""
|
||||
|
||||
def __init__(self, request, credentials, service_account_email):
|
||||
"""
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
credentials (google.auth.credentials.Credentials): The credentials
|
||||
that will be used to authenticate the request to the IAM API.
|
||||
The credentials must have of one the following scopes:
|
||||
|
||||
- https://www.googleapis.com/auth/iam
|
||||
- https://www.googleapis.com/auth/cloud-platform
|
||||
service_account_email (str): The service account email identifying
|
||||
which service account to use to sign bytes. Often, this can
|
||||
be the same as the service account email in the given
|
||||
credentials.
|
||||
"""
|
||||
self._request = request
|
||||
self._credentials = credentials
|
||||
self._service_account_email = service_account_email
|
||||
|
||||
def _make_signing_request(self, message):
|
||||
"""Makes a request to the API signBlob API."""
|
||||
message = _helpers.to_bytes(message)
|
||||
|
||||
method = "POST"
|
||||
url = _IAM_SIGN_ENDPOINT.replace(
|
||||
credentials.DEFAULT_UNIVERSE_DOMAIN, self._credentials.universe_domain
|
||||
).format(self._service_account_email)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
body = json.dumps(
|
||||
{"payload": base64.b64encode(message).decode("utf-8")}
|
||||
).encode("utf-8")
|
||||
|
||||
retries = _exponential_backoff.ExponentialBackoff()
|
||||
for _ in retries:
|
||||
self._credentials.before_request(self._request, method, url, headers)
|
||||
|
||||
response = self._request(url=url, method=method, body=body, headers=headers)
|
||||
|
||||
if response.status in IAM_RETRY_CODES:
|
||||
continue
|
||||
|
||||
if response.status != http_client.OK:
|
||||
raise exceptions.TransportError(
|
||||
"Error calling the IAM signBlob API: {}".format(response.data)
|
||||
)
|
||||
|
||||
return json.loads(response.data.decode("utf-8"))
|
||||
raise exceptions.TransportError("exhausted signBlob endpoint retries")
|
||||
|
||||
@property
|
||||
def key_id(self):
|
||||
"""Optional[str]: The key ID used to identify this private key.
|
||||
|
||||
.. warning::
|
||||
This is always ``None``. The key ID used by IAM can not
|
||||
be reliably determined ahead of time.
|
||||
"""
|
||||
return None
|
||||
|
||||
@_helpers.copy_docstring(crypt.Signer)
|
||||
def sign(self, message):
|
||||
response = self._make_signing_request(message)
|
||||
return base64.b64decode(response["signedBlob"])
|
||||
575
venv/lib/python3.12/site-packages/google/auth/identity_pool.py
Normal file
575
venv/lib/python3.12/site-packages/google/auth/identity_pool.py
Normal file
@@ -0,0 +1,575 @@
|
||||
# Copyright 2020 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.
|
||||
|
||||
"""Identity Pool Credentials.
|
||||
|
||||
This module provides credentials to access Google Cloud resources from on-prem
|
||||
or non-Google Cloud platforms which support external credentials (e.g. OIDC ID
|
||||
tokens) retrieved from local file locations or local servers. This includes
|
||||
Microsoft Azure and OIDC identity providers (e.g. K8s workloads registered with
|
||||
Hub with Hub workload identity enabled).
|
||||
|
||||
These credentials are recommended over the use of service account credentials
|
||||
in on-prem/non-Google Cloud platforms as they do not involve the management of
|
||||
long-live service account private keys.
|
||||
|
||||
Identity Pool Credentials are initialized using external_account
|
||||
arguments which are typically loaded from an external credentials file or
|
||||
an external credentials URL.
|
||||
|
||||
This module also provides a definition for an abstract subject token supplier.
|
||||
This supplier can be implemented to return a valid OIDC or SAML2.0 subject token
|
||||
and used to create Identity Pool credentials. The credentials will then call the
|
||||
supplier instead of using pre-defined methods such as reading a local file or
|
||||
calling a URL.
|
||||
"""
|
||||
|
||||
try:
|
||||
from collections.abc import Mapping
|
||||
# Python 2.7 compatibility
|
||||
except ImportError: # pragma: NO COVER
|
||||
from collections import Mapping # type: ignore
|
||||
import abc
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from typing import NamedTuple
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import exceptions
|
||||
from google.auth import external_account
|
||||
from google.auth.transport import _mtls_helper
|
||||
|
||||
|
||||
class SubjectTokenSupplier(metaclass=abc.ABCMeta):
|
||||
"""Base class for subject token suppliers. This can be implemented with custom logic to retrieve
|
||||
a subject token to exchange for a Google Cloud access token when using Workload or
|
||||
Workforce Identity Federation. The identity pool credential does not cache the subject token,
|
||||
so caching logic should be added in the implementation.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_subject_token(self, context, request):
|
||||
"""Returns the requested subject token. The subject token must be valid.
|
||||
|
||||
.. warning: This is not cached by the calling Google credential, so caching logic should be implemented in the supplier.
|
||||
|
||||
Args:
|
||||
context (google.auth.externalaccount.SupplierContext): The context object
|
||||
containing information about the requested audience and subject token type.
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If an error is encountered during
|
||||
subject token retrieval logic.
|
||||
|
||||
Returns:
|
||||
str: The requested subject token string.
|
||||
"""
|
||||
raise NotImplementedError("")
|
||||
|
||||
|
||||
class _TokenContent(NamedTuple):
|
||||
"""Models the token content response from file and url internal suppliers.
|
||||
Attributes:
|
||||
content (str): The string content of the file or URL response.
|
||||
location (str): The location the content was retrieved from. This will either be a file location or a URL.
|
||||
"""
|
||||
|
||||
content: str
|
||||
location: str
|
||||
|
||||
|
||||
class _FileSupplier(SubjectTokenSupplier):
|
||||
"""Internal implementation of subject token supplier which supports reading a subject token from a file."""
|
||||
|
||||
def __init__(self, path, format_type, subject_token_field_name):
|
||||
self._path = path
|
||||
self._format_type = format_type
|
||||
self._subject_token_field_name = subject_token_field_name
|
||||
|
||||
@_helpers.copy_docstring(SubjectTokenSupplier)
|
||||
def get_subject_token(self, context, request):
|
||||
if not os.path.exists(self._path):
|
||||
raise exceptions.RefreshError("File '{}' was not found.".format(self._path))
|
||||
|
||||
with open(self._path, "r", encoding="utf-8") as file_obj:
|
||||
token_content = _TokenContent(file_obj.read(), self._path)
|
||||
|
||||
return _parse_token_data(
|
||||
token_content, self._format_type, self._subject_token_field_name
|
||||
)
|
||||
|
||||
|
||||
class _UrlSupplier(SubjectTokenSupplier):
|
||||
"""Internal implementation of subject token supplier which supports retrieving a subject token by calling a URL endpoint."""
|
||||
|
||||
def __init__(self, url, format_type, subject_token_field_name, headers):
|
||||
self._url = url
|
||||
self._format_type = format_type
|
||||
self._subject_token_field_name = subject_token_field_name
|
||||
self._headers = headers
|
||||
|
||||
@_helpers.copy_docstring(SubjectTokenSupplier)
|
||||
def get_subject_token(self, context, request):
|
||||
response = request(url=self._url, method="GET", headers=self._headers)
|
||||
|
||||
# support both string and bytes type response.data
|
||||
response_body = (
|
||||
response.data.decode("utf-8")
|
||||
if hasattr(response.data, "decode")
|
||||
else response.data
|
||||
)
|
||||
|
||||
if response.status != 200:
|
||||
raise exceptions.RefreshError(
|
||||
"Unable to retrieve Identity Pool subject token", response_body
|
||||
)
|
||||
token_content = _TokenContent(response_body, self._url)
|
||||
return _parse_token_data(
|
||||
token_content, self._format_type, self._subject_token_field_name
|
||||
)
|
||||
|
||||
|
||||
class _X509Supplier(SubjectTokenSupplier):
|
||||
"""Internal supplier for X509 workload credentials. This class is used internally and always returns an empty string as the subject token."""
|
||||
|
||||
def __init__(self, trust_chain_path, leaf_cert_callback):
|
||||
self._trust_chain_path = trust_chain_path
|
||||
self._leaf_cert_callback = leaf_cert_callback
|
||||
|
||||
@_helpers.copy_docstring(SubjectTokenSupplier)
|
||||
def get_subject_token(self, context, request):
|
||||
# Import OpennSSL inline because it is an extra import only required by customers
|
||||
# using mTLS.
|
||||
from OpenSSL import crypto
|
||||
|
||||
leaf_cert = crypto.load_certificate(
|
||||
crypto.FILETYPE_PEM, self._leaf_cert_callback()
|
||||
)
|
||||
trust_chain = self._read_trust_chain()
|
||||
cert_chain = []
|
||||
|
||||
cert_chain.append(_X509Supplier._encode_cert(leaf_cert))
|
||||
|
||||
if trust_chain is None or len(trust_chain) == 0:
|
||||
return json.dumps(cert_chain)
|
||||
|
||||
# Append the first cert if it is not the leaf cert.
|
||||
first_cert = _X509Supplier._encode_cert(trust_chain[0])
|
||||
if first_cert != cert_chain[0]:
|
||||
cert_chain.append(first_cert)
|
||||
|
||||
for i in range(1, len(trust_chain)):
|
||||
encoded = _X509Supplier._encode_cert(trust_chain[i])
|
||||
# Check if the current cert is the leaf cert and raise an exception if it is.
|
||||
if encoded == cert_chain[0]:
|
||||
raise exceptions.RefreshError(
|
||||
"The leaf certificate must be at the top of the trust chain file"
|
||||
)
|
||||
else:
|
||||
cert_chain.append(encoded)
|
||||
return json.dumps(cert_chain)
|
||||
|
||||
def _read_trust_chain(self):
|
||||
# Import OpennSSL inline because it is an extra import only required by customers
|
||||
# using mTLS.
|
||||
from OpenSSL import crypto
|
||||
|
||||
certificate_trust_chain = []
|
||||
# If no trust chain path was provided, return an empty list.
|
||||
if self._trust_chain_path is None or self._trust_chain_path == "":
|
||||
return certificate_trust_chain
|
||||
try:
|
||||
# Open the trust chain file.
|
||||
with open(self._trust_chain_path, "rb") as f:
|
||||
trust_chain_data = f.read()
|
||||
# Split PEM data into individual certificates.
|
||||
cert_blocks = trust_chain_data.split(b"-----BEGIN CERTIFICATE-----")
|
||||
for cert_block in cert_blocks:
|
||||
# Skip empty blocks.
|
||||
if cert_block.strip():
|
||||
cert_data = b"-----BEGIN CERTIFICATE-----" + cert_block
|
||||
try:
|
||||
# Load each certificate and add it to the trust chain.
|
||||
cert = crypto.load_certificate(
|
||||
crypto.FILETYPE_PEM, cert_data
|
||||
)
|
||||
certificate_trust_chain.append(cert)
|
||||
except Exception as e:
|
||||
raise exceptions.RefreshError(
|
||||
"Error loading PEM certificates from the trust chain file '{}'".format(
|
||||
self._trust_chain_path
|
||||
)
|
||||
) from e
|
||||
return certificate_trust_chain
|
||||
except FileNotFoundError:
|
||||
raise exceptions.RefreshError(
|
||||
"Trust chain file '{}' was not found.".format(self._trust_chain_path)
|
||||
)
|
||||
|
||||
def _encode_cert(cert):
|
||||
# Import OpennSSL inline because it is an extra import only required by customers
|
||||
# using mTLS.
|
||||
from OpenSSL import crypto
|
||||
|
||||
return base64.b64encode(
|
||||
crypto.dump_certificate(crypto.FILETYPE_ASN1, cert)
|
||||
).decode("utf-8")
|
||||
|
||||
|
||||
def _parse_token_data(token_content, format_type="text", subject_token_field_name=None):
|
||||
if format_type == "text":
|
||||
token = token_content.content
|
||||
else:
|
||||
try:
|
||||
# Parse file content as JSON.
|
||||
response_data = json.loads(token_content.content)
|
||||
# Get the subject_token.
|
||||
token = response_data[subject_token_field_name]
|
||||
except (KeyError, ValueError):
|
||||
raise exceptions.RefreshError(
|
||||
"Unable to parse subject_token from JSON file '{}' using key '{}'".format(
|
||||
token_content.location, subject_token_field_name
|
||||
)
|
||||
)
|
||||
if not token:
|
||||
raise exceptions.RefreshError(
|
||||
"Missing subject_token in the credential_source file"
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
class Credentials(external_account.Credentials):
|
||||
"""External account credentials sourced from files and URLs.
|
||||
|
||||
**IMPORTANT**:
|
||||
This class does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audience,
|
||||
subject_token_type,
|
||||
token_url=external_account._DEFAULT_TOKEN_URL,
|
||||
credential_source=None,
|
||||
subject_token_supplier=None,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
"""Instantiates an external account credentials object from a file/URL.
|
||||
|
||||
Args:
|
||||
audience (str): The STS audience field.
|
||||
subject_token_type (str): The subject token type based on the Oauth2.0 token exchange spec.
|
||||
Expected values include::
|
||||
|
||||
“urn:ietf:params:oauth:token-type:jwt”
|
||||
“urn:ietf:params:oauth:token-type:id-token”
|
||||
“urn:ietf:params:oauth:token-type:saml2”
|
||||
|
||||
token_url (Optional [str]): The STS endpoint URL. If not provided, will default to "https://sts.googleapis.com/v1/token".
|
||||
credential_source (Optional [Mapping]): The credential source dictionary used to
|
||||
provide instructions on how to retrieve external credential to be
|
||||
exchanged for Google access tokens. Either a credential source or
|
||||
a subject token supplier must be provided.
|
||||
|
||||
Example credential_source for url-sourced credential::
|
||||
|
||||
{
|
||||
"url": "http://www.example.com",
|
||||
"format": {
|
||||
"type": "json",
|
||||
"subject_token_field_name": "access_token",
|
||||
},
|
||||
"headers": {"foo": "bar"},
|
||||
}
|
||||
|
||||
Example credential_source for file-sourced credential::
|
||||
|
||||
{
|
||||
"file": "/path/to/token/file.txt"
|
||||
}
|
||||
subject_token_supplier (Optional [SubjectTokenSupplier]): Optional subject token supplier.
|
||||
This will be called to supply a valid subject token which will then
|
||||
be exchanged for Google access tokens. Either a subject token supplier
|
||||
or a credential source must be provided.
|
||||
args (List): Optional positional arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method.
|
||||
kwargs (Mapping): Optional keyword arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If an error is encountered during
|
||||
access token retrieval logic.
|
||||
ValueError: For invalid parameters.
|
||||
|
||||
.. note:: Typically one of the helper constructors
|
||||
:meth:`from_file` or
|
||||
:meth:`from_info` are used instead of calling the constructor directly.
|
||||
"""
|
||||
|
||||
super(Credentials, self).__init__(
|
||||
audience=audience,
|
||||
subject_token_type=subject_token_type,
|
||||
token_url=token_url,
|
||||
credential_source=credential_source,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
if credential_source is None and subject_token_supplier is None:
|
||||
raise exceptions.InvalidValue(
|
||||
"A valid credential source or a subject token supplier must be provided."
|
||||
)
|
||||
if credential_source is not None and subject_token_supplier is not None:
|
||||
raise exceptions.InvalidValue(
|
||||
"Identity pool credential cannot have both a credential source and a subject token supplier."
|
||||
)
|
||||
|
||||
if subject_token_supplier is not None:
|
||||
self._subject_token_supplier = subject_token_supplier
|
||||
self._credential_source_file = None
|
||||
self._credential_source_url = None
|
||||
self._credential_source_certificate = None
|
||||
else:
|
||||
if not isinstance(credential_source, Mapping):
|
||||
self._credential_source_executable = None
|
||||
raise exceptions.MalformedError(
|
||||
"Invalid credential_source. The credential_source is not a dict."
|
||||
)
|
||||
self._credential_source_file = credential_source.get("file")
|
||||
self._credential_source_url = credential_source.get("url")
|
||||
self._credential_source_certificate = credential_source.get("certificate")
|
||||
|
||||
# environment_id is only supported in AWS or dedicated future external
|
||||
# account credentials.
|
||||
if "environment_id" in credential_source:
|
||||
raise exceptions.MalformedError(
|
||||
"Invalid Identity Pool credential_source field 'environment_id'"
|
||||
)
|
||||
|
||||
# check that only one of file, url, or certificate are provided.
|
||||
self._validate_single_source()
|
||||
|
||||
if self._credential_source_certificate:
|
||||
self._validate_certificate_config()
|
||||
else:
|
||||
self._validate_file_or_url_config(credential_source)
|
||||
|
||||
if self._credential_source_file:
|
||||
self._subject_token_supplier = _FileSupplier(
|
||||
self._credential_source_file,
|
||||
self._credential_source_format_type,
|
||||
self._credential_source_field_name,
|
||||
)
|
||||
elif self._credential_source_url:
|
||||
self._subject_token_supplier = _UrlSupplier(
|
||||
self._credential_source_url,
|
||||
self._credential_source_format_type,
|
||||
self._credential_source_field_name,
|
||||
self._credential_source_headers,
|
||||
)
|
||||
else: # self._credential_source_certificate
|
||||
self._subject_token_supplier = _X509Supplier(
|
||||
self._trust_chain_path, self._get_cert_bytes
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(external_account.Credentials)
|
||||
def retrieve_subject_token(self, request):
|
||||
return self._subject_token_supplier.get_subject_token(
|
||||
self._supplier_context, request
|
||||
)
|
||||
|
||||
def _get_mtls_cert_and_key_paths(self):
|
||||
if self._credential_source_certificate is None:
|
||||
raise exceptions.RefreshError(
|
||||
'The credential is not configured to use mtls requests. The credential should include a "certificate" section in the credential source.'
|
||||
)
|
||||
else:
|
||||
return _mtls_helper._get_workload_cert_and_key_paths(
|
||||
self._certificate_config_location
|
||||
)
|
||||
|
||||
def _get_cert_bytes(self):
|
||||
cert_path, _ = self._get_mtls_cert_and_key_paths()
|
||||
return _mtls_helper._read_cert_file(cert_path)
|
||||
|
||||
def _mtls_required(self):
|
||||
return self._credential_source_certificate is not None
|
||||
|
||||
def _create_default_metrics_options(self):
|
||||
metrics_options = super(Credentials, self)._create_default_metrics_options()
|
||||
# Check that credential source is a dict before checking for credential type. This check needs to be done
|
||||
# here because the external_account credential constructor needs to pass the metrics options to the
|
||||
# impersonated credential object before the identity_pool credentials are validated.
|
||||
if isinstance(self._credential_source, Mapping):
|
||||
if self._credential_source.get("file"):
|
||||
metrics_options["source"] = "file"
|
||||
elif self._credential_source.get("url"):
|
||||
metrics_options["source"] = "url"
|
||||
else:
|
||||
metrics_options["source"] = "x509"
|
||||
else:
|
||||
metrics_options["source"] = "programmatic"
|
||||
return metrics_options
|
||||
|
||||
def _has_custom_supplier(self):
|
||||
return self._credential_source is None
|
||||
|
||||
def _constructor_args(self):
|
||||
args = super(Credentials, self)._constructor_args()
|
||||
# If a custom supplier was used, append it to the args dict.
|
||||
if self._has_custom_supplier():
|
||||
args.update({"subject_token_supplier": self._subject_token_supplier})
|
||||
return args
|
||||
|
||||
def _validate_certificate_config(self):
|
||||
self._certificate_config_location = self._credential_source_certificate.get(
|
||||
"certificate_config_location"
|
||||
)
|
||||
use_default = self._credential_source_certificate.get(
|
||||
"use_default_certificate_config"
|
||||
)
|
||||
self._trust_chain_path = self._credential_source_certificate.get(
|
||||
"trust_chain_path"
|
||||
)
|
||||
if self._certificate_config_location and use_default:
|
||||
raise exceptions.MalformedError(
|
||||
"Invalid certificate configuration, certificate_config_location cannot be specified when use_default_certificate_config = true."
|
||||
)
|
||||
if not self._certificate_config_location and not use_default:
|
||||
raise exceptions.MalformedError(
|
||||
"Invalid certificate configuration, use_default_certificate_config should be true if no certificate_config_location is provided."
|
||||
)
|
||||
|
||||
def _validate_file_or_url_config(self, credential_source):
|
||||
self._credential_source_headers = credential_source.get("headers")
|
||||
credential_source_format = credential_source.get("format", {})
|
||||
# Get credential_source format type. When not provided, this
|
||||
# defaults to text.
|
||||
self._credential_source_format_type = (
|
||||
credential_source_format.get("type") or "text"
|
||||
)
|
||||
if self._credential_source_format_type not in ["text", "json"]:
|
||||
raise exceptions.MalformedError(
|
||||
"Invalid credential_source format '{}'".format(
|
||||
self._credential_source_format_type
|
||||
)
|
||||
)
|
||||
# For JSON types, get the required subject_token field name.
|
||||
if self._credential_source_format_type == "json":
|
||||
self._credential_source_field_name = credential_source_format.get(
|
||||
"subject_token_field_name"
|
||||
)
|
||||
if self._credential_source_field_name is None:
|
||||
raise exceptions.MalformedError(
|
||||
"Missing subject_token_field_name for JSON credential_source format"
|
||||
)
|
||||
else:
|
||||
self._credential_source_field_name = None
|
||||
|
||||
def _validate_single_source(self):
|
||||
credential_sources = [
|
||||
self._credential_source_file,
|
||||
self._credential_source_url,
|
||||
self._credential_source_certificate,
|
||||
]
|
||||
valid_credential_sources = list(
|
||||
filter(lambda source: source is not None, credential_sources)
|
||||
)
|
||||
|
||||
if len(valid_credential_sources) > 1:
|
||||
raise exceptions.MalformedError(
|
||||
"Ambiguous credential_source. 'file', 'url', and 'certificate' are mutually exclusive.."
|
||||
)
|
||||
if len(valid_credential_sources) != 1:
|
||||
raise exceptions.MalformedError(
|
||||
"Missing credential_source. A 'file', 'url', or 'certificate' must be provided."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_info(cls, info, **kwargs):
|
||||
"""Creates an Identity Pool Credentials instance from parsed external account info.
|
||||
|
||||
**IMPORTANT**:
|
||||
This method does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using with this method.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
|
||||
Args:
|
||||
info (Mapping[str, str]): The Identity Pool external account info in Google
|
||||
format.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.identity_pool.Credentials: The constructed
|
||||
credentials.
|
||||
|
||||
Raises:
|
||||
ValueError: For invalid parameters.
|
||||
"""
|
||||
subject_token_supplier = info.get("subject_token_supplier")
|
||||
kwargs.update({"subject_token_supplier": subject_token_supplier})
|
||||
return super(Credentials, cls).from_info(info, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, filename, **kwargs):
|
||||
"""Creates an IdentityPool Credentials instance from an external account json file.
|
||||
|
||||
**IMPORTANT**:
|
||||
This method does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using with this method.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
|
||||
Args:
|
||||
filename (str): The path to the IdentityPool external account json file.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.identity_pool.Credentials: The constructed
|
||||
credentials.
|
||||
"""
|
||||
return super(Credentials, cls).from_file(filename, **kwargs)
|
||||
|
||||
def refresh(self, request):
|
||||
"""Refreshes the access token.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): The object used to make
|
||||
HTTP requests.
|
||||
"""
|
||||
from google.auth import _agent_identity_utils
|
||||
|
||||
cert_fingerprint = None
|
||||
# Check if the credential is X.509 based.
|
||||
if self._credential_source_certificate is not None:
|
||||
cert_bytes = self._get_cert_bytes()
|
||||
cert = _agent_identity_utils.parse_certificate(cert_bytes)
|
||||
if _agent_identity_utils.should_request_bound_token(cert):
|
||||
cert_fingerprint = (
|
||||
_agent_identity_utils.calculate_certificate_fingerprint(cert)
|
||||
)
|
||||
|
||||
self._perform_refresh_token(request, cert_fingerprint=cert_fingerprint)
|
||||
self._handle_trust_boundary(request)
|
||||
@@ -0,0 +1,712 @@
|
||||
# Copyright 2018 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.
|
||||
|
||||
"""Google Cloud Impersonated credentials.
|
||||
|
||||
This module provides authentication for applications where local credentials
|
||||
impersonates a remote service account using `IAM Credentials API`_.
|
||||
|
||||
This class can be used to impersonate a service account as long as the original
|
||||
Credential object has the "Service Account Token Creator" role on the target
|
||||
service account.
|
||||
|
||||
.. _IAM Credentials API:
|
||||
https://cloud.google.com/iam/credentials/reference/rest/
|
||||
"""
|
||||
|
||||
import base64
|
||||
import copy
|
||||
from datetime import datetime
|
||||
import http.client as http_client
|
||||
import json
|
||||
|
||||
from google.auth import _exponential_backoff
|
||||
from google.auth import _helpers
|
||||
from google.auth import credentials
|
||||
from google.auth import exceptions
|
||||
from google.auth import iam
|
||||
from google.auth import jwt
|
||||
from google.auth import metrics
|
||||
from google.oauth2 import _client
|
||||
|
||||
|
||||
_REFRESH_ERROR = "Unable to acquire impersonated credentials"
|
||||
|
||||
_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds
|
||||
|
||||
_GOOGLE_OAUTH2_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
|
||||
_TRUST_BOUNDARY_LOOKUP_ENDPOINT = (
|
||||
"https://iamcredentials.{}/v1/projects/-/serviceAccounts/{}/allowedLocations"
|
||||
)
|
||||
|
||||
_SOURCE_CREDENTIAL_AUTHORIZED_USER_TYPE = "authorized_user"
|
||||
_SOURCE_CREDENTIAL_SERVICE_ACCOUNT_TYPE = "service_account"
|
||||
_SOURCE_CREDENTIAL_EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE = (
|
||||
"external_account_authorized_user"
|
||||
)
|
||||
|
||||
|
||||
def _make_iam_token_request(
|
||||
request,
|
||||
principal,
|
||||
headers,
|
||||
body,
|
||||
universe_domain=credentials.DEFAULT_UNIVERSE_DOMAIN,
|
||||
iam_endpoint_override=None,
|
||||
):
|
||||
"""Makes a request to the Google Cloud IAM service for an access token.
|
||||
Args:
|
||||
request (Request): The Request object to use.
|
||||
principal (str): The principal to request an access token for.
|
||||
headers (Mapping[str, str]): Map of headers to transmit.
|
||||
body (Mapping[str, str]): JSON Payload body for the iamcredentials
|
||||
API call.
|
||||
iam_endpoint_override (Optiona[str]): The full IAM endpoint override
|
||||
with the target_principal embedded. This is useful when supporting
|
||||
impersonation with regional endpoints.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: Raised if there is an underlying
|
||||
HTTP connection error
|
||||
google.auth.exceptions.RefreshError: Raised if the impersonated
|
||||
credentials are not available. Common reasons are
|
||||
`iamcredentials.googleapis.com` is not enabled or the
|
||||
`Service Account Token Creator` is not assigned
|
||||
"""
|
||||
iam_endpoint = iam_endpoint_override or iam._IAM_ENDPOINT.replace(
|
||||
credentials.DEFAULT_UNIVERSE_DOMAIN, universe_domain
|
||||
).format(principal)
|
||||
|
||||
body = json.dumps(body).encode("utf-8")
|
||||
|
||||
response = request(url=iam_endpoint, method="POST", headers=headers, body=body)
|
||||
|
||||
# support both string and bytes type response.data
|
||||
response_body = (
|
||||
response.data.decode("utf-8")
|
||||
if hasattr(response.data, "decode")
|
||||
else response.data
|
||||
)
|
||||
|
||||
if response.status != http_client.OK:
|
||||
raise exceptions.RefreshError(_REFRESH_ERROR, response_body)
|
||||
|
||||
try:
|
||||
token_response = json.loads(response_body)
|
||||
token = token_response["accessToken"]
|
||||
expiry = datetime.strptime(token_response["expireTime"], "%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
return token, expiry
|
||||
|
||||
except (KeyError, ValueError) as caught_exc:
|
||||
new_exc = exceptions.RefreshError(
|
||||
"{}: No access token or invalid expiration in response.".format(
|
||||
_REFRESH_ERROR
|
||||
),
|
||||
response_body,
|
||||
)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
|
||||
class Credentials(
|
||||
credentials.Scoped,
|
||||
credentials.CredentialsWithQuotaProject,
|
||||
credentials.Signing,
|
||||
credentials.CredentialsWithTrustBoundary,
|
||||
):
|
||||
"""This module defines impersonated credentials which are essentially
|
||||
impersonated identities.
|
||||
|
||||
Impersonated Credentials allows credentials issued to a user or
|
||||
service account to impersonate another. The target service account must
|
||||
grant the originating credential principal the
|
||||
`Service Account Token Creator`_ IAM role:
|
||||
|
||||
For more information about Token Creator IAM role and
|
||||
IAMCredentials API, see
|
||||
`Creating Short-Lived Service Account Credentials`_.
|
||||
|
||||
.. _Service Account Token Creator:
|
||||
https://cloud.google.com/iam/docs/service-accounts#the_service_account_token_creator_role
|
||||
|
||||
.. _Creating Short-Lived Service Account Credentials:
|
||||
https://cloud.google.com/iam/docs/creating-short-lived-service-account-credentials
|
||||
|
||||
Usage:
|
||||
|
||||
First grant source_credentials the `Service Account Token Creator`
|
||||
role on the target account to impersonate. In this example, the
|
||||
service account represented by svc_account.json has the
|
||||
token creator role on
|
||||
`impersonated-account@_project_.iam.gserviceaccount.com`.
|
||||
|
||||
Enable the IAMCredentials API on the source project:
|
||||
`gcloud services enable iamcredentials.googleapis.com`.
|
||||
|
||||
Initialize a source credential which does not have access to
|
||||
list bucket::
|
||||
|
||||
from google.oauth2 import service_account
|
||||
|
||||
target_scopes = [
|
||||
'https://www.googleapis.com/auth/devstorage.read_only']
|
||||
|
||||
source_credentials = (
|
||||
service_account.Credentials.from_service_account_file(
|
||||
'/path/to/svc_account.json',
|
||||
scopes=target_scopes))
|
||||
|
||||
Now use the source credentials to acquire credentials to impersonate
|
||||
another service account::
|
||||
|
||||
from google.auth import impersonated_credentials
|
||||
|
||||
target_credentials = impersonated_credentials.Credentials(
|
||||
source_credentials=source_credentials,
|
||||
target_principal='impersonated-account@_project_.iam.gserviceaccount.com',
|
||||
target_scopes = target_scopes,
|
||||
lifetime=500)
|
||||
|
||||
Resource access is granted::
|
||||
|
||||
client = storage.Client(credentials=target_credentials)
|
||||
buckets = client.list_buckets(project='your_project')
|
||||
for bucket in buckets:
|
||||
print(bucket.name)
|
||||
|
||||
**IMPORTANT**:
|
||||
This class does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_credentials,
|
||||
target_principal,
|
||||
target_scopes,
|
||||
delegates=None,
|
||||
subject=None,
|
||||
lifetime=_DEFAULT_TOKEN_LIFETIME_SECS,
|
||||
quota_project_id=None,
|
||||
iam_endpoint_override=None,
|
||||
trust_boundary=None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
source_credentials (google.auth.Credentials): The source credential
|
||||
used as to acquire the impersonated credentials.
|
||||
target_principal (str): The service account to impersonate.
|
||||
target_scopes (Sequence[str]): Scopes to request during the
|
||||
authorization grant.
|
||||
delegates (Sequence[str]): The chained list of delegates required
|
||||
to grant the final access_token. If set, the sequence of
|
||||
identities must have "Service Account Token Creator" capability
|
||||
granted to the prceeding identity. For example, if set to
|
||||
[serviceAccountB, serviceAccountC], the source_credential
|
||||
must have the Token Creator role on serviceAccountB.
|
||||
serviceAccountB must have the Token Creator on
|
||||
serviceAccountC.
|
||||
Finally, C must have Token Creator on target_principal.
|
||||
If left unset, source_credential must have that role on
|
||||
target_principal.
|
||||
lifetime (int): Number of seconds the delegated credential should
|
||||
be valid for (upto 3600).
|
||||
quota_project_id (Optional[str]): The project ID used for quota and billing.
|
||||
This project may be different from the project used to
|
||||
create the credentials.
|
||||
iam_endpoint_override (Optional[str]): The full IAM endpoint override
|
||||
with the target_principal embedded. This is useful when supporting
|
||||
impersonation with regional endpoints.
|
||||
subject (Optional[str]): sub field of a JWT. This field should only be set
|
||||
if you wish to impersonate as a user. This feature is useful when
|
||||
using domain wide delegation.
|
||||
trust_boundary (Mapping[str,str]): A credential trust boundary.
|
||||
"""
|
||||
|
||||
super(Credentials, self).__init__()
|
||||
|
||||
self._source_credentials = copy.copy(source_credentials)
|
||||
# Service account source credentials must have the _IAM_SCOPE
|
||||
# added to refresh correctly. User credentials cannot have
|
||||
# their original scopes modified.
|
||||
if isinstance(self._source_credentials, credentials.Scoped):
|
||||
self._source_credentials = self._source_credentials.with_scopes(
|
||||
iam._IAM_SCOPE
|
||||
)
|
||||
# If the source credential is service account and self signed jwt
|
||||
# is needed, we need to create a jwt credential inside it
|
||||
if (
|
||||
hasattr(self._source_credentials, "_create_self_signed_jwt")
|
||||
and self._source_credentials._always_use_jwt_access
|
||||
):
|
||||
self._source_credentials._create_self_signed_jwt(None)
|
||||
|
||||
self._universe_domain = source_credentials.universe_domain
|
||||
self._target_principal = target_principal
|
||||
self._target_scopes = target_scopes
|
||||
self._delegates = delegates
|
||||
self._subject = subject
|
||||
self._lifetime = lifetime or _DEFAULT_TOKEN_LIFETIME_SECS
|
||||
self.token = None
|
||||
self.expiry = _helpers.utcnow()
|
||||
self._quota_project_id = quota_project_id
|
||||
self._iam_endpoint_override = iam_endpoint_override
|
||||
self._cred_file_path = None
|
||||
self._trust_boundary = trust_boundary
|
||||
|
||||
def _metric_header_for_usage(self):
|
||||
return metrics.CRED_TYPE_SA_IMPERSONATE
|
||||
|
||||
def _perform_refresh_token(self, request):
|
||||
"""Updates credentials with a new access_token representing
|
||||
the impersonated account.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.requests.Request): Request object
|
||||
to use for refreshing credentials.
|
||||
"""
|
||||
|
||||
# Refresh our source credentials if it is not valid.
|
||||
if (
|
||||
self._source_credentials.token_state == credentials.TokenState.STALE
|
||||
or self._source_credentials.token_state == credentials.TokenState.INVALID
|
||||
):
|
||||
self._source_credentials.refresh(request)
|
||||
|
||||
body = {
|
||||
"delegates": self._delegates,
|
||||
"scope": self._target_scopes,
|
||||
"lifetime": str(self._lifetime) + "s",
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
metrics.API_CLIENT_HEADER: metrics.token_request_access_token_impersonate(),
|
||||
}
|
||||
|
||||
# Apply the source credentials authentication info.
|
||||
self._source_credentials.apply(headers)
|
||||
|
||||
# If a subject is specified a domain-wide delegation auth-flow is initiated
|
||||
# to impersonate as the provided subject (user).
|
||||
if self._subject:
|
||||
if self.universe_domain != credentials.DEFAULT_UNIVERSE_DOMAIN:
|
||||
raise exceptions.GoogleAuthError(
|
||||
"Domain-wide delegation is not supported in universes other "
|
||||
+ "than googleapis.com"
|
||||
)
|
||||
|
||||
now = _helpers.utcnow()
|
||||
payload = {
|
||||
"iss": self._target_principal,
|
||||
"scope": _helpers.scopes_to_string(self._target_scopes or ()),
|
||||
"sub": self._subject,
|
||||
"aud": _GOOGLE_OAUTH2_TOKEN_ENDPOINT,
|
||||
"iat": _helpers.datetime_to_secs(now),
|
||||
"exp": _helpers.datetime_to_secs(now) + _DEFAULT_TOKEN_LIFETIME_SECS,
|
||||
}
|
||||
|
||||
assertion = _sign_jwt_request(
|
||||
request=request,
|
||||
principal=self._target_principal,
|
||||
headers=headers,
|
||||
payload=payload,
|
||||
delegates=self._delegates,
|
||||
)
|
||||
|
||||
self.token, self.expiry, _ = _client.jwt_grant(
|
||||
request, _GOOGLE_OAUTH2_TOKEN_ENDPOINT, assertion
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
self.token, self.expiry = _make_iam_token_request(
|
||||
request=request,
|
||||
principal=self._target_principal,
|
||||
headers=headers,
|
||||
body=body,
|
||||
universe_domain=self.universe_domain,
|
||||
iam_endpoint_override=self._iam_endpoint_override,
|
||||
)
|
||||
|
||||
def _build_trust_boundary_lookup_url(self):
|
||||
"""Builds and returns the URL for the trust boundary lookup API.
|
||||
|
||||
This method constructs the specific URL for the IAM Credentials API's
|
||||
`allowedLocations` endpoint, using the credential's universe domain
|
||||
and service account email.
|
||||
|
||||
Raises:
|
||||
ValueError: If `self.service_account_email` is None or an empty
|
||||
string, as it's required to form the URL.
|
||||
|
||||
Returns:
|
||||
str: The URL for the trust boundary lookup endpoint.
|
||||
"""
|
||||
if not self.service_account_email:
|
||||
raise ValueError(
|
||||
"Service account email is required to build the trust boundary lookup URL."
|
||||
)
|
||||
return _TRUST_BOUNDARY_LOOKUP_ENDPOINT.format(
|
||||
self.universe_domain, self.service_account_email
|
||||
)
|
||||
|
||||
def sign_bytes(self, message):
|
||||
from google.auth.transport.requests import AuthorizedSession
|
||||
|
||||
iam_sign_endpoint = iam._IAM_SIGN_ENDPOINT.replace(
|
||||
credentials.DEFAULT_UNIVERSE_DOMAIN, self.universe_domain
|
||||
).format(self._target_principal)
|
||||
|
||||
body = {
|
||||
"payload": base64.b64encode(message).decode("utf-8"),
|
||||
"delegates": self._delegates,
|
||||
}
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
authed_session = AuthorizedSession(self._source_credentials)
|
||||
|
||||
try:
|
||||
retries = _exponential_backoff.ExponentialBackoff()
|
||||
for _ in retries:
|
||||
response = authed_session.post(
|
||||
url=iam_sign_endpoint, headers=headers, json=body
|
||||
)
|
||||
if response.status_code in iam.IAM_RETRY_CODES:
|
||||
continue
|
||||
if response.status_code != http_client.OK:
|
||||
raise exceptions.TransportError(
|
||||
"Error calling sign_bytes: {}".format(response.json())
|
||||
)
|
||||
|
||||
return base64.b64decode(response.json()["signedBlob"])
|
||||
finally:
|
||||
authed_session.close()
|
||||
raise exceptions.TransportError("exhausted signBlob endpoint retries")
|
||||
|
||||
@property
|
||||
def signer_email(self):
|
||||
return self._target_principal
|
||||
|
||||
@property
|
||||
def service_account_email(self):
|
||||
return self._target_principal
|
||||
|
||||
@property
|
||||
def signer(self):
|
||||
return self
|
||||
|
||||
@property
|
||||
def requires_scopes(self):
|
||||
return not self._target_scopes
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
def get_cred_info(self):
|
||||
if self._cred_file_path:
|
||||
return {
|
||||
"credential_source": self._cred_file_path,
|
||||
"credential_type": "impersonated credentials",
|
||||
"principal": self._target_principal,
|
||||
}
|
||||
return None
|
||||
|
||||
def _make_copy(self):
|
||||
cred = self.__class__(
|
||||
self._source_credentials,
|
||||
target_principal=self._target_principal,
|
||||
target_scopes=self._target_scopes,
|
||||
delegates=self._delegates,
|
||||
lifetime=self._lifetime,
|
||||
quota_project_id=self._quota_project_id,
|
||||
iam_endpoint_override=self._iam_endpoint_override,
|
||||
trust_boundary=self._trust_boundary,
|
||||
)
|
||||
cred._cred_file_path = self._cred_file_path
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithTrustBoundary)
|
||||
def with_trust_boundary(self, trust_boundary):
|
||||
cred = self._make_copy()
|
||||
cred._trust_boundary = trust_boundary
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
|
||||
def with_quota_project(self, quota_project_id):
|
||||
cred = self._make_copy()
|
||||
cred._quota_project_id = quota_project_id
|
||||
return cred
|
||||
|
||||
@_helpers.copy_docstring(credentials.Scoped)
|
||||
def with_scopes(self, scopes, default_scopes=None):
|
||||
cred = self._make_copy()
|
||||
cred._target_scopes = scopes or default_scopes
|
||||
return cred
|
||||
|
||||
@classmethod
|
||||
def from_impersonated_service_account_info(cls, info, scopes=None):
|
||||
"""Creates a Credentials instance from parsed impersonated service account credentials info.
|
||||
|
||||
**IMPORTANT**:
|
||||
This method does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using with this method.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
|
||||
Args:
|
||||
info (Mapping[str, str]): The impersonated service account credentials info in Google
|
||||
format.
|
||||
scopes (Sequence[str]): Optional list of scopes to include in the
|
||||
credentials.
|
||||
|
||||
Returns:
|
||||
google.oauth2.credentials.Credentials: The constructed
|
||||
credentials.
|
||||
|
||||
Raises:
|
||||
InvalidType: If the info["source_credentials"] are not a supported impersonation type
|
||||
InvalidValue: If the info["service_account_impersonation_url"] is not in the expected format.
|
||||
ValueError: If the info is not in the expected format.
|
||||
"""
|
||||
|
||||
source_credentials_info = info.get("source_credentials")
|
||||
source_credentials_type = source_credentials_info.get("type")
|
||||
if source_credentials_type == _SOURCE_CREDENTIAL_AUTHORIZED_USER_TYPE:
|
||||
from google.oauth2 import credentials
|
||||
|
||||
source_credentials = credentials.Credentials.from_authorized_user_info(
|
||||
source_credentials_info
|
||||
)
|
||||
elif source_credentials_type == _SOURCE_CREDENTIAL_SERVICE_ACCOUNT_TYPE:
|
||||
from google.oauth2 import service_account
|
||||
|
||||
source_credentials = service_account.Credentials.from_service_account_info(
|
||||
source_credentials_info
|
||||
)
|
||||
elif (
|
||||
source_credentials_type
|
||||
== _SOURCE_CREDENTIAL_EXTERNAL_ACCOUNT_AUTHORIZED_USER_TYPE
|
||||
):
|
||||
from google.auth import external_account_authorized_user
|
||||
|
||||
source_credentials = external_account_authorized_user.Credentials.from_info(
|
||||
source_credentials_info
|
||||
)
|
||||
else:
|
||||
raise exceptions.InvalidType(
|
||||
"source credential of type {} is not supported.".format(
|
||||
source_credentials_type
|
||||
)
|
||||
)
|
||||
|
||||
impersonation_url = info.get("service_account_impersonation_url")
|
||||
start_index = impersonation_url.rfind("/")
|
||||
end_index = impersonation_url.find(":generateAccessToken")
|
||||
if start_index == -1 or end_index == -1 or start_index > end_index:
|
||||
raise exceptions.InvalidValue(
|
||||
"Cannot extract target principal from {}".format(impersonation_url)
|
||||
)
|
||||
target_principal = impersonation_url[start_index + 1 : end_index]
|
||||
delegates = info.get("delegates")
|
||||
quota_project_id = info.get("quota_project_id")
|
||||
scopes = scopes or info.get("scopes")
|
||||
trust_boundary = info.get("trust_boundary")
|
||||
|
||||
return cls(
|
||||
source_credentials,
|
||||
target_principal,
|
||||
scopes,
|
||||
delegates,
|
||||
quota_project_id=quota_project_id,
|
||||
trust_boundary=trust_boundary,
|
||||
)
|
||||
|
||||
|
||||
class IDTokenCredentials(credentials.CredentialsWithQuotaProject):
|
||||
"""Open ID Connect ID Token-based service account credentials."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
target_credentials,
|
||||
target_audience=None,
|
||||
include_email=False,
|
||||
quota_project_id=None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
target_credentials (google.auth.Credentials): The target
|
||||
credential used as to acquire the id tokens for.
|
||||
target_audience (string): Audience to issue the token for.
|
||||
include_email (bool): Include email in IdToken
|
||||
quota_project_id (Optional[str]): The project ID used for
|
||||
quota and billing.
|
||||
"""
|
||||
super(IDTokenCredentials, self).__init__()
|
||||
|
||||
if not isinstance(target_credentials, Credentials):
|
||||
raise exceptions.GoogleAuthError(
|
||||
"Provided Credential must be " "impersonated_credentials"
|
||||
)
|
||||
self._target_credentials = target_credentials
|
||||
self._target_audience = target_audience
|
||||
self._include_email = include_email
|
||||
self._quota_project_id = quota_project_id
|
||||
|
||||
def from_credentials(self, target_credentials, target_audience=None):
|
||||
return self.__class__(
|
||||
target_credentials=target_credentials,
|
||||
target_audience=target_audience,
|
||||
include_email=self._include_email,
|
||||
quota_project_id=self._quota_project_id,
|
||||
)
|
||||
|
||||
def with_target_audience(self, target_audience):
|
||||
return self.__class__(
|
||||
target_credentials=self._target_credentials,
|
||||
target_audience=target_audience,
|
||||
include_email=self._include_email,
|
||||
quota_project_id=self._quota_project_id,
|
||||
)
|
||||
|
||||
def with_include_email(self, include_email):
|
||||
return self.__class__(
|
||||
target_credentials=self._target_credentials,
|
||||
target_audience=self._target_audience,
|
||||
include_email=include_email,
|
||||
quota_project_id=self._quota_project_id,
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
|
||||
def with_quota_project(self, quota_project_id):
|
||||
return self.__class__(
|
||||
target_credentials=self._target_credentials,
|
||||
target_audience=self._target_audience,
|
||||
include_email=self._include_email,
|
||||
quota_project_id=quota_project_id,
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(credentials.Credentials)
|
||||
def refresh(self, request):
|
||||
from google.auth.transport.requests import AuthorizedSession
|
||||
|
||||
iam_sign_endpoint = iam._IAM_IDTOKEN_ENDPOINT.replace(
|
||||
credentials.DEFAULT_UNIVERSE_DOMAIN,
|
||||
self._target_credentials.universe_domain,
|
||||
).format(self._target_credentials.signer_email)
|
||||
|
||||
body = {
|
||||
"audience": self._target_audience,
|
||||
"delegates": self._target_credentials._delegates,
|
||||
"includeEmail": self._include_email,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
metrics.API_CLIENT_HEADER: metrics.token_request_id_token_impersonate(),
|
||||
}
|
||||
|
||||
authed_session = AuthorizedSession(
|
||||
self._target_credentials._source_credentials, auth_request=request
|
||||
)
|
||||
|
||||
try:
|
||||
response = authed_session.post(
|
||||
url=iam_sign_endpoint,
|
||||
headers=headers,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
)
|
||||
finally:
|
||||
authed_session.close()
|
||||
|
||||
if response.status_code != http_client.OK:
|
||||
raise exceptions.RefreshError(
|
||||
"Error getting ID token: {}".format(response.json())
|
||||
)
|
||||
|
||||
try:
|
||||
id_token = response.json()["token"]
|
||||
except (KeyError, ValueError) as caught_exc:
|
||||
new_exc = exceptions.RefreshError(
|
||||
"No ID token in response.", response.json()
|
||||
)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
self.token = id_token
|
||||
self.expiry = _helpers.utcfromtimestamp(
|
||||
jwt.decode(id_token, verify=False)["exp"]
|
||||
)
|
||||
|
||||
|
||||
def _sign_jwt_request(request, principal, headers, payload, delegates=[]):
|
||||
"""Makes a request to the Google Cloud IAM service to sign a JWT using a
|
||||
service account's system-managed private key.
|
||||
Args:
|
||||
request (Request): The Request object to use.
|
||||
principal (str): The principal to request an access token for.
|
||||
headers (Mapping[str, str]): Map of headers to transmit.
|
||||
payload (Mapping[str, str]): The JWT payload to sign. Must be a
|
||||
serialized JSON object that contains a JWT Claims Set.
|
||||
delegates (Sequence[str]): The chained list of delegates required
|
||||
to grant the final access_token. If set, the sequence of
|
||||
identities must have "Service Account Token Creator" capability
|
||||
granted to the prceeding identity. For example, if set to
|
||||
[serviceAccountB, serviceAccountC], the source_credential
|
||||
must have the Token Creator role on serviceAccountB.
|
||||
serviceAccountB must have the Token Creator on
|
||||
serviceAccountC.
|
||||
Finally, C must have Token Creator on target_principal.
|
||||
If left unset, source_credential must have that role on
|
||||
target_principal.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: Raised if there is an underlying
|
||||
HTTP connection error
|
||||
google.auth.exceptions.RefreshError: Raised if the impersonated
|
||||
credentials are not available. Common reasons are
|
||||
`iamcredentials.googleapis.com` is not enabled or the
|
||||
`Service Account Token Creator` is not assigned
|
||||
"""
|
||||
iam_endpoint = iam._IAM_SIGNJWT_ENDPOINT.format(principal)
|
||||
|
||||
body = {"delegates": delegates, "payload": json.dumps(payload)}
|
||||
body = json.dumps(body).encode("utf-8")
|
||||
|
||||
response = request(url=iam_endpoint, method="POST", headers=headers, body=body)
|
||||
|
||||
# support both string and bytes type response.data
|
||||
response_body = (
|
||||
response.data.decode("utf-8")
|
||||
if hasattr(response.data, "decode")
|
||||
else response.data
|
||||
)
|
||||
|
||||
if response.status != http_client.OK:
|
||||
raise exceptions.RefreshError(_REFRESH_ERROR, response_body)
|
||||
|
||||
try:
|
||||
jwt_response = json.loads(response_body)
|
||||
signed_jwt = jwt_response["signedJwt"]
|
||||
return signed_jwt
|
||||
|
||||
except (KeyError, ValueError) as caught_exc:
|
||||
new_exc = exceptions.RefreshError(
|
||||
"{}: No signed JWT in response.".format(_REFRESH_ERROR), response_body
|
||||
)
|
||||
raise new_exc from caught_exc
|
||||
877
venv/lib/python3.12/site-packages/google/auth/jwt.py
Normal file
877
venv/lib/python3.12/site-packages/google/auth/jwt.py
Normal file
@@ -0,0 +1,877 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""JSON Web Tokens
|
||||
|
||||
Provides support for creating (encoding) and verifying (decoding) JWTs,
|
||||
especially JWTs generated and consumed by Google infrastructure.
|
||||
|
||||
See `rfc7519`_ for more details on JWTs.
|
||||
|
||||
To encode a JWT use :func:`encode`::
|
||||
|
||||
from google.auth import crypt
|
||||
from google.auth import jwt
|
||||
|
||||
signer = crypt.Signer(private_key)
|
||||
payload = {'some': 'payload'}
|
||||
encoded = jwt.encode(signer, payload)
|
||||
|
||||
To decode a JWT and verify claims use :func:`decode`::
|
||||
|
||||
claims = jwt.decode(encoded, certs=public_certs)
|
||||
|
||||
You can also skip verification::
|
||||
|
||||
claims = jwt.decode(encoded, verify=False)
|
||||
|
||||
.. _rfc7519: https://tools.ietf.org/html/rfc7519
|
||||
|
||||
"""
|
||||
|
||||
try:
|
||||
from collections.abc import Mapping
|
||||
# Python 2.7 compatibility
|
||||
except ImportError: # pragma: NO COVER
|
||||
from collections import Mapping # type: ignore
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
import urllib
|
||||
|
||||
from google.auth import _cache
|
||||
from google.auth import _helpers
|
||||
from google.auth import _service_account_info
|
||||
from google.auth import crypt
|
||||
from google.auth import exceptions
|
||||
import google.auth.credentials
|
||||
|
||||
try:
|
||||
from google.auth.crypt import es
|
||||
except ImportError: # pragma: NO COVER
|
||||
es = None # type: ignore
|
||||
|
||||
_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds
|
||||
_DEFAULT_MAX_CACHE_SIZE = 10
|
||||
_ALGORITHM_TO_VERIFIER_CLASS = {"RS256": crypt.RSAVerifier}
|
||||
_CRYPTOGRAPHY_BASED_ALGORITHMS = frozenset(["ES256", "ES384"])
|
||||
|
||||
if es is not None: # pragma: NO COVER
|
||||
_ALGORITHM_TO_VERIFIER_CLASS["ES256"] = es.EsVerifier # type: ignore
|
||||
_ALGORITHM_TO_VERIFIER_CLASS["ES384"] = es.EsVerifier # type: ignore
|
||||
|
||||
|
||||
def encode(signer, payload, header=None, key_id=None):
|
||||
"""Make a signed JWT.
|
||||
|
||||
Args:
|
||||
signer (google.auth.crypt.Signer): The signer used to sign the JWT.
|
||||
payload (Mapping[str, str]): The JWT payload.
|
||||
header (Mapping[str, str]): Additional JWT header payload.
|
||||
key_id (str): The key id to add to the JWT header. If the
|
||||
signer has a key id it will be used as the default. If this is
|
||||
specified it will override the signer's key id.
|
||||
|
||||
Returns:
|
||||
bytes: The encoded JWT.
|
||||
"""
|
||||
if header is None:
|
||||
header = {}
|
||||
|
||||
if key_id is None:
|
||||
key_id = signer.key_id
|
||||
|
||||
header.update({"typ": "JWT"})
|
||||
|
||||
if "alg" not in header:
|
||||
if es is not None and isinstance(signer, es.EsSigner):
|
||||
header.update({"alg": signer.algorithm})
|
||||
else:
|
||||
header.update({"alg": "RS256"})
|
||||
|
||||
if key_id is not None:
|
||||
header["kid"] = key_id
|
||||
|
||||
segments = [
|
||||
_helpers.unpadded_urlsafe_b64encode(json.dumps(header).encode("utf-8")),
|
||||
_helpers.unpadded_urlsafe_b64encode(json.dumps(payload).encode("utf-8")),
|
||||
]
|
||||
|
||||
signing_input = b".".join(segments)
|
||||
signature = signer.sign(signing_input)
|
||||
segments.append(_helpers.unpadded_urlsafe_b64encode(signature))
|
||||
|
||||
return b".".join(segments)
|
||||
|
||||
|
||||
def _decode_jwt_segment(encoded_section):
|
||||
"""Decodes a single JWT segment."""
|
||||
section_bytes = _helpers.padded_urlsafe_b64decode(encoded_section)
|
||||
try:
|
||||
return json.loads(section_bytes.decode("utf-8"))
|
||||
except ValueError as caught_exc:
|
||||
new_exc = exceptions.MalformedError(
|
||||
"Can't parse segment: {0}".format(section_bytes)
|
||||
)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
|
||||
def _unverified_decode(token):
|
||||
"""Decodes a token and does no verification.
|
||||
|
||||
Args:
|
||||
token (Union[str, bytes]): The encoded JWT.
|
||||
|
||||
Returns:
|
||||
Tuple[Mapping, Mapping, str, str]: header, payload, signed_section, and
|
||||
signature.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.MalformedError: if there are an incorrect amount of segments in the token or segments of the wrong type.
|
||||
"""
|
||||
token = _helpers.to_bytes(token)
|
||||
|
||||
if token.count(b".") != 2:
|
||||
raise exceptions.MalformedError(
|
||||
"Wrong number of segments in token: {0}".format(token)
|
||||
)
|
||||
|
||||
encoded_header, encoded_payload, signature = token.split(b".")
|
||||
signed_section = encoded_header + b"." + encoded_payload
|
||||
signature = _helpers.padded_urlsafe_b64decode(signature)
|
||||
|
||||
# Parse segments
|
||||
header = _decode_jwt_segment(encoded_header)
|
||||
payload = _decode_jwt_segment(encoded_payload)
|
||||
|
||||
if not isinstance(header, Mapping):
|
||||
raise exceptions.MalformedError(
|
||||
"Header segment should be a JSON object: {0}".format(encoded_header)
|
||||
)
|
||||
|
||||
if not isinstance(payload, Mapping):
|
||||
raise exceptions.MalformedError(
|
||||
"Payload segment should be a JSON object: {0}".format(encoded_payload)
|
||||
)
|
||||
|
||||
return header, payload, signed_section, signature
|
||||
|
||||
|
||||
def decode_header(token):
|
||||
"""Return the decoded header of a token.
|
||||
|
||||
No verification is done. This is useful to extract the key id from
|
||||
the header in order to acquire the appropriate certificate to verify
|
||||
the token.
|
||||
|
||||
Args:
|
||||
token (Union[str, bytes]): the encoded JWT.
|
||||
|
||||
Returns:
|
||||
Mapping: The decoded JWT header.
|
||||
"""
|
||||
header, _, _, _ = _unverified_decode(token)
|
||||
return header
|
||||
|
||||
|
||||
def _verify_iat_and_exp(payload, clock_skew_in_seconds=0):
|
||||
"""Verifies the ``iat`` (Issued At) and ``exp`` (Expires) claims in a token
|
||||
payload.
|
||||
|
||||
Args:
|
||||
payload (Mapping[str, str]): The JWT payload.
|
||||
clock_skew_in_seconds (int): The clock skew used for `iat` and `exp`
|
||||
validation.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidValue: if value validation failed.
|
||||
google.auth.exceptions.MalformedError: if schema validation failed.
|
||||
"""
|
||||
now = _helpers.datetime_to_secs(_helpers.utcnow())
|
||||
|
||||
# Make sure the iat and exp claims are present.
|
||||
for key in ("iat", "exp"):
|
||||
if key not in payload:
|
||||
raise exceptions.MalformedError(
|
||||
"Token does not contain required claim {}".format(key)
|
||||
)
|
||||
|
||||
# Make sure the token wasn't issued in the future.
|
||||
iat = payload["iat"]
|
||||
# Err on the side of accepting a token that is slightly early to account
|
||||
# for clock skew.
|
||||
earliest = iat - clock_skew_in_seconds
|
||||
if now < earliest:
|
||||
raise exceptions.InvalidValue(
|
||||
"Token used too early, {} < {}. Check that your computer's clock is set correctly.".format(
|
||||
now, iat
|
||||
)
|
||||
)
|
||||
|
||||
# Make sure the token wasn't issued in the past.
|
||||
exp = payload["exp"]
|
||||
# Err on the side of accepting a token that is slightly out of date
|
||||
# to account for clow skew.
|
||||
latest = exp + clock_skew_in_seconds
|
||||
if latest < now:
|
||||
raise exceptions.InvalidValue("Token expired, {} < {}".format(latest, now))
|
||||
|
||||
|
||||
def decode(token, certs=None, verify=True, audience=None, clock_skew_in_seconds=0):
|
||||
"""Decode and verify a JWT.
|
||||
|
||||
Args:
|
||||
token (str): The encoded JWT.
|
||||
certs (Union[str, bytes, Mapping[str, Union[str, bytes]]]): The
|
||||
certificate used to validate the JWT signature. If bytes or string,
|
||||
it must the the public key certificate in PEM format. If a mapping,
|
||||
it must be a mapping of key IDs to public key certificates in PEM
|
||||
format. The mapping must contain the same key ID that's specified
|
||||
in the token's header.
|
||||
verify (bool): Whether to perform signature and claim validation.
|
||||
Verification is done by default.
|
||||
audience (str or list): The audience claim, 'aud', that this JWT should
|
||||
contain. Or a list of audience claims. If None then the JWT's 'aud'
|
||||
parameter is not verified.
|
||||
clock_skew_in_seconds (int): The clock skew used for `iat` and `exp`
|
||||
validation.
|
||||
|
||||
Returns:
|
||||
Mapping[str, str]: The deserialized JSON payload in the JWT.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidValue: if value validation failed.
|
||||
google.auth.exceptions.MalformedError: if schema validation failed.
|
||||
"""
|
||||
header, payload, signed_section, signature = _unverified_decode(token)
|
||||
|
||||
if not verify:
|
||||
return payload
|
||||
|
||||
# Pluck the key id and algorithm from the header and make sure we have
|
||||
# a verifier that can support it.
|
||||
key_alg = header.get("alg")
|
||||
key_id = header.get("kid")
|
||||
|
||||
try:
|
||||
verifier_cls = _ALGORITHM_TO_VERIFIER_CLASS[key_alg]
|
||||
except KeyError as exc:
|
||||
if key_alg in _CRYPTOGRAPHY_BASED_ALGORITHMS:
|
||||
raise exceptions.InvalidValue(
|
||||
"The key algorithm {} requires the cryptography package to be installed.".format(
|
||||
key_alg
|
||||
)
|
||||
) from exc
|
||||
else:
|
||||
raise exceptions.InvalidValue(
|
||||
"Unsupported signature algorithm {}".format(key_alg)
|
||||
) from exc
|
||||
# If certs is specified as a dictionary of key IDs to certificates, then
|
||||
# use the certificate identified by the key ID in the token header.
|
||||
if isinstance(certs, Mapping):
|
||||
if key_id:
|
||||
if key_id not in certs:
|
||||
raise exceptions.MalformedError(
|
||||
"Certificate for key id {} not found.".format(key_id)
|
||||
)
|
||||
certs_to_check = [certs[key_id]]
|
||||
# If there's no key id in the header, check against all of the certs.
|
||||
else:
|
||||
certs_to_check = certs.values()
|
||||
else:
|
||||
certs_to_check = certs
|
||||
|
||||
# Verify that the signature matches the message.
|
||||
if not crypt.verify_signature(
|
||||
signed_section, signature, certs_to_check, verifier_cls
|
||||
):
|
||||
raise exceptions.MalformedError("Could not verify token signature.")
|
||||
|
||||
# Verify the issued at and created times in the payload.
|
||||
_verify_iat_and_exp(payload, clock_skew_in_seconds)
|
||||
|
||||
# Check audience.
|
||||
if audience is not None:
|
||||
claim_audience = payload.get("aud")
|
||||
if isinstance(audience, str):
|
||||
audience = [audience]
|
||||
if claim_audience not in audience:
|
||||
raise exceptions.InvalidValue(
|
||||
"Token has wrong audience {}, expected one of {}".format(
|
||||
claim_audience, audience
|
||||
)
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
class Credentials(
|
||||
google.auth.credentials.Signing, google.auth.credentials.CredentialsWithQuotaProject
|
||||
):
|
||||
"""Credentials that use a JWT as the bearer token.
|
||||
|
||||
These credentials require an "audience" claim. This claim identifies the
|
||||
intended recipient of the bearer token.
|
||||
|
||||
The constructor arguments determine the claims for the JWT that is
|
||||
sent with requests. Usually, you'll construct these credentials with
|
||||
one of the helper constructors as shown in the next section.
|
||||
|
||||
To create JWT credentials using a Google service account private key
|
||||
JSON file::
|
||||
|
||||
audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Publisher'
|
||||
credentials = jwt.Credentials.from_service_account_file(
|
||||
'service-account.json',
|
||||
audience=audience)
|
||||
|
||||
If you already have the service account file loaded and parsed::
|
||||
|
||||
service_account_info = json.load(open('service_account.json'))
|
||||
credentials = jwt.Credentials.from_service_account_info(
|
||||
service_account_info,
|
||||
audience=audience)
|
||||
|
||||
Both helper methods pass on arguments to the constructor, so you can
|
||||
specify the JWT claims::
|
||||
|
||||
credentials = jwt.Credentials.from_service_account_file(
|
||||
'service-account.json',
|
||||
audience=audience,
|
||||
additional_claims={'meta': 'data'})
|
||||
|
||||
You can also construct the credentials directly if you have a
|
||||
:class:`~google.auth.crypt.Signer` instance::
|
||||
|
||||
credentials = jwt.Credentials(
|
||||
signer,
|
||||
issuer='your-issuer',
|
||||
subject='your-subject',
|
||||
audience=audience)
|
||||
|
||||
The claims are considered immutable. If you want to modify the claims,
|
||||
you can easily create another instance using :meth:`with_claims`::
|
||||
|
||||
new_audience = (
|
||||
'https://pubsub.googleapis.com/google.pubsub.v1.Subscriber')
|
||||
new_credentials = credentials.with_claims(audience=new_audience)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
signer,
|
||||
issuer,
|
||||
subject,
|
||||
audience,
|
||||
additional_claims=None,
|
||||
token_lifetime=_DEFAULT_TOKEN_LIFETIME_SECS,
|
||||
quota_project_id=None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
|
||||
issuer (str): The `iss` claim.
|
||||
subject (str): The `sub` claim.
|
||||
audience (str): the `aud` claim. The intended audience for the
|
||||
credentials.
|
||||
additional_claims (Mapping[str, str]): Any additional claims for
|
||||
the JWT payload.
|
||||
token_lifetime (int): The amount of time in seconds for
|
||||
which the token is valid. Defaults to 1 hour.
|
||||
quota_project_id (Optional[str]): The project ID used for quota
|
||||
and billing.
|
||||
"""
|
||||
super(Credentials, self).__init__()
|
||||
self._signer = signer
|
||||
self._issuer = issuer
|
||||
self._subject = subject
|
||||
self._audience = audience
|
||||
self._token_lifetime = token_lifetime
|
||||
self._quota_project_id = quota_project_id
|
||||
|
||||
if additional_claims is None:
|
||||
additional_claims = {}
|
||||
|
||||
self._additional_claims = additional_claims
|
||||
|
||||
@classmethod
|
||||
def _from_signer_and_info(cls, signer, info, **kwargs):
|
||||
"""Creates a Credentials instance from a signer and service account
|
||||
info.
|
||||
|
||||
Args:
|
||||
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
|
||||
info (Mapping[str, str]): The service account info.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.jwt.Credentials: The constructed credentials.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.MalformedError: If the info is not in the expected format.
|
||||
"""
|
||||
kwargs.setdefault("subject", info["client_email"])
|
||||
kwargs.setdefault("issuer", info["client_email"])
|
||||
return cls(signer, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_service_account_info(cls, info, **kwargs):
|
||||
"""Creates an Credentials instance from a dictionary.
|
||||
|
||||
Args:
|
||||
info (Mapping[str, str]): The service account info in Google
|
||||
format.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.jwt.Credentials: The constructed credentials.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.MalformedError: If the info is not in the expected format.
|
||||
"""
|
||||
signer = _service_account_info.from_dict(info, require=["client_email"])
|
||||
return cls._from_signer_and_info(signer, info, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_service_account_file(cls, filename, **kwargs):
|
||||
"""Creates a Credentials instance from a service account .json file
|
||||
in Google format.
|
||||
|
||||
Args:
|
||||
filename (str): The path to the service account .json file.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.jwt.Credentials: The constructed credentials.
|
||||
"""
|
||||
info, signer = _service_account_info.from_filename(
|
||||
filename, require=["client_email"]
|
||||
)
|
||||
return cls._from_signer_and_info(signer, info, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_signing_credentials(cls, credentials, audience, **kwargs):
|
||||
"""Creates a new :class:`google.auth.jwt.Credentials` instance from an
|
||||
existing :class:`google.auth.credentials.Signing` instance.
|
||||
|
||||
The new instance will use the same signer as the existing instance and
|
||||
will use the existing instance's signer email as the issuer and
|
||||
subject by default.
|
||||
|
||||
Example::
|
||||
|
||||
svc_creds = service_account.Credentials.from_service_account_file(
|
||||
'service_account.json')
|
||||
audience = (
|
||||
'https://pubsub.googleapis.com/google.pubsub.v1.Publisher')
|
||||
jwt_creds = jwt.Credentials.from_signing_credentials(
|
||||
svc_creds, audience=audience)
|
||||
|
||||
Args:
|
||||
credentials (google.auth.credentials.Signing): The credentials to
|
||||
use to construct the new credentials.
|
||||
audience (str): the `aud` claim. The intended audience for the
|
||||
credentials.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.jwt.Credentials: A new Credentials instance.
|
||||
"""
|
||||
kwargs.setdefault("issuer", credentials.signer_email)
|
||||
kwargs.setdefault("subject", credentials.signer_email)
|
||||
return cls(credentials.signer, audience=audience, **kwargs)
|
||||
|
||||
def with_claims(
|
||||
self, issuer=None, subject=None, audience=None, additional_claims=None
|
||||
):
|
||||
"""Returns a copy of these credentials with modified claims.
|
||||
|
||||
Args:
|
||||
issuer (str): The `iss` claim. If unspecified the current issuer
|
||||
claim will be used.
|
||||
subject (str): The `sub` claim. If unspecified the current subject
|
||||
claim will be used.
|
||||
audience (str): the `aud` claim. If unspecified the current
|
||||
audience claim will be used.
|
||||
additional_claims (Mapping[str, str]): Any additional claims for
|
||||
the JWT payload. This will be merged with the current
|
||||
additional claims.
|
||||
|
||||
Returns:
|
||||
google.auth.jwt.Credentials: A new credentials instance.
|
||||
"""
|
||||
new_additional_claims = copy.deepcopy(self._additional_claims)
|
||||
new_additional_claims.update(additional_claims or {})
|
||||
|
||||
return self.__class__(
|
||||
self._signer,
|
||||
issuer=issuer if issuer is not None else self._issuer,
|
||||
subject=subject if subject is not None else self._subject,
|
||||
audience=audience if audience is not None else self._audience,
|
||||
additional_claims=new_additional_claims,
|
||||
quota_project_id=self._quota_project_id,
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(google.auth.credentials.CredentialsWithQuotaProject)
|
||||
def with_quota_project(self, quota_project_id):
|
||||
return self.__class__(
|
||||
self._signer,
|
||||
issuer=self._issuer,
|
||||
subject=self._subject,
|
||||
audience=self._audience,
|
||||
additional_claims=self._additional_claims,
|
||||
quota_project_id=quota_project_id,
|
||||
)
|
||||
|
||||
def _make_jwt(self):
|
||||
"""Make a signed JWT.
|
||||
|
||||
Returns:
|
||||
Tuple[bytes, datetime]: The encoded JWT and the expiration.
|
||||
"""
|
||||
now = _helpers.utcnow()
|
||||
lifetime = datetime.timedelta(seconds=self._token_lifetime)
|
||||
expiry = now + lifetime
|
||||
|
||||
payload = {
|
||||
"iss": self._issuer,
|
||||
"sub": self._subject,
|
||||
"iat": _helpers.datetime_to_secs(now),
|
||||
"exp": _helpers.datetime_to_secs(expiry),
|
||||
}
|
||||
if self._audience:
|
||||
payload["aud"] = self._audience
|
||||
|
||||
payload.update(self._additional_claims)
|
||||
|
||||
jwt = encode(self._signer, payload)
|
||||
|
||||
return jwt, expiry
|
||||
|
||||
def refresh(self, request):
|
||||
"""Refreshes the access token.
|
||||
|
||||
Args:
|
||||
request (Any): Unused.
|
||||
"""
|
||||
# pylint: disable=unused-argument
|
||||
# (pylint doesn't correctly recognize overridden methods.)
|
||||
self.token, self.expiry = self._make_jwt()
|
||||
|
||||
@_helpers.copy_docstring(google.auth.credentials.Signing)
|
||||
def sign_bytes(self, message):
|
||||
return self._signer.sign(message)
|
||||
|
||||
@property # type: ignore
|
||||
@_helpers.copy_docstring(google.auth.credentials.Signing)
|
||||
def signer_email(self):
|
||||
return self._issuer
|
||||
|
||||
@property # type: ignore
|
||||
@_helpers.copy_docstring(google.auth.credentials.Signing)
|
||||
def signer(self):
|
||||
return self._signer
|
||||
|
||||
@property # type: ignore
|
||||
def additional_claims(self):
|
||||
"""Additional claims the JWT object was created with."""
|
||||
return self._additional_claims
|
||||
|
||||
|
||||
class OnDemandCredentials(
|
||||
google.auth.credentials.Signing, google.auth.credentials.CredentialsWithQuotaProject
|
||||
):
|
||||
"""On-demand JWT credentials.
|
||||
|
||||
Like :class:`Credentials`, this class uses a JWT as the bearer token for
|
||||
authentication. However, this class does not require the audience at
|
||||
construction time. Instead, it will generate a new token on-demand for
|
||||
each request using the request URI as the audience. It caches tokens
|
||||
so that multiple requests to the same URI do not incur the overhead
|
||||
of generating a new token every time.
|
||||
|
||||
This behavior is especially useful for `gRPC`_ clients. A gRPC service may
|
||||
have multiple audience and gRPC clients may not know all of the audiences
|
||||
required for accessing a particular service. With these credentials,
|
||||
no knowledge of the audiences is required ahead of time.
|
||||
|
||||
.. _grpc: http://www.grpc.io/
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
signer,
|
||||
issuer,
|
||||
subject,
|
||||
additional_claims=None,
|
||||
token_lifetime=_DEFAULT_TOKEN_LIFETIME_SECS,
|
||||
max_cache_size=_DEFAULT_MAX_CACHE_SIZE,
|
||||
quota_project_id=None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
|
||||
issuer (str): The `iss` claim.
|
||||
subject (str): The `sub` claim.
|
||||
additional_claims (Mapping[str, str]): Any additional claims for
|
||||
the JWT payload.
|
||||
token_lifetime (int): The amount of time in seconds for
|
||||
which the token is valid. Defaults to 1 hour.
|
||||
max_cache_size (int): The maximum number of JWT tokens to keep in
|
||||
cache. Tokens are cached using :class:`google.auth._cache.LRUCache`.
|
||||
quota_project_id (Optional[str]): The project ID used for quota
|
||||
and billing.
|
||||
|
||||
"""
|
||||
super(OnDemandCredentials, self).__init__()
|
||||
self._signer = signer
|
||||
self._issuer = issuer
|
||||
self._subject = subject
|
||||
self._token_lifetime = token_lifetime
|
||||
self._quota_project_id = quota_project_id
|
||||
|
||||
if additional_claims is None:
|
||||
additional_claims = {}
|
||||
|
||||
self._additional_claims = additional_claims
|
||||
self._cache = _cache.LRUCache(maxsize=max_cache_size)
|
||||
|
||||
@classmethod
|
||||
def _from_signer_and_info(cls, signer, info, **kwargs):
|
||||
"""Creates an OnDemandCredentials instance from a signer and service
|
||||
account info.
|
||||
|
||||
Args:
|
||||
signer (google.auth.crypt.Signer): The signer used to sign JWTs.
|
||||
info (Mapping[str, str]): The service account info.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.jwt.OnDemandCredentials: The constructed credentials.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.MalformedError: If the info is not in the expected format.
|
||||
"""
|
||||
kwargs.setdefault("subject", info["client_email"])
|
||||
kwargs.setdefault("issuer", info["client_email"])
|
||||
return cls(signer, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_service_account_info(cls, info, **kwargs):
|
||||
"""Creates an OnDemandCredentials instance from a dictionary.
|
||||
|
||||
Args:
|
||||
info (Mapping[str, str]): The service account info in Google
|
||||
format.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.jwt.OnDemandCredentials: The constructed credentials.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.MalformedError: If the info is not in the expected format.
|
||||
"""
|
||||
signer = _service_account_info.from_dict(info, require=["client_email"])
|
||||
return cls._from_signer_and_info(signer, info, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_service_account_file(cls, filename, **kwargs):
|
||||
"""Creates an OnDemandCredentials instance from a service account .json
|
||||
file in Google format.
|
||||
|
||||
Args:
|
||||
filename (str): The path to the service account .json file.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.jwt.OnDemandCredentials: The constructed credentials.
|
||||
"""
|
||||
info, signer = _service_account_info.from_filename(
|
||||
filename, require=["client_email"]
|
||||
)
|
||||
return cls._from_signer_and_info(signer, info, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_signing_credentials(cls, credentials, **kwargs):
|
||||
"""Creates a new :class:`google.auth.jwt.OnDemandCredentials` instance
|
||||
from an existing :class:`google.auth.credentials.Signing` instance.
|
||||
|
||||
The new instance will use the same signer as the existing instance and
|
||||
will use the existing instance's signer email as the issuer and
|
||||
subject by default.
|
||||
|
||||
Example::
|
||||
|
||||
svc_creds = service_account.Credentials.from_service_account_file(
|
||||
'service_account.json')
|
||||
jwt_creds = jwt.OnDemandCredentials.from_signing_credentials(
|
||||
svc_creds)
|
||||
|
||||
Args:
|
||||
credentials (google.auth.credentials.Signing): The credentials to
|
||||
use to construct the new credentials.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.jwt.Credentials: A new Credentials instance.
|
||||
"""
|
||||
kwargs.setdefault("issuer", credentials.signer_email)
|
||||
kwargs.setdefault("subject", credentials.signer_email)
|
||||
return cls(credentials.signer, **kwargs)
|
||||
|
||||
def with_claims(self, issuer=None, subject=None, additional_claims=None):
|
||||
"""Returns a copy of these credentials with modified claims.
|
||||
|
||||
Args:
|
||||
issuer (str): The `iss` claim. If unspecified the current issuer
|
||||
claim will be used.
|
||||
subject (str): The `sub` claim. If unspecified the current subject
|
||||
claim will be used.
|
||||
additional_claims (Mapping[str, str]): Any additional claims for
|
||||
the JWT payload. This will be merged with the current
|
||||
additional claims.
|
||||
|
||||
Returns:
|
||||
google.auth.jwt.OnDemandCredentials: A new credentials instance.
|
||||
"""
|
||||
new_additional_claims = copy.deepcopy(self._additional_claims)
|
||||
new_additional_claims.update(additional_claims or {})
|
||||
|
||||
return self.__class__(
|
||||
self._signer,
|
||||
issuer=issuer if issuer is not None else self._issuer,
|
||||
subject=subject if subject is not None else self._subject,
|
||||
additional_claims=new_additional_claims,
|
||||
max_cache_size=self._cache.maxsize,
|
||||
quota_project_id=self._quota_project_id,
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(google.auth.credentials.CredentialsWithQuotaProject)
|
||||
def with_quota_project(self, quota_project_id):
|
||||
return self.__class__(
|
||||
self._signer,
|
||||
issuer=self._issuer,
|
||||
subject=self._subject,
|
||||
additional_claims=self._additional_claims,
|
||||
max_cache_size=self._cache.maxsize,
|
||||
quota_project_id=quota_project_id,
|
||||
)
|
||||
|
||||
@property
|
||||
def valid(self):
|
||||
"""Checks the validity of the credentials.
|
||||
|
||||
These credentials are always valid because it generates tokens on
|
||||
demand.
|
||||
"""
|
||||
return True
|
||||
|
||||
def _make_jwt_for_audience(self, audience):
|
||||
"""Make a new JWT for the given audience.
|
||||
|
||||
Args:
|
||||
audience (str): The intended audience.
|
||||
|
||||
Returns:
|
||||
Tuple[bytes, datetime]: The encoded JWT and the expiration.
|
||||
"""
|
||||
now = _helpers.utcnow()
|
||||
lifetime = datetime.timedelta(seconds=self._token_lifetime)
|
||||
expiry = now + lifetime
|
||||
|
||||
payload = {
|
||||
"iss": self._issuer,
|
||||
"sub": self._subject,
|
||||
"iat": _helpers.datetime_to_secs(now),
|
||||
"exp": _helpers.datetime_to_secs(expiry),
|
||||
"aud": audience,
|
||||
}
|
||||
|
||||
payload.update(self._additional_claims)
|
||||
|
||||
jwt = encode(self._signer, payload)
|
||||
|
||||
return jwt, expiry
|
||||
|
||||
def _get_jwt_for_audience(self, audience):
|
||||
"""Get a JWT For a given audience.
|
||||
|
||||
If there is already an existing, non-expired token in the cache for
|
||||
the audience, that token is used. Otherwise, a new token will be
|
||||
created.
|
||||
|
||||
Args:
|
||||
audience (str): The intended audience.
|
||||
|
||||
Returns:
|
||||
bytes: The encoded JWT.
|
||||
"""
|
||||
token, expiry = self._cache.get(audience, (None, None))
|
||||
|
||||
if token is None or expiry < _helpers.utcnow():
|
||||
token, expiry = self._make_jwt_for_audience(audience)
|
||||
self._cache[audience] = token, expiry
|
||||
|
||||
return token
|
||||
|
||||
def refresh(self, request):
|
||||
"""Raises an exception, these credentials can not be directly
|
||||
refreshed.
|
||||
|
||||
Args:
|
||||
request (Any): Unused.
|
||||
|
||||
Raises:
|
||||
google.auth.RefreshError
|
||||
"""
|
||||
# pylint: disable=unused-argument
|
||||
# (pylint doesn't correctly recognize overridden methods.)
|
||||
raise exceptions.RefreshError(
|
||||
"OnDemandCredentials can not be directly refreshed."
|
||||
)
|
||||
|
||||
def before_request(self, request, method, url, headers):
|
||||
"""Performs credential-specific before request logic.
|
||||
|
||||
Args:
|
||||
request (Any): Unused. JWT credentials do not need to make an
|
||||
HTTP request to refresh.
|
||||
method (str): The request's HTTP method.
|
||||
url (str): The request's URI. This is used as the audience claim
|
||||
when generating the JWT.
|
||||
headers (Mapping): The request's headers.
|
||||
"""
|
||||
# pylint: disable=unused-argument
|
||||
# (pylint doesn't correctly recognize overridden methods.)
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
# Strip query string and fragment
|
||||
audience = urllib.parse.urlunsplit(
|
||||
(parts.scheme, parts.netloc, parts.path, "", "")
|
||||
)
|
||||
token = self._get_jwt_for_audience(audience)
|
||||
self.apply(headers, token=token)
|
||||
|
||||
@_helpers.copy_docstring(google.auth.credentials.Signing)
|
||||
def sign_bytes(self, message):
|
||||
return self._signer.sign(message)
|
||||
|
||||
@property # type: ignore
|
||||
@_helpers.copy_docstring(google.auth.credentials.Signing)
|
||||
def signer_email(self):
|
||||
return self._issuer
|
||||
|
||||
@property # type: ignore
|
||||
@_helpers.copy_docstring(google.auth.credentials.Signing)
|
||||
def signer(self):
|
||||
return self._signer
|
||||
156
venv/lib/python3.12/site-packages/google/auth/metrics.py
Normal file
156
venv/lib/python3.12/site-packages/google/auth/metrics.py
Normal file
@@ -0,0 +1,156 @@
|
||||
# Copyright 2023 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.
|
||||
|
||||
""" We use x-goog-api-client header to report metrics. This module provides
|
||||
the constants and helper methods to construct x-goog-api-client header.
|
||||
"""
|
||||
|
||||
import platform
|
||||
|
||||
from google.auth import version
|
||||
|
||||
|
||||
API_CLIENT_HEADER = "x-goog-api-client"
|
||||
|
||||
# BYOID Specific consts
|
||||
BYOID_HEADER_SECTION = "google-byoid-sdk"
|
||||
|
||||
# Auth request type
|
||||
REQUEST_TYPE_ACCESS_TOKEN = "auth-request-type/at"
|
||||
REQUEST_TYPE_ID_TOKEN = "auth-request-type/it"
|
||||
REQUEST_TYPE_MDS_PING = "auth-request-type/mds"
|
||||
REQUEST_TYPE_REAUTH_START = "auth-request-type/re-start"
|
||||
REQUEST_TYPE_REAUTH_CONTINUE = "auth-request-type/re-cont"
|
||||
|
||||
# Credential type
|
||||
CRED_TYPE_USER = "cred-type/u"
|
||||
CRED_TYPE_SA_ASSERTION = "cred-type/sa"
|
||||
CRED_TYPE_SA_JWT = "cred-type/jwt"
|
||||
CRED_TYPE_SA_MDS = "cred-type/mds"
|
||||
CRED_TYPE_SA_IMPERSONATE = "cred-type/imp"
|
||||
|
||||
|
||||
# Versions
|
||||
def python_and_auth_lib_version():
|
||||
return "gl-python/{} auth/{}".format(platform.python_version(), version.__version__)
|
||||
|
||||
|
||||
# Token request metric header values
|
||||
|
||||
|
||||
# x-goog-api-client header value for access token request via metadata server.
|
||||
# Example: "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/mds"
|
||||
def token_request_access_token_mds():
|
||||
return "{} {} {}".format(
|
||||
python_and_auth_lib_version(), REQUEST_TYPE_ACCESS_TOKEN, CRED_TYPE_SA_MDS
|
||||
)
|
||||
|
||||
|
||||
# x-goog-api-client header value for ID token request via metadata server.
|
||||
# Example: "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/mds"
|
||||
def token_request_id_token_mds():
|
||||
return "{} {} {}".format(
|
||||
python_and_auth_lib_version(), REQUEST_TYPE_ID_TOKEN, CRED_TYPE_SA_MDS
|
||||
)
|
||||
|
||||
|
||||
# x-goog-api-client header value for impersonated credentials access token request.
|
||||
# Example: "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/imp"
|
||||
def token_request_access_token_impersonate():
|
||||
return "{} {} {}".format(
|
||||
python_and_auth_lib_version(),
|
||||
REQUEST_TYPE_ACCESS_TOKEN,
|
||||
CRED_TYPE_SA_IMPERSONATE,
|
||||
)
|
||||
|
||||
|
||||
# x-goog-api-client header value for impersonated credentials ID token request.
|
||||
# Example: "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/imp"
|
||||
def token_request_id_token_impersonate():
|
||||
return "{} {} {}".format(
|
||||
python_and_auth_lib_version(), REQUEST_TYPE_ID_TOKEN, CRED_TYPE_SA_IMPERSONATE
|
||||
)
|
||||
|
||||
|
||||
# x-goog-api-client header value for service account credentials access token
|
||||
# request (assertion flow).
|
||||
# Example: "gl-python/3.7 auth/1.1 auth-request-type/at cred-type/sa"
|
||||
def token_request_access_token_sa_assertion():
|
||||
return "{} {} {}".format(
|
||||
python_and_auth_lib_version(), REQUEST_TYPE_ACCESS_TOKEN, CRED_TYPE_SA_ASSERTION
|
||||
)
|
||||
|
||||
|
||||
# x-goog-api-client header value for service account credentials ID token
|
||||
# request (assertion flow).
|
||||
# Example: "gl-python/3.7 auth/1.1 auth-request-type/it cred-type/sa"
|
||||
def token_request_id_token_sa_assertion():
|
||||
return "{} {} {}".format(
|
||||
python_and_auth_lib_version(), REQUEST_TYPE_ID_TOKEN, CRED_TYPE_SA_ASSERTION
|
||||
)
|
||||
|
||||
|
||||
# x-goog-api-client header value for user credentials token request.
|
||||
# Example: "gl-python/3.7 auth/1.1 cred-type/u"
|
||||
def token_request_user():
|
||||
return "{} {}".format(python_and_auth_lib_version(), CRED_TYPE_USER)
|
||||
|
||||
|
||||
# Miscellenous metrics
|
||||
|
||||
|
||||
# x-goog-api-client header value for metadata server ping.
|
||||
# Example: "gl-python/3.7 auth/1.1 auth-request-type/mds"
|
||||
def mds_ping():
|
||||
return "{} {}".format(python_and_auth_lib_version(), REQUEST_TYPE_MDS_PING)
|
||||
|
||||
|
||||
# x-goog-api-client header value for reauth start endpoint calls.
|
||||
# Example: "gl-python/3.7 auth/1.1 auth-request-type/re-start"
|
||||
def reauth_start():
|
||||
return "{} {}".format(python_and_auth_lib_version(), REQUEST_TYPE_REAUTH_START)
|
||||
|
||||
|
||||
# x-goog-api-client header value for reauth continue endpoint calls.
|
||||
# Example: "gl-python/3.7 auth/1.1 cred-type/re-cont"
|
||||
def reauth_continue():
|
||||
return "{} {}".format(python_and_auth_lib_version(), REQUEST_TYPE_REAUTH_CONTINUE)
|
||||
|
||||
|
||||
# x-goog-api-client header value for BYOID calls to the Security Token Service exchange token endpoint.
|
||||
# Example: "gl-python/3.7 auth/1.1 google-byoid-sdk source/aws sa-impersonation/true sa-impersonation/true"
|
||||
def byoid_metrics_header(metrics_options):
|
||||
header = "{} {}".format(python_and_auth_lib_version(), BYOID_HEADER_SECTION)
|
||||
for key, value in metrics_options.items():
|
||||
header = "{} {}/{}".format(header, key, value)
|
||||
return header
|
||||
|
||||
|
||||
def add_metric_header(headers, metric_header_value):
|
||||
"""Add x-goog-api-client header with the given value.
|
||||
|
||||
Args:
|
||||
headers (Mapping[str, str]): The headers to which we will add the
|
||||
metric header.
|
||||
metric_header_value (Optional[str]): If value is None, do nothing;
|
||||
if headers already has a x-goog-api-client header, append the value
|
||||
to the existing header; otherwise add a new x-goog-api-client
|
||||
header with the given value.
|
||||
"""
|
||||
if not metric_header_value:
|
||||
return
|
||||
if API_CLIENT_HEADER not in headers:
|
||||
headers[API_CLIENT_HEADER] = metric_header_value
|
||||
else:
|
||||
headers[API_CLIENT_HEADER] += " " + metric_header_value
|
||||
445
venv/lib/python3.12/site-packages/google/auth/pluggable.py
Normal file
445
venv/lib/python3.12/site-packages/google/auth/pluggable.py
Normal file
@@ -0,0 +1,445 @@
|
||||
# Copyright 2022 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.
|
||||
|
||||
"""Pluggable Credentials.
|
||||
Pluggable Credentials are initialized using external_account arguments which
|
||||
are typically loaded from third-party executables. Unlike other
|
||||
credentials that can be initialized with a list of explicit arguments, secrets
|
||||
or credentials, external account clients use the environment and hints/guidelines
|
||||
provided by the external_account JSON file to retrieve credentials and exchange
|
||||
them for Google access tokens.
|
||||
|
||||
Example credential_source for pluggable credential:
|
||||
{
|
||||
"executable": {
|
||||
"command": "/path/to/get/credentials.sh --arg1=value1 --arg2=value2",
|
||||
"timeout_millis": 5000,
|
||||
"output_file": "/path/to/generated/cached/credentials"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
try:
|
||||
from collections.abc import Mapping
|
||||
# Python 2.7 compatibility
|
||||
except ImportError: # pragma: NO COVER
|
||||
from collections import Mapping # type: ignore
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import exceptions
|
||||
from google.auth import external_account
|
||||
|
||||
# The max supported executable spec version.
|
||||
EXECUTABLE_SUPPORTED_MAX_VERSION = 1
|
||||
|
||||
EXECUTABLE_TIMEOUT_MILLIS_DEFAULT = 30 * 1000 # 30 seconds
|
||||
EXECUTABLE_TIMEOUT_MILLIS_LOWER_BOUND = 5 * 1000 # 5 seconds
|
||||
EXECUTABLE_TIMEOUT_MILLIS_UPPER_BOUND = 120 * 1000 # 2 minutes
|
||||
|
||||
EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND = 30 * 1000 # 30 seconds
|
||||
EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_UPPER_BOUND = 30 * 60 * 1000 # 30 minutes
|
||||
|
||||
|
||||
class Credentials(external_account.Credentials):
|
||||
"""External account credentials sourced from executables.
|
||||
|
||||
**IMPORTANT**:
|
||||
This class does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
audience,
|
||||
subject_token_type,
|
||||
token_url,
|
||||
credential_source,
|
||||
*args,
|
||||
**kwargs
|
||||
):
|
||||
"""Instantiates an external account credentials object from a executables.
|
||||
|
||||
Args:
|
||||
audience (str): The STS audience field.
|
||||
subject_token_type (str): The subject token type.
|
||||
token_url (str): The STS endpoint URL.
|
||||
credential_source (Mapping): The credential source dictionary used to
|
||||
provide instructions on how to retrieve external credential to be
|
||||
exchanged for Google access tokens.
|
||||
|
||||
Example credential_source for pluggable credential:
|
||||
|
||||
{
|
||||
"executable": {
|
||||
"command": "/path/to/get/credentials.sh --arg1=value1 --arg2=value2",
|
||||
"timeout_millis": 5000,
|
||||
"output_file": "/path/to/generated/cached/credentials"
|
||||
}
|
||||
}
|
||||
args (List): Optional positional arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method.
|
||||
kwargs (Mapping): Optional keyword arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If an error is encountered during
|
||||
access token retrieval logic.
|
||||
google.auth.exceptions.InvalidValue: For invalid parameters.
|
||||
google.auth.exceptions.MalformedError: For invalid parameters.
|
||||
|
||||
.. note:: Typically one of the helper constructors
|
||||
:meth:`from_file` or
|
||||
:meth:`from_info` are used instead of calling the constructor directly.
|
||||
"""
|
||||
|
||||
self.interactive = kwargs.pop("interactive", False)
|
||||
super(Credentials, self).__init__(
|
||||
audience=audience,
|
||||
subject_token_type=subject_token_type,
|
||||
token_url=token_url,
|
||||
credential_source=credential_source,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
||||
if not isinstance(credential_source, Mapping):
|
||||
self._credential_source_executable = None
|
||||
raise exceptions.MalformedError(
|
||||
"Missing credential_source. The credential_source is not a dict."
|
||||
)
|
||||
self._credential_source_executable = credential_source.get("executable")
|
||||
if not self._credential_source_executable:
|
||||
raise exceptions.MalformedError(
|
||||
"Missing credential_source. An 'executable' must be provided."
|
||||
)
|
||||
self._credential_source_executable_command = (
|
||||
self._credential_source_executable.get("command")
|
||||
)
|
||||
self._credential_source_executable_timeout_millis = (
|
||||
self._credential_source_executable.get("timeout_millis")
|
||||
)
|
||||
self._credential_source_executable_interactive_timeout_millis = (
|
||||
self._credential_source_executable.get("interactive_timeout_millis")
|
||||
)
|
||||
self._credential_source_executable_output_file = (
|
||||
self._credential_source_executable.get("output_file")
|
||||
)
|
||||
|
||||
# Dummy value. This variable is only used via injection, not exposed to ctor
|
||||
self._tokeninfo_username = ""
|
||||
|
||||
if not self._credential_source_executable_command:
|
||||
raise exceptions.MalformedError(
|
||||
"Missing command field. Executable command must be provided."
|
||||
)
|
||||
if not self._credential_source_executable_timeout_millis:
|
||||
self._credential_source_executable_timeout_millis = (
|
||||
EXECUTABLE_TIMEOUT_MILLIS_DEFAULT
|
||||
)
|
||||
elif (
|
||||
self._credential_source_executable_timeout_millis
|
||||
< EXECUTABLE_TIMEOUT_MILLIS_LOWER_BOUND
|
||||
or self._credential_source_executable_timeout_millis
|
||||
> EXECUTABLE_TIMEOUT_MILLIS_UPPER_BOUND
|
||||
):
|
||||
raise exceptions.InvalidValue("Timeout must be between 5 and 120 seconds.")
|
||||
|
||||
if self._credential_source_executable_interactive_timeout_millis:
|
||||
if (
|
||||
self._credential_source_executable_interactive_timeout_millis
|
||||
< EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND
|
||||
or self._credential_source_executable_interactive_timeout_millis
|
||||
> EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_UPPER_BOUND
|
||||
):
|
||||
raise exceptions.InvalidValue(
|
||||
"Interactive timeout must be between 30 seconds and 30 minutes."
|
||||
)
|
||||
|
||||
@_helpers.copy_docstring(external_account.Credentials)
|
||||
def retrieve_subject_token(self, request):
|
||||
self._validate_running_mode()
|
||||
|
||||
# Check output file.
|
||||
if self._credential_source_executable_output_file is not None:
|
||||
try:
|
||||
with open(
|
||||
self._credential_source_executable_output_file, encoding="utf-8"
|
||||
) as output_file:
|
||||
response = json.load(output_file)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
# If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again.
|
||||
subject_token = self._parse_subject_token(response)
|
||||
if (
|
||||
"expiration_time" not in response
|
||||
): # Always treat missing expiration_time as expired and proceed to executable run.
|
||||
raise exceptions.RefreshError
|
||||
except (exceptions.MalformedError, exceptions.InvalidValue):
|
||||
raise
|
||||
except exceptions.RefreshError:
|
||||
pass
|
||||
else:
|
||||
return subject_token
|
||||
|
||||
# Inject env vars.
|
||||
env = os.environ.copy()
|
||||
self._inject_env_variables(env)
|
||||
env["GOOGLE_EXTERNAL_ACCOUNT_REVOKE"] = "0"
|
||||
|
||||
# Run executable.
|
||||
exe_timeout = (
|
||||
self._credential_source_executable_interactive_timeout_millis / 1000
|
||||
if self.interactive
|
||||
else self._credential_source_executable_timeout_millis / 1000
|
||||
)
|
||||
exe_stdin = sys.stdin if self.interactive else None
|
||||
exe_stdout = sys.stdout if self.interactive else subprocess.PIPE
|
||||
exe_stderr = sys.stdout if self.interactive else subprocess.STDOUT
|
||||
|
||||
result = subprocess.run(
|
||||
shlex.split(self._credential_source_executable_command),
|
||||
timeout=exe_timeout,
|
||||
stdin=exe_stdin,
|
||||
stdout=exe_stdout,
|
||||
stderr=exe_stderr,
|
||||
env=env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise exceptions.RefreshError(
|
||||
"Executable exited with non-zero return code {}. Error: {}".format(
|
||||
result.returncode, result.stdout
|
||||
)
|
||||
)
|
||||
|
||||
# Handle executable output.
|
||||
response = json.loads(result.stdout.decode("utf-8")) if result.stdout else None
|
||||
if not response and self._credential_source_executable_output_file is not None:
|
||||
response = json.load(
|
||||
open(self._credential_source_executable_output_file, encoding="utf-8")
|
||||
)
|
||||
|
||||
subject_token = self._parse_subject_token(response)
|
||||
return subject_token
|
||||
|
||||
def revoke(self, request):
|
||||
"""Revokes the subject token using the credential_source object.
|
||||
|
||||
Args:
|
||||
request (google.auth.transport.Request): A callable used to make
|
||||
HTTP requests.
|
||||
Raises:
|
||||
google.auth.exceptions.RefreshError: If the executable revocation
|
||||
not properly executed.
|
||||
|
||||
"""
|
||||
if not self.interactive:
|
||||
raise exceptions.InvalidValue(
|
||||
"Revoke is only enabled under interactive mode."
|
||||
)
|
||||
self._validate_running_mode()
|
||||
|
||||
# Inject variables
|
||||
env = os.environ.copy()
|
||||
self._inject_env_variables(env)
|
||||
env["GOOGLE_EXTERNAL_ACCOUNT_REVOKE"] = "1"
|
||||
|
||||
# Run executable
|
||||
result = subprocess.run(
|
||||
shlex.split(self._credential_source_executable_command),
|
||||
timeout=self._credential_source_executable_interactive_timeout_millis
|
||||
/ 1000,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise exceptions.RefreshError(
|
||||
"Auth revoke failed on executable. Exit with non-zero return code {}. Error: {}".format(
|
||||
result.returncode, result.stdout
|
||||
)
|
||||
)
|
||||
|
||||
response = json.loads(result.stdout.decode("utf-8"))
|
||||
self._validate_revoke_response(response)
|
||||
|
||||
@property
|
||||
def external_account_id(self):
|
||||
"""Returns the external account identifier.
|
||||
|
||||
When service account impersonation is used the identifier is the service
|
||||
account email.
|
||||
|
||||
Without service account impersonation, this returns None, unless it is
|
||||
being used by the Google Cloud CLI which populates this field.
|
||||
"""
|
||||
|
||||
return self.service_account_email or self._tokeninfo_username
|
||||
|
||||
@classmethod
|
||||
def from_info(cls, info, **kwargs):
|
||||
"""Creates a Pluggable Credentials instance from parsed external account info.
|
||||
|
||||
**IMPORTANT**:
|
||||
This method does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using with this method.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
|
||||
Args:
|
||||
info (Mapping[str, str]): The Pluggable external account info in Google
|
||||
format.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.pluggable.Credentials: The constructed
|
||||
credentials.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.InvalidValue: For invalid parameters.
|
||||
google.auth.exceptions.MalformedError: For invalid parameters.
|
||||
"""
|
||||
return super(Credentials, cls).from_info(info, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, filename, **kwargs):
|
||||
"""Creates an Pluggable Credentials instance from an external account json file.
|
||||
|
||||
**IMPORTANT**:
|
||||
This method does not validate the credential configuration. A security
|
||||
risk occurs when a credential configuration configured with malicious urls
|
||||
is used.
|
||||
When the credential configuration is accepted from an
|
||||
untrusted source, you should validate it before using with this method.
|
||||
Refer https://cloud.google.com/docs/authentication/external/externally-sourced-credentials for more details.
|
||||
|
||||
Args:
|
||||
filename (str): The path to the Pluggable external account json file.
|
||||
kwargs: Additional arguments to pass to the constructor.
|
||||
|
||||
Returns:
|
||||
google.auth.pluggable.Credentials: The constructed
|
||||
credentials.
|
||||
"""
|
||||
return super(Credentials, cls).from_file(filename, **kwargs)
|
||||
|
||||
def _inject_env_variables(self, env):
|
||||
env["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience
|
||||
env["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self._subject_token_type
|
||||
env["GOOGLE_EXTERNAL_ACCOUNT_ID"] = self.external_account_id
|
||||
env["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = "1" if self.interactive else "0"
|
||||
|
||||
if self._service_account_impersonation_url is not None:
|
||||
env[
|
||||
"GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"
|
||||
] = self.service_account_email
|
||||
if self._credential_source_executable_output_file is not None:
|
||||
env[
|
||||
"GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"
|
||||
] = self._credential_source_executable_output_file
|
||||
|
||||
def _parse_subject_token(self, response):
|
||||
self._validate_response_schema(response)
|
||||
if not response["success"]:
|
||||
if "code" not in response or "message" not in response:
|
||||
raise exceptions.MalformedError(
|
||||
"Error code and message fields are required in the response."
|
||||
)
|
||||
raise exceptions.RefreshError(
|
||||
"Executable returned unsuccessful response: code: {}, message: {}.".format(
|
||||
response["code"], response["message"]
|
||||
)
|
||||
)
|
||||
if "expiration_time" in response and response["expiration_time"] < time.time():
|
||||
raise exceptions.RefreshError(
|
||||
"The token returned by the executable is expired."
|
||||
)
|
||||
if "token_type" not in response:
|
||||
raise exceptions.MalformedError(
|
||||
"The executable response is missing the token_type field."
|
||||
)
|
||||
if (
|
||||
response["token_type"] == "urn:ietf:params:oauth:token-type:jwt"
|
||||
or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token"
|
||||
): # OIDC
|
||||
return response["id_token"]
|
||||
elif response["token_type"] == "urn:ietf:params:oauth:token-type:saml2": # SAML
|
||||
return response["saml_response"]
|
||||
else:
|
||||
raise exceptions.RefreshError("Executable returned unsupported token type.")
|
||||
|
||||
def _validate_revoke_response(self, response):
|
||||
self._validate_response_schema(response)
|
||||
if not response["success"]:
|
||||
raise exceptions.RefreshError("Revoke failed with unsuccessful response.")
|
||||
|
||||
def _validate_response_schema(self, response):
|
||||
if "version" not in response:
|
||||
raise exceptions.MalformedError(
|
||||
"The executable response is missing the version field."
|
||||
)
|
||||
if response["version"] > EXECUTABLE_SUPPORTED_MAX_VERSION:
|
||||
raise exceptions.RefreshError(
|
||||
"Executable returned unsupported version {}.".format(
|
||||
response["version"]
|
||||
)
|
||||
)
|
||||
|
||||
if "success" not in response:
|
||||
raise exceptions.MalformedError(
|
||||
"The executable response is missing the success field."
|
||||
)
|
||||
|
||||
def _validate_running_mode(self):
|
||||
env_allow_executables = os.environ.get(
|
||||
"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"
|
||||
)
|
||||
if env_allow_executables != "1":
|
||||
raise exceptions.MalformedError(
|
||||
"Executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run."
|
||||
)
|
||||
|
||||
if self.interactive and not self._credential_source_executable_output_file:
|
||||
raise exceptions.MalformedError(
|
||||
"An output_file must be specified in the credential configuration for interactive mode."
|
||||
)
|
||||
|
||||
if (
|
||||
self.interactive
|
||||
and not self._credential_source_executable_interactive_timeout_millis
|
||||
):
|
||||
raise exceptions.InvalidOperation(
|
||||
"Interactive mode cannot run without an interactive timeout."
|
||||
)
|
||||
|
||||
if self.interactive and not self.is_workforce_pool:
|
||||
raise exceptions.InvalidValue(
|
||||
"Interactive mode is only enabled for workforce pool."
|
||||
)
|
||||
|
||||
def _create_default_metrics_options(self):
|
||||
metrics_options = super(Credentials, self)._create_default_metrics_options()
|
||||
metrics_options["source"] = "executable"
|
||||
return metrics_options
|
||||
2
venv/lib/python3.12/site-packages/google/auth/py.typed
Normal file
2
venv/lib/python3.12/site-packages/google/auth/py.typed
Normal file
@@ -0,0 +1,2 @@
|
||||
# Marker file for PEP 561.
|
||||
# The google-auth package uses inline types.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Transport - HTTP client library support.
|
||||
|
||||
:mod:`google.auth` is designed to work with various HTTP client libraries such
|
||||
as urllib3 and requests. In order to work across these libraries with different
|
||||
interfaces some abstraction is needed.
|
||||
|
||||
This module provides two interfaces that are implemented by transport adapters
|
||||
to support HTTP libraries. :class:`Request` defines the interface expected by
|
||||
:mod:`google.auth` to make requests. :class:`Response` defines the interface
|
||||
for the return value of :class:`Request`.
|
||||
"""
|
||||
|
||||
import abc
|
||||
import http.client as http_client
|
||||
|
||||
DEFAULT_RETRYABLE_STATUS_CODES = (
|
||||
http_client.INTERNAL_SERVER_ERROR,
|
||||
http_client.SERVICE_UNAVAILABLE,
|
||||
http_client.GATEWAY_TIMEOUT,
|
||||
http_client.REQUEST_TIMEOUT,
|
||||
http_client.TOO_MANY_REQUESTS,
|
||||
)
|
||||
"""Sequence[int]: HTTP status codes indicating a request can be retried.
|
||||
"""
|
||||
|
||||
|
||||
DEFAULT_REFRESH_STATUS_CODES = (http_client.UNAUTHORIZED,)
|
||||
"""Sequence[int]: Which HTTP status code indicate that credentials should be
|
||||
refreshed.
|
||||
"""
|
||||
|
||||
DEFAULT_MAX_REFRESH_ATTEMPTS = 2
|
||||
"""int: How many times to refresh the credentials and retry a request."""
|
||||
|
||||
|
||||
class Response(metaclass=abc.ABCMeta):
|
||||
"""HTTP Response data."""
|
||||
|
||||
@abc.abstractproperty
|
||||
def status(self):
|
||||
"""int: The HTTP status code."""
|
||||
raise NotImplementedError("status must be implemented.")
|
||||
|
||||
@abc.abstractproperty
|
||||
def headers(self):
|
||||
"""Mapping[str, str]: The HTTP response headers."""
|
||||
raise NotImplementedError("headers must be implemented.")
|
||||
|
||||
@abc.abstractproperty
|
||||
def data(self):
|
||||
"""bytes: The response body."""
|
||||
raise NotImplementedError("data must be implemented.")
|
||||
|
||||
|
||||
class Request(metaclass=abc.ABCMeta):
|
||||
"""Interface for a callable that makes HTTP requests.
|
||||
|
||||
Specific transport implementations should provide an implementation of
|
||||
this that adapts their specific request / response API.
|
||||
|
||||
.. automethod:: __call__
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def __call__(
|
||||
self, url, method="GET", body=None, headers=None, timeout=None, **kwargs
|
||||
):
|
||||
"""Make an HTTP request.
|
||||
|
||||
Args:
|
||||
url (str): The URI to be requested.
|
||||
method (str): The HTTP method to use for the request. Defaults
|
||||
to 'GET'.
|
||||
body (bytes): The payload / body in HTTP request.
|
||||
headers (Mapping[str, str]): Request headers.
|
||||
timeout (Optional[int]): The number of seconds to wait for a
|
||||
response from the server. If not specified or if None, the
|
||||
transport-specific default timeout will be used.
|
||||
kwargs: Additionally arguments passed on to the transport's
|
||||
request method.
|
||||
|
||||
Returns:
|
||||
Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: If any exception occurred.
|
||||
"""
|
||||
# pylint: disable=redundant-returns-doc, missing-raises-doc
|
||||
# (pylint doesn't play well with abstract docstrings.)
|
||||
raise NotImplementedError("__call__ must be implemented.")
|
||||
@@ -0,0 +1,396 @@
|
||||
# Copyright 2020 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.
|
||||
|
||||
"""Transport adapter for Async HTTP (aiohttp).
|
||||
|
||||
NOTE: This async support is experimental and marked internal. This surface may
|
||||
change in minor releases.
|
||||
"""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
|
||||
import aiohttp # type: ignore
|
||||
import urllib3 # type: ignore
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import exceptions
|
||||
from google.auth import transport
|
||||
from google.auth.aio import _helpers as _helpers_async
|
||||
from google.auth.transport import requests
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Timeout can be re-defined depending on async requirement. Currently made 60s more than
|
||||
# sync timeout.
|
||||
_DEFAULT_TIMEOUT = 180 # in seconds
|
||||
|
||||
|
||||
class _CombinedResponse(transport.Response):
|
||||
"""
|
||||
In order to more closely resemble the `requests` interface, where a raw
|
||||
and deflated content could be accessed at once, this class lazily reads the
|
||||
stream in `transport.Response` so both return forms can be used.
|
||||
|
||||
The gzip and deflate transfer-encodings are automatically decoded for you
|
||||
because the default parameter for autodecompress into the ClientSession is set
|
||||
to False, and therefore we add this class to act as a wrapper for a user to be
|
||||
able to access both the raw and decoded response bodies - mirroring the sync
|
||||
implementation.
|
||||
"""
|
||||
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
self._raw_content = None
|
||||
|
||||
def _is_compressed(self):
|
||||
headers = self._response.headers
|
||||
return "Content-Encoding" in headers and (
|
||||
headers["Content-Encoding"] == "gzip"
|
||||
or headers["Content-Encoding"] == "deflate"
|
||||
)
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self._response.status
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return self._response.headers
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._response.content
|
||||
|
||||
async def raw_content(self):
|
||||
if self._raw_content is None:
|
||||
self._raw_content = await self._response.content.read()
|
||||
return self._raw_content
|
||||
|
||||
async def content(self):
|
||||
# Load raw_content if necessary
|
||||
await self.raw_content()
|
||||
if self._is_compressed():
|
||||
decoder = urllib3.response.MultiDecoder(
|
||||
self._response.headers["Content-Encoding"]
|
||||
)
|
||||
decompressed = decoder.decompress(self._raw_content)
|
||||
return decompressed
|
||||
|
||||
return self._raw_content
|
||||
|
||||
|
||||
class _Response(transport.Response):
|
||||
"""
|
||||
Requests transport response adapter.
|
||||
|
||||
Args:
|
||||
response (requests.Response): The raw Requests response.
|
||||
"""
|
||||
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self._response.status
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return self._response.headers
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._response.content
|
||||
|
||||
|
||||
class Request(transport.Request):
|
||||
"""Requests request adapter.
|
||||
|
||||
This class is used internally for making requests using asyncio transports
|
||||
in a consistent way. If you use :class:`AuthorizedSession` you do not need
|
||||
to construct or use this class directly.
|
||||
|
||||
This class can be useful if you want to manually refresh a
|
||||
:class:`~google.auth.credentials.Credentials` instance::
|
||||
|
||||
import google.auth.transport.aiohttp_requests
|
||||
|
||||
request = google.auth.transport.aiohttp_requests.Request()
|
||||
|
||||
credentials.refresh(request)
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): An instance :class:`aiohttp.ClientSession` used
|
||||
to make HTTP requests. If not specified, a session will be created.
|
||||
|
||||
.. automethod:: __call__
|
||||
"""
|
||||
|
||||
def __init__(self, session=None):
|
||||
# TODO: Use auto_decompress property for aiohttp 3.7+
|
||||
if session is not None and session._auto_decompress:
|
||||
raise exceptions.InvalidOperation(
|
||||
"Client sessions with auto_decompress=True are not supported."
|
||||
)
|
||||
self.session = session
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
url,
|
||||
method="GET",
|
||||
body=None,
|
||||
headers=None,
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Make an HTTP request using aiohttp.
|
||||
|
||||
Args:
|
||||
url (str): The URL to be requested.
|
||||
method (Optional[str]):
|
||||
The HTTP method to use for the request. Defaults to 'GET'.
|
||||
body (Optional[bytes]):
|
||||
The payload or body in HTTP request.
|
||||
headers (Optional[Mapping[str, str]]):
|
||||
Request headers.
|
||||
timeout (Optional[int]): The number of seconds to wait for a
|
||||
response from the server. If not specified or if None, the
|
||||
requests default timeout will be used.
|
||||
kwargs: Additional arguments passed through to the underlying
|
||||
requests :meth:`requests.Session.request` method.
|
||||
|
||||
Returns:
|
||||
google.auth.transport.Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: If any exception occurred.
|
||||
"""
|
||||
|
||||
try:
|
||||
if self.session is None: # pragma: NO COVER
|
||||
self.session = aiohttp.ClientSession(
|
||||
auto_decompress=False
|
||||
) # pragma: NO COVER
|
||||
_helpers.request_log(_LOGGER, method, url, body, headers)
|
||||
response = await self.session.request(
|
||||
method, url, data=body, headers=headers, timeout=timeout, **kwargs
|
||||
)
|
||||
await _helpers_async.response_log_async(_LOGGER, response)
|
||||
return _CombinedResponse(response)
|
||||
|
||||
except aiohttp.ClientError as caught_exc:
|
||||
new_exc = exceptions.TransportError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
except asyncio.TimeoutError as caught_exc:
|
||||
new_exc = exceptions.TransportError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
|
||||
class AuthorizedSession(aiohttp.ClientSession):
|
||||
"""This is an async implementation of the Authorized Session class. We utilize an
|
||||
aiohttp transport instance, and the interface mirrors the google.auth.transport.requests
|
||||
Authorized Session class, except for the change in the transport used in the async use case.
|
||||
|
||||
A Requests Session class with credentials.
|
||||
|
||||
This class is used to perform requests to API endpoints that require
|
||||
authorization::
|
||||
|
||||
from google.auth.transport import aiohttp_requests
|
||||
|
||||
async with aiohttp_requests.AuthorizedSession(credentials) as authed_session:
|
||||
response = await authed_session.request(
|
||||
'GET', 'https://www.googleapis.com/storage/v1/b')
|
||||
|
||||
The underlying :meth:`request` implementation handles adding the
|
||||
credentials' headers to the request and refreshing credentials as needed.
|
||||
|
||||
Args:
|
||||
credentials (google.auth._credentials_async.Credentials):
|
||||
The credentials to add to the request.
|
||||
refresh_status_codes (Sequence[int]): Which HTTP status codes indicate
|
||||
that credentials should be refreshed and the request should be
|
||||
retried.
|
||||
max_refresh_attempts (int): The maximum number of times to attempt to
|
||||
refresh the credentials and retry the request.
|
||||
refresh_timeout (Optional[int]): The timeout value in seconds for
|
||||
credential refresh HTTP requests.
|
||||
auth_request (google.auth.transport.aiohttp_requests.Request):
|
||||
(Optional) An instance of
|
||||
:class:`~google.auth.transport.aiohttp_requests.Request` used when
|
||||
refreshing credentials. If not passed,
|
||||
an instance of :class:`~google.auth.transport.aiohttp_requests.Request`
|
||||
is created.
|
||||
kwargs: Additional arguments passed through to the underlying
|
||||
ClientSession :meth:`aiohttp.ClientSession` object.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credentials,
|
||||
refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES,
|
||||
max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS,
|
||||
refresh_timeout=None,
|
||||
auth_request=None,
|
||||
auto_decompress=False,
|
||||
**kwargs,
|
||||
):
|
||||
super(AuthorizedSession, self).__init__(**kwargs)
|
||||
self.credentials = credentials
|
||||
self._refresh_status_codes = refresh_status_codes
|
||||
self._max_refresh_attempts = max_refresh_attempts
|
||||
self._refresh_timeout = refresh_timeout
|
||||
self._is_mtls = False
|
||||
self._auth_request = auth_request
|
||||
self._auth_request_session = None
|
||||
self._loop = asyncio.get_event_loop()
|
||||
self._refresh_lock = asyncio.Lock()
|
||||
self._auto_decompress = auto_decompress
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method,
|
||||
url,
|
||||
data=None,
|
||||
headers=None,
|
||||
max_allowed_time=None,
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
auto_decompress=False,
|
||||
**kwargs,
|
||||
):
|
||||
"""Implementation of Authorized Session aiohttp request.
|
||||
|
||||
Args:
|
||||
method (str):
|
||||
The http request method used (e.g. GET, PUT, DELETE)
|
||||
url (str):
|
||||
The url at which the http request is sent.
|
||||
data (Optional[dict]): Dictionary, list of tuples, bytes, or file-like
|
||||
object to send in the body of the Request.
|
||||
headers (Optional[dict]): Dictionary of HTTP Headers to send with the
|
||||
Request.
|
||||
timeout (Optional[Union[float, aiohttp.ClientTimeout]]):
|
||||
The amount of time in seconds to wait for the server response
|
||||
with each individual request. Can also be passed as an
|
||||
``aiohttp.ClientTimeout`` object.
|
||||
max_allowed_time (Optional[float]):
|
||||
If the method runs longer than this, a ``Timeout`` exception is
|
||||
automatically raised. Unlike the ``timeout`` parameter, this
|
||||
value applies to the total method execution time, even if
|
||||
multiple requests are made under the hood.
|
||||
|
||||
Mind that it is not guaranteed that the timeout error is raised
|
||||
at ``max_allowed_time``. It might take longer, for example, if
|
||||
an underlying request takes a lot of time, but the request
|
||||
itself does not timeout, e.g. if a large file is being
|
||||
transmitted. The timeout error will be raised after such
|
||||
request completes.
|
||||
"""
|
||||
# Headers come in as bytes which isn't expected behavior, the resumable
|
||||
# media libraries in some cases expect a str type for the header values,
|
||||
# but sometimes the operations return these in bytes types.
|
||||
if headers:
|
||||
for key in headers.keys():
|
||||
if type(headers[key]) is bytes:
|
||||
headers[key] = headers[key].decode("utf-8")
|
||||
|
||||
async with aiohttp.ClientSession(
|
||||
auto_decompress=self._auto_decompress,
|
||||
trust_env=kwargs.get("trust_env", False),
|
||||
) as self._auth_request_session:
|
||||
auth_request = Request(self._auth_request_session)
|
||||
self._auth_request = auth_request
|
||||
|
||||
# Use a kwarg for this instead of an attribute to maintain
|
||||
# thread-safety.
|
||||
_credential_refresh_attempt = kwargs.pop("_credential_refresh_attempt", 0)
|
||||
# Make a copy of the headers. They will be modified by the credentials
|
||||
# and we want to pass the original headers if we recurse.
|
||||
request_headers = headers.copy() if headers is not None else {}
|
||||
|
||||
# Do not apply the timeout unconditionally in order to not override the
|
||||
# _auth_request's default timeout.
|
||||
auth_request = (
|
||||
self._auth_request
|
||||
if timeout is None
|
||||
else functools.partial(self._auth_request, timeout=timeout)
|
||||
)
|
||||
|
||||
remaining_time = max_allowed_time
|
||||
|
||||
with requests.TimeoutGuard(remaining_time, asyncio.TimeoutError) as guard:
|
||||
await self.credentials.before_request(
|
||||
auth_request, method, url, request_headers
|
||||
)
|
||||
|
||||
with requests.TimeoutGuard(remaining_time, asyncio.TimeoutError) as guard:
|
||||
response = await super(AuthorizedSession, self).request(
|
||||
method,
|
||||
url,
|
||||
data=data,
|
||||
headers=request_headers,
|
||||
timeout=timeout,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
remaining_time = guard.remaining_timeout
|
||||
|
||||
if (
|
||||
response.status in self._refresh_status_codes
|
||||
and _credential_refresh_attempt < self._max_refresh_attempts
|
||||
):
|
||||
requests._LOGGER.info(
|
||||
"Refreshing credentials due to a %s response. Attempt %s/%s.",
|
||||
response.status,
|
||||
_credential_refresh_attempt + 1,
|
||||
self._max_refresh_attempts,
|
||||
)
|
||||
|
||||
# Do not apply the timeout unconditionally in order to not override the
|
||||
# _auth_request's default timeout.
|
||||
auth_request = (
|
||||
self._auth_request
|
||||
if timeout is None
|
||||
else functools.partial(self._auth_request, timeout=timeout)
|
||||
)
|
||||
|
||||
with requests.TimeoutGuard(
|
||||
remaining_time, asyncio.TimeoutError
|
||||
) as guard:
|
||||
async with self._refresh_lock:
|
||||
await self._loop.run_in_executor(
|
||||
None, self.credentials.refresh, auth_request
|
||||
)
|
||||
|
||||
remaining_time = guard.remaining_timeout
|
||||
|
||||
return await self.request(
|
||||
method,
|
||||
url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
max_allowed_time=remaining_time,
|
||||
timeout=timeout,
|
||||
_credential_refresh_attempt=_credential_refresh_attempt + 1,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,283 @@
|
||||
# Copyright 2022 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.
|
||||
|
||||
"""
|
||||
Code for configuring client side TLS to offload the signing operation to
|
||||
signing libraries.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
import cffi # type: ignore
|
||||
|
||||
from google.auth import exceptions
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# C++ offload lib requires google-auth lib to provide the following callback:
|
||||
# using SignFunc = int (*)(unsigned char *sig, size_t *sig_len,
|
||||
# const unsigned char *tbs, size_t tbs_len)
|
||||
# The bytes to be signed and the length are provided via `tbs` and `tbs_len`,
|
||||
# the callback computes the signature, and write the signature and its length
|
||||
# into `sig` and `sig_len`.
|
||||
# If the signing is successful, the callback returns 1, otherwise it returns 0.
|
||||
SIGN_CALLBACK_CTYPE = ctypes.CFUNCTYPE(
|
||||
ctypes.c_int, # return type
|
||||
ctypes.POINTER(ctypes.c_ubyte), # sig
|
||||
ctypes.POINTER(ctypes.c_size_t), # sig_len
|
||||
ctypes.POINTER(ctypes.c_ubyte), # tbs
|
||||
ctypes.c_size_t, # tbs_len
|
||||
)
|
||||
|
||||
|
||||
# Cast SSL_CTX* to void*
|
||||
def _cast_ssl_ctx_to_void_p_pyopenssl(ssl_ctx):
|
||||
return ctypes.cast(int(cffi.FFI().cast("intptr_t", ssl_ctx)), ctypes.c_void_p)
|
||||
|
||||
|
||||
# Cast SSL_CTX* to void*
|
||||
def _cast_ssl_ctx_to_void_p_stdlib(context):
|
||||
return ctypes.c_void_p.from_address(
|
||||
id(context) + ctypes.sizeof(ctypes.c_void_p) * 2
|
||||
)
|
||||
|
||||
|
||||
# Load offload library and set up the function types.
|
||||
def load_offload_lib(offload_lib_path):
|
||||
_LOGGER.debug("loading offload library from %s", offload_lib_path)
|
||||
|
||||
# winmode parameter is only available for python 3.8+.
|
||||
lib = (
|
||||
ctypes.CDLL(offload_lib_path, winmode=0)
|
||||
if sys.version_info >= (3, 8) and os.name == "nt"
|
||||
else ctypes.CDLL(offload_lib_path)
|
||||
)
|
||||
|
||||
# Set up types for:
|
||||
# int ConfigureSslContext(SignFunc sign_func, const char *cert, SSL_CTX *ctx)
|
||||
lib.ConfigureSslContext.argtypes = [
|
||||
SIGN_CALLBACK_CTYPE,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_void_p,
|
||||
]
|
||||
lib.ConfigureSslContext.restype = ctypes.c_int
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
# Load signer library and set up the function types.
|
||||
# See: https://github.com/googleapis/enterprise-certificate-proxy/blob/main/cshared/main.go
|
||||
def load_signer_lib(signer_lib_path):
|
||||
_LOGGER.debug("loading signer library from %s", signer_lib_path)
|
||||
|
||||
# winmode parameter is only available for python 3.8+.
|
||||
lib = (
|
||||
ctypes.CDLL(signer_lib_path, winmode=0)
|
||||
if sys.version_info >= (3, 8) and os.name == "nt"
|
||||
else ctypes.CDLL(signer_lib_path)
|
||||
)
|
||||
|
||||
# Set up types for:
|
||||
# func GetCertPemForPython(configFilePath *C.char, certHolder *byte, certHolderLen int)
|
||||
lib.GetCertPemForPython.argtypes = [ctypes.c_char_p, ctypes.c_char_p, ctypes.c_int]
|
||||
# Returns: certLen
|
||||
lib.GetCertPemForPython.restype = ctypes.c_int
|
||||
|
||||
# Set up types for:
|
||||
# func SignForPython(configFilePath *C.char, digest *byte, digestLen int,
|
||||
# sigHolder *byte, sigHolderLen int)
|
||||
lib.SignForPython.argtypes = [
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int,
|
||||
]
|
||||
# Returns: the signature length
|
||||
lib.SignForPython.restype = ctypes.c_int
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
def load_provider_lib(provider_lib_path):
|
||||
_LOGGER.debug("loading provider library from %s", provider_lib_path)
|
||||
|
||||
# winmode parameter is only available for python 3.8+.
|
||||
lib = (
|
||||
ctypes.CDLL(provider_lib_path, winmode=0)
|
||||
if sys.version_info >= (3, 8) and os.name == "nt"
|
||||
else ctypes.CDLL(provider_lib_path)
|
||||
)
|
||||
|
||||
lib.ECP_attach_to_ctx.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
|
||||
lib.ECP_attach_to_ctx.restype = ctypes.c_int
|
||||
|
||||
return lib
|
||||
|
||||
|
||||
# Computes SHA256 hash.
|
||||
def _compute_sha256_digest(to_be_signed, to_be_signed_len):
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
|
||||
data = ctypes.string_at(to_be_signed, to_be_signed_len)
|
||||
hash = hashes.Hash(hashes.SHA256())
|
||||
hash.update(data)
|
||||
return hash.finalize()
|
||||
|
||||
|
||||
# Create the signing callback. The actual signing work is done by the
|
||||
# `SignForPython` method from the signer lib.
|
||||
def get_sign_callback(signer_lib, config_file_path):
|
||||
def sign_callback(sig, sig_len, tbs, tbs_len):
|
||||
_LOGGER.debug("calling sign callback...")
|
||||
|
||||
digest = _compute_sha256_digest(tbs, tbs_len)
|
||||
digestArray = ctypes.c_char * len(digest)
|
||||
|
||||
# reserve 2000 bytes for the signature, shoud be more then enough.
|
||||
# RSA signature is 256 bytes, EC signature is 70~72.
|
||||
sig_holder_len = 2000
|
||||
sig_holder = ctypes.create_string_buffer(sig_holder_len)
|
||||
|
||||
signature_len = signer_lib.SignForPython(
|
||||
config_file_path.encode(), # configFilePath
|
||||
digestArray.from_buffer(bytearray(digest)), # digest
|
||||
len(digest), # digestLen
|
||||
sig_holder, # sigHolder
|
||||
sig_holder_len, # sigHolderLen
|
||||
)
|
||||
|
||||
if signature_len == 0:
|
||||
# signing failed, return 0
|
||||
return 0
|
||||
|
||||
sig_len[0] = signature_len
|
||||
bs = bytearray(sig_holder)
|
||||
for i in range(signature_len):
|
||||
sig[i] = bs[i]
|
||||
|
||||
return 1
|
||||
|
||||
return SIGN_CALLBACK_CTYPE(sign_callback)
|
||||
|
||||
|
||||
# Obtain the certificate bytes by calling the `GetCertPemForPython` method from
|
||||
# the signer lib. The method is called twice, the first time is to compute the
|
||||
# cert length, then we create a buffer to hold the cert, and call it again to
|
||||
# fill the buffer.
|
||||
def get_cert(signer_lib, config_file_path):
|
||||
# First call to calculate the cert length
|
||||
cert_len = signer_lib.GetCertPemForPython(
|
||||
config_file_path.encode(), # configFilePath
|
||||
None, # certHolder
|
||||
0, # certHolderLen
|
||||
)
|
||||
if cert_len == 0:
|
||||
raise exceptions.MutualTLSChannelError("failed to get certificate")
|
||||
|
||||
# Then we create an array to hold the cert, and call again to fill the cert
|
||||
cert_holder = ctypes.create_string_buffer(cert_len)
|
||||
signer_lib.GetCertPemForPython(
|
||||
config_file_path.encode(), # configFilePath
|
||||
cert_holder, # certHolder
|
||||
cert_len, # certHolderLen
|
||||
)
|
||||
return bytes(cert_holder)
|
||||
|
||||
|
||||
class CustomTlsSigner(object):
|
||||
def __init__(self, enterprise_cert_file_path):
|
||||
"""
|
||||
This class loads the offload and signer library, and calls APIs from
|
||||
these libraries to obtain the cert and a signing callback, and attach
|
||||
them to SSL context. The cert and the signing callback will be used
|
||||
for client authentication in TLS handshake.
|
||||
|
||||
Args:
|
||||
enterprise_cert_file_path (str): the path to a enterprise cert JSON
|
||||
file. The file should contain the following field:
|
||||
|
||||
{
|
||||
"libs": {
|
||||
"ecp_client": "...",
|
||||
"tls_offload": "..."
|
||||
}
|
||||
}
|
||||
"""
|
||||
self._enterprise_cert_file_path = enterprise_cert_file_path
|
||||
self._cert = None
|
||||
self._sign_callback = None
|
||||
self._provider_lib = None
|
||||
|
||||
def load_libraries(self):
|
||||
with open(self._enterprise_cert_file_path, "r") as f:
|
||||
enterprise_cert_json = json.load(f)
|
||||
libs = enterprise_cert_json.get("libs", {})
|
||||
|
||||
signer_library = libs.get("ecp_client", None)
|
||||
offload_library = libs.get("tls_offload", None)
|
||||
provider_library = libs.get("ecp_provider", None)
|
||||
|
||||
# Using newer provider implementation. This is mutually exclusive to the
|
||||
# offload implementation.
|
||||
if provider_library:
|
||||
self._provider_lib = load_provider_lib(provider_library)
|
||||
return
|
||||
|
||||
# Using old offload implementation
|
||||
if offload_library and signer_library:
|
||||
self._offload_lib = load_offload_lib(offload_library)
|
||||
self._signer_lib = load_signer_lib(signer_library)
|
||||
self.set_up_custom_key()
|
||||
return
|
||||
|
||||
raise exceptions.MutualTLSChannelError("enterprise cert file is invalid")
|
||||
|
||||
def set_up_custom_key(self):
|
||||
# We need to keep a reference of the cert and sign callback so it won't
|
||||
# be garbage collected, otherwise it will crash when used by signer lib.
|
||||
self._cert = get_cert(self._signer_lib, self._enterprise_cert_file_path)
|
||||
self._sign_callback = get_sign_callback(
|
||||
self._signer_lib, self._enterprise_cert_file_path
|
||||
)
|
||||
|
||||
def should_use_provider(self):
|
||||
if self._provider_lib:
|
||||
return True
|
||||
return False
|
||||
|
||||
def attach_to_ssl_context(self, ctx):
|
||||
if self.should_use_provider():
|
||||
if not self._provider_lib.ECP_attach_to_ctx(
|
||||
_cast_ssl_ctx_to_void_p_stdlib(ctx),
|
||||
self._enterprise_cert_file_path.encode("ascii"),
|
||||
):
|
||||
raise exceptions.MutualTLSChannelError(
|
||||
"failed to configure ECP Provider SSL context"
|
||||
)
|
||||
elif self._offload_lib and self._signer_lib:
|
||||
if not self._offload_lib.ConfigureSslContext(
|
||||
self._sign_callback,
|
||||
ctypes.c_char_p(self._cert),
|
||||
_cast_ssl_ctx_to_void_p_pyopenssl(ctx._ctx._context),
|
||||
):
|
||||
raise exceptions.MutualTLSChannelError(
|
||||
"failed to configure ECP Offload SSL context"
|
||||
)
|
||||
else:
|
||||
raise exceptions.MutualTLSChannelError("Invalid ECP configuration.")
|
||||
@@ -0,0 +1,114 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Transport adapter for http.client, for internal use only."""
|
||||
|
||||
import http.client as http_client
|
||||
import logging
|
||||
import socket
|
||||
import urllib
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import exceptions
|
||||
from google.auth import transport
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Response(transport.Response):
|
||||
"""http.client transport response adapter.
|
||||
|
||||
Args:
|
||||
response (http.client.HTTPResponse): The raw http client response.
|
||||
"""
|
||||
|
||||
def __init__(self, response):
|
||||
self._status = response.status
|
||||
self._headers = {key.lower(): value for key, value in response.getheaders()}
|
||||
self._data = response.read()
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return self._headers
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._data
|
||||
|
||||
|
||||
class Request(transport.Request):
|
||||
"""http.client transport request adapter."""
|
||||
|
||||
def __call__(
|
||||
self, url, method="GET", body=None, headers=None, timeout=None, **kwargs
|
||||
):
|
||||
"""Make an HTTP request using http.client.
|
||||
|
||||
Args:
|
||||
url (str): The URI to be requested.
|
||||
method (str): The HTTP method to use for the request. Defaults
|
||||
to 'GET'.
|
||||
body (bytes): The payload / body in HTTP request.
|
||||
headers (Mapping): Request headers.
|
||||
timeout (Optional(int)): The number of seconds to wait for a
|
||||
response from the server. If not specified or if None, the
|
||||
socket global default timeout will be used.
|
||||
kwargs: Additional arguments passed throught to the underlying
|
||||
:meth:`~http.client.HTTPConnection.request` method.
|
||||
|
||||
Returns:
|
||||
Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: If any exception occurred.
|
||||
"""
|
||||
# socket._GLOBAL_DEFAULT_TIMEOUT is the default in http.client.
|
||||
if timeout is None:
|
||||
timeout = socket._GLOBAL_DEFAULT_TIMEOUT
|
||||
|
||||
# http.client doesn't allow None as the headers argument.
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
# http.client needs the host and path parts specified separately.
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
path = urllib.parse.urlunsplit(
|
||||
("", "", parts.path, parts.query, parts.fragment)
|
||||
)
|
||||
|
||||
if parts.scheme != "http":
|
||||
raise exceptions.TransportError(
|
||||
"http.client transport only supports the http scheme, {}"
|
||||
" was specified".format(parts.scheme)
|
||||
)
|
||||
|
||||
connection = http_client.HTTPConnection(parts.netloc, timeout=timeout)
|
||||
|
||||
try:
|
||||
_helpers.request_log(_LOGGER, method, url, body, headers)
|
||||
connection.request(method, path, body=body, headers=headers, **kwargs)
|
||||
response = connection.getresponse()
|
||||
_helpers.response_log(_LOGGER, response)
|
||||
return Response(response)
|
||||
|
||||
except (http_client.HTTPException, socket.error) as caught_exc:
|
||||
new_exc = exceptions.TransportError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
finally:
|
||||
connection.close()
|
||||
@@ -0,0 +1,531 @@
|
||||
# Copyright 2020 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.
|
||||
|
||||
"""Helper functions for getting mTLS cert and key."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from os import environ, getenv, path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from google.auth import _agent_identity_utils
|
||||
from google.auth import environment_vars
|
||||
from google.auth import exceptions
|
||||
|
||||
CONTEXT_AWARE_METADATA_PATH = "~/.secureConnect/context_aware_metadata.json"
|
||||
|
||||
# Default gcloud config path, to be used with path.expanduser for cross-platform compatibility.
|
||||
CERTIFICATE_CONFIGURATION_DEFAULT_PATH = "~/.config/gcloud/certificate_config.json"
|
||||
_CERT_PROVIDER_COMMAND = "cert_provider_command"
|
||||
_CERT_REGEX = re.compile(
|
||||
b"-----BEGIN CERTIFICATE-----.+-----END CERTIFICATE-----\r?\n?", re.DOTALL
|
||||
)
|
||||
|
||||
# support various format of key files, e.g.
|
||||
# "-----BEGIN PRIVATE KEY-----...",
|
||||
# "-----BEGIN EC PRIVATE KEY-----...",
|
||||
# "-----BEGIN RSA PRIVATE KEY-----..."
|
||||
# "-----BEGIN ENCRYPTED PRIVATE KEY-----"
|
||||
_KEY_REGEX = re.compile(
|
||||
b"-----BEGIN [A-Z ]*PRIVATE KEY-----.+-----END [A-Z ]*PRIVATE KEY-----\r?\n?",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_PASSPHRASE_REGEX = re.compile(
|
||||
b"-----BEGIN PASSPHRASE-----(.+)-----END PASSPHRASE-----", re.DOTALL
|
||||
)
|
||||
|
||||
# Temporary patch to accomodate incorrect cert config in Cloud Run prod environment.
|
||||
_WELL_KNOWN_CLOUD_RUN_CERT_PATH = (
|
||||
"/var/run/secrets/workload-spiffe-credentials/certificates.pem"
|
||||
)
|
||||
_WELL_KNOWN_CLOUD_RUN_KEY_PATH = (
|
||||
"/var/run/secrets/workload-spiffe-credentials/private_key.pem"
|
||||
)
|
||||
_INCORRECT_CLOUD_RUN_CERT_PATH = (
|
||||
"/var/lib/volumes/certificate/workload-certificates/certificates.pem"
|
||||
)
|
||||
_INCORRECT_CLOUD_RUN_KEY_PATH = (
|
||||
"/var/lib/volumes/certificate/workload-certificates/private_key.pem"
|
||||
)
|
||||
|
||||
|
||||
def _check_config_path(config_path):
|
||||
"""Checks for config file path. If it exists, returns the absolute path with user expansion;
|
||||
otherwise returns None.
|
||||
|
||||
Args:
|
||||
config_path (str): The config file path for either context_aware_metadata.json or certificate_config.json for example
|
||||
|
||||
Returns:
|
||||
str: absolute path if exists and None otherwise.
|
||||
"""
|
||||
config_path = path.expanduser(config_path)
|
||||
if not path.exists(config_path):
|
||||
_LOGGER.debug("%s is not found.", config_path)
|
||||
return None
|
||||
return config_path
|
||||
|
||||
|
||||
def _load_json_file(path):
|
||||
"""Reads and loads JSON from the given path. Used to read both X509 workload certificate and
|
||||
secure connect configurations.
|
||||
|
||||
Args:
|
||||
path (str): the path to read from.
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: The JSON stored at the file.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.ClientCertError: If failed to parse the file as JSON.
|
||||
"""
|
||||
try:
|
||||
with open(path) as f:
|
||||
json_data = json.load(f)
|
||||
except ValueError as caught_exc:
|
||||
new_exc = exceptions.ClientCertError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
return json_data
|
||||
|
||||
|
||||
def _get_workload_cert_and_key(
|
||||
certificate_config_path=None, include_context_aware=True
|
||||
):
|
||||
"""Read the workload identity cert and key files specified in the certificate config provided.
|
||||
If no config path is provided, check the environment variable: "GOOGLE_API_CERTIFICATE_CONFIG"
|
||||
first, then the well known gcloud location: "~/.config/gcloud/certificate_config.json".
|
||||
|
||||
Args:
|
||||
certificate_config_path (string): The certificate config path. If no path is provided,
|
||||
the environment variable will be checked first, then the well known gcloud location.
|
||||
include_context_aware (bool): If context aware metadata path should be checked for the
|
||||
SecureConnect mTLS configuration.
|
||||
|
||||
Returns:
|
||||
Tuple[Optional[bytes], Optional[bytes]]: client certificate bytes in PEM format and key
|
||||
bytes in PEM format.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.ClientCertError: if problems occurs when retrieving
|
||||
the certificate or key information.
|
||||
"""
|
||||
|
||||
cert_path, key_path = _get_workload_cert_and_key_paths(
|
||||
certificate_config_path, include_context_aware
|
||||
)
|
||||
|
||||
if cert_path is None and key_path is None:
|
||||
return None, None
|
||||
|
||||
return _read_cert_and_key_files(cert_path, key_path)
|
||||
|
||||
|
||||
def _get_cert_config_path(certificate_config_path=None, include_context_aware=True):
|
||||
"""Get the certificate configuration path based on the following order:
|
||||
|
||||
1: Explicit override, if set
|
||||
2: Environment variable, if set
|
||||
3: Well-known location
|
||||
|
||||
Returns "None" if the selected config file does not exist.
|
||||
|
||||
Args:
|
||||
certificate_config_path (string): The certificate config path. If provided, the well known
|
||||
location and environment variable will be ignored.
|
||||
include_context_aware (bool): If context aware metadata path should be checked for the
|
||||
SecureConnect mTLS configuration.
|
||||
|
||||
Returns:
|
||||
The absolute path of the certificate config file, and None if the file does not exist.
|
||||
"""
|
||||
|
||||
if certificate_config_path is None:
|
||||
env_path = environ.get(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG, None)
|
||||
if env_path is not None and env_path != "":
|
||||
certificate_config_path = env_path
|
||||
else:
|
||||
env_path = environ.get(
|
||||
environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH,
|
||||
None,
|
||||
)
|
||||
if include_context_aware and env_path is not None and env_path != "":
|
||||
certificate_config_path = env_path
|
||||
else:
|
||||
certificate_config_path = CERTIFICATE_CONFIGURATION_DEFAULT_PATH
|
||||
|
||||
certificate_config_path = path.expanduser(certificate_config_path)
|
||||
if not path.exists(certificate_config_path):
|
||||
return None
|
||||
return certificate_config_path
|
||||
|
||||
|
||||
def _get_workload_cert_and_key_paths(config_path, include_context_aware=True):
|
||||
absolute_path = _get_cert_config_path(config_path, include_context_aware)
|
||||
if absolute_path is None:
|
||||
return None, None
|
||||
|
||||
data = _load_json_file(absolute_path)
|
||||
|
||||
if "cert_configs" not in data:
|
||||
raise exceptions.ClientCertError(
|
||||
'Certificate config file {} is in an invalid format, a "cert configs" object is expected'.format(
|
||||
absolute_path
|
||||
)
|
||||
)
|
||||
cert_configs = data["cert_configs"]
|
||||
|
||||
# We return None, None if the expected workload fields are not present.
|
||||
# The certificate config might be present for other types of connections (e.g. gECC),
|
||||
# and we want to gracefully fallback to testing other mTLS configurations
|
||||
# like SecureConnect instead of throwing an exception.
|
||||
|
||||
if "workload" not in cert_configs:
|
||||
return None, None
|
||||
workload = cert_configs["workload"]
|
||||
|
||||
if "cert_path" not in workload:
|
||||
return None, None
|
||||
cert_path = workload["cert_path"]
|
||||
|
||||
if "key_path" not in workload:
|
||||
return None, None
|
||||
key_path = workload["key_path"]
|
||||
|
||||
# == BEGIN Temporary Cloud Run PATCH ==
|
||||
# See https://github.com/googleapis/google-auth-library-python/issues/1881
|
||||
if (cert_path == _INCORRECT_CLOUD_RUN_CERT_PATH) and (
|
||||
key_path == _INCORRECT_CLOUD_RUN_KEY_PATH
|
||||
):
|
||||
if not path.exists(cert_path) and not path.exists(key_path):
|
||||
_LOGGER.debug(
|
||||
"Applying Cloud Run certificate path patch. "
|
||||
"Configured paths not found: %s, %s. "
|
||||
"Using well-known paths: %s, %s",
|
||||
cert_path,
|
||||
key_path,
|
||||
_WELL_KNOWN_CLOUD_RUN_CERT_PATH,
|
||||
_WELL_KNOWN_CLOUD_RUN_KEY_PATH,
|
||||
)
|
||||
cert_path = _WELL_KNOWN_CLOUD_RUN_CERT_PATH
|
||||
key_path = _WELL_KNOWN_CLOUD_RUN_KEY_PATH
|
||||
# == END Temporary Cloud Run PATCH ==
|
||||
|
||||
return cert_path, key_path
|
||||
|
||||
|
||||
def _read_cert_and_key_files(cert_path, key_path):
|
||||
cert_data = _read_cert_file(cert_path)
|
||||
key_data = _read_key_file(key_path)
|
||||
|
||||
return cert_data, key_data
|
||||
|
||||
|
||||
def _read_cert_file(cert_path):
|
||||
with open(cert_path, "rb") as cert_file:
|
||||
cert_data = cert_file.read()
|
||||
|
||||
cert_match = re.findall(_CERT_REGEX, cert_data)
|
||||
if len(cert_match) != 1:
|
||||
raise exceptions.ClientCertError(
|
||||
"Certificate file {} is in an invalid format, a single PEM formatted certificate is expected".format(
|
||||
cert_path
|
||||
)
|
||||
)
|
||||
return cert_match[0]
|
||||
|
||||
|
||||
def _read_key_file(key_path):
|
||||
with open(key_path, "rb") as key_file:
|
||||
key_data = key_file.read()
|
||||
|
||||
key_match = re.findall(_KEY_REGEX, key_data)
|
||||
if len(key_match) != 1:
|
||||
raise exceptions.ClientCertError(
|
||||
"Private key file {} is in an invalid format, a single PEM formatted private key is expected".format(
|
||||
key_path
|
||||
)
|
||||
)
|
||||
|
||||
return key_match[0]
|
||||
|
||||
|
||||
def _run_cert_provider_command(command, expect_encrypted_key=False):
|
||||
"""Run the provided command, and return client side mTLS cert, key and
|
||||
passphrase.
|
||||
|
||||
Args:
|
||||
command (List[str]): cert provider command.
|
||||
expect_encrypted_key (bool): If encrypted private key is expected.
|
||||
|
||||
Returns:
|
||||
Tuple[bytes, bytes, bytes]: client certificate bytes in PEM format, key
|
||||
bytes in PEM format and passphrase bytes.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.ClientCertError: if problems occurs when running
|
||||
the cert provider command or generating cert, key and passphrase.
|
||||
"""
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = process.communicate()
|
||||
except OSError as caught_exc:
|
||||
new_exc = exceptions.ClientCertError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
# Check cert provider command execution error.
|
||||
if process.returncode != 0:
|
||||
raise exceptions.ClientCertError(
|
||||
"Cert provider command returns non-zero status code %s" % process.returncode
|
||||
)
|
||||
|
||||
# Extract certificate (chain), key and passphrase.
|
||||
cert_match = re.findall(_CERT_REGEX, stdout)
|
||||
if len(cert_match) != 1:
|
||||
raise exceptions.ClientCertError("Client SSL certificate is missing or invalid")
|
||||
key_match = re.findall(_KEY_REGEX, stdout)
|
||||
if len(key_match) != 1:
|
||||
raise exceptions.ClientCertError("Client SSL key is missing or invalid")
|
||||
passphrase_match = re.findall(_PASSPHRASE_REGEX, stdout)
|
||||
|
||||
if expect_encrypted_key:
|
||||
if len(passphrase_match) != 1:
|
||||
raise exceptions.ClientCertError("Passphrase is missing or invalid")
|
||||
if b"ENCRYPTED" not in key_match[0]:
|
||||
raise exceptions.ClientCertError("Encrypted private key is expected")
|
||||
return cert_match[0], key_match[0], passphrase_match[0].strip()
|
||||
|
||||
if b"ENCRYPTED" in key_match[0]:
|
||||
raise exceptions.ClientCertError("Encrypted private key is not expected")
|
||||
if len(passphrase_match) > 0:
|
||||
raise exceptions.ClientCertError("Passphrase is not expected")
|
||||
return cert_match[0], key_match[0], None
|
||||
|
||||
|
||||
def get_client_ssl_credentials(
|
||||
generate_encrypted_key=False,
|
||||
context_aware_metadata_path=CONTEXT_AWARE_METADATA_PATH,
|
||||
certificate_config_path=None,
|
||||
):
|
||||
"""Returns the client side certificate, private key and passphrase.
|
||||
|
||||
We look for certificates and keys with the following order of priority:
|
||||
1. Certificate and key specified by certificate_config.json.
|
||||
Currently, only X.509 workload certificates are supported.
|
||||
2. Certificate and key specified by context aware metadata (i.e. SecureConnect).
|
||||
|
||||
Args:
|
||||
generate_encrypted_key (bool): If set to True, encrypted private key
|
||||
and passphrase will be generated; otherwise, unencrypted private key
|
||||
will be generated and passphrase will be None. This option only
|
||||
affects keys obtained via context_aware_metadata.json.
|
||||
context_aware_metadata_path (str): The context_aware_metadata.json file path.
|
||||
certificate_config_path (str): The certificate_config.json file path.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, bytes, bytes, bytes]:
|
||||
A boolean indicating if cert, key and passphrase are obtained, the
|
||||
cert bytes and key bytes both in PEM format, and passphrase bytes.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.ClientCertError: if problems occurs when getting
|
||||
the cert, key and passphrase.
|
||||
"""
|
||||
|
||||
# 1. Attempt to retrieve X.509 Workload cert and key.
|
||||
cert, key = _get_workload_cert_and_key(certificate_config_path)
|
||||
if cert and key:
|
||||
return True, cert, key, None
|
||||
|
||||
# 2. Check for context aware metadata json
|
||||
metadata_path = _check_config_path(context_aware_metadata_path)
|
||||
|
||||
if metadata_path:
|
||||
metadata_json = _load_json_file(metadata_path)
|
||||
|
||||
if _CERT_PROVIDER_COMMAND not in metadata_json:
|
||||
raise exceptions.ClientCertError("Cert provider command is not found")
|
||||
|
||||
command = metadata_json[_CERT_PROVIDER_COMMAND]
|
||||
|
||||
if generate_encrypted_key and "--with_passphrase" not in command:
|
||||
command.append("--with_passphrase")
|
||||
|
||||
# Execute the command.
|
||||
cert, key, passphrase = _run_cert_provider_command(
|
||||
command, expect_encrypted_key=generate_encrypted_key
|
||||
)
|
||||
return True, cert, key, passphrase
|
||||
|
||||
return False, None, None, None
|
||||
|
||||
|
||||
def get_client_cert_and_key(client_cert_callback=None):
|
||||
"""Returns the client side certificate and private key. The function first
|
||||
tries to get certificate and key from client_cert_callback; if the callback
|
||||
is None or doesn't provide certificate and key, the function tries application
|
||||
default SSL credentials.
|
||||
|
||||
Args:
|
||||
client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): An
|
||||
optional callback which returns client certificate bytes and private
|
||||
key bytes both in PEM format.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, bytes, bytes]:
|
||||
A boolean indicating if cert and key are obtained, the cert bytes
|
||||
and key bytes both in PEM format.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.ClientCertError: if problems occurs when getting
|
||||
the cert and key.
|
||||
"""
|
||||
if client_cert_callback:
|
||||
cert, key = client_cert_callback()
|
||||
return True, cert, key
|
||||
|
||||
has_cert, cert, key, _ = get_client_ssl_credentials(generate_encrypted_key=False)
|
||||
return has_cert, cert, key
|
||||
|
||||
|
||||
def decrypt_private_key(key, passphrase):
|
||||
"""A helper function to decrypt the private key with the given passphrase.
|
||||
google-auth library doesn't support passphrase protected private key for
|
||||
mutual TLS channel. This helper function can be used to decrypt the
|
||||
passphrase protected private key in order to estalish mutual TLS channel.
|
||||
|
||||
For example, if you have a function which produces client cert, passphrase
|
||||
protected private key and passphrase, you can convert it to a client cert
|
||||
callback function accepted by google-auth::
|
||||
|
||||
from google.auth.transport import _mtls_helper
|
||||
|
||||
def your_client_cert_function():
|
||||
return cert, encrypted_key, passphrase
|
||||
|
||||
# callback accepted by google-auth for mutual TLS channel.
|
||||
def client_cert_callback():
|
||||
cert, encrypted_key, passphrase = your_client_cert_function()
|
||||
decrypted_key = _mtls_helper.decrypt_private_key(encrypted_key,
|
||||
passphrase)
|
||||
return cert, decrypted_key
|
||||
|
||||
Args:
|
||||
key (bytes): The private key bytes in PEM format.
|
||||
passphrase (bytes): The passphrase bytes.
|
||||
|
||||
Returns:
|
||||
bytes: The decrypted private key in PEM format.
|
||||
|
||||
Raises:
|
||||
ImportError: If pyOpenSSL is not installed.
|
||||
OpenSSL.crypto.Error: If there is any problem decrypting the private key.
|
||||
"""
|
||||
from OpenSSL import crypto
|
||||
|
||||
# First convert encrypted_key_bytes to PKey object
|
||||
pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key, passphrase=passphrase)
|
||||
|
||||
# Then dump the decrypted key bytes
|
||||
return crypto.dump_privatekey(crypto.FILETYPE_PEM, pkey)
|
||||
|
||||
|
||||
def check_use_client_cert():
|
||||
"""Returns boolean for whether the client certificate should be used for mTLS.
|
||||
|
||||
If GOOGLE_API_USE_CLIENT_CERTIFICATE is set to true or false, a corresponding
|
||||
bool value will be returned. If the value is set to an unexpected string, it
|
||||
will default to False.
|
||||
If GOOGLE_API_USE_CLIENT_CERTIFICATE is unset, the value will be inferred
|
||||
by reading a file pointed at by GOOGLE_API_CERTIFICATE_CONFIG, and verifying
|
||||
it contains a "workload" section. If so, the function will return True,
|
||||
otherwise False.
|
||||
|
||||
Returns:
|
||||
bool: Whether the client certificate should be used for mTLS connection.
|
||||
"""
|
||||
use_client_cert = getenv(environment_vars.GOOGLE_API_USE_CLIENT_CERTIFICATE)
|
||||
if use_client_cert is None or use_client_cert == "":
|
||||
use_client_cert = getenv(
|
||||
environment_vars.CLOUDSDK_CONTEXT_AWARE_USE_CLIENT_CERTIFICATE
|
||||
)
|
||||
|
||||
# Check if the value of GOOGLE_API_USE_CLIENT_CERTIFICATE is set.
|
||||
if use_client_cert:
|
||||
return use_client_cert.lower() == "true"
|
||||
else:
|
||||
# Check if the value of GOOGLE_API_CERTIFICATE_CONFIG is set.
|
||||
cert_path = getenv(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG)
|
||||
if cert_path is None:
|
||||
cert_path = getenv(
|
||||
environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH
|
||||
)
|
||||
|
||||
if cert_path:
|
||||
try:
|
||||
with open(cert_path, "r") as f:
|
||||
content = json.load(f)
|
||||
# verify json has workload key
|
||||
content["cert_configs"]["workload"]
|
||||
return True
|
||||
except (
|
||||
FileNotFoundError,
|
||||
OSError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
json.JSONDecodeError,
|
||||
) as e:
|
||||
_LOGGER.debug("error decoding certificate: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
def check_parameters_for_unauthorized_response(cached_cert):
|
||||
"""Returns the cached and current cert fingerprint for reconfiguring mTLS.
|
||||
|
||||
Args:
|
||||
cached_cert(bytes): The cached client certificate.
|
||||
|
||||
Returns:
|
||||
bytes: The client callback cert bytes.
|
||||
bytes: The client callback key bytes.
|
||||
str: The base64-encoded SHA256 cached fingerprint.
|
||||
str: The base64-encoded SHA256 current cert fingerprint.
|
||||
"""
|
||||
call_cert_bytes, call_key_bytes = call_client_cert_callback()
|
||||
cert_obj = _agent_identity_utils.parse_certificate(call_cert_bytes)
|
||||
current_cert_fingerprint = _agent_identity_utils.calculate_certificate_fingerprint(
|
||||
cert_obj
|
||||
)
|
||||
if cached_cert:
|
||||
cached_fingerprint = _agent_identity_utils.get_cached_cert_fingerprint(
|
||||
cached_cert
|
||||
)
|
||||
else:
|
||||
cached_fingerprint = current_cert_fingerprint
|
||||
return call_cert_bytes, call_key_bytes, cached_fingerprint, current_cert_fingerprint
|
||||
|
||||
|
||||
def call_client_cert_callback():
|
||||
"""Calls the client cert callback and returns the certificate and key."""
|
||||
_, cert_bytes, key_bytes, passphrase = get_client_ssl_credentials(
|
||||
generate_encrypted_key=True
|
||||
)
|
||||
return cert_bytes, key_bytes
|
||||
@@ -0,0 +1,53 @@
|
||||
# Copyright 2024 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.
|
||||
|
||||
"""Transport adapter for Base Requests."""
|
||||
# NOTE: The coverage for this file is temporarily disabled in `.coveragerc`
|
||||
# since it is currently unused.
|
||||
|
||||
import abc
|
||||
|
||||
|
||||
_DEFAULT_TIMEOUT = 120 # in second
|
||||
|
||||
|
||||
class _BaseAuthorizedSession(metaclass=abc.ABCMeta):
|
||||
"""Base class for a Request Session with credentials. This class is intended to capture
|
||||
the common logic between synchronous and asynchronous request sessions and is not intended to
|
||||
be instantiated directly.
|
||||
|
||||
Args:
|
||||
credentials (google.auth._credentials_base.BaseCredentials): The credentials to
|
||||
add to the request.
|
||||
"""
|
||||
|
||||
def __init__(self, credentials):
|
||||
self.credentials = credentials
|
||||
|
||||
@abc.abstractmethod
|
||||
def request(
|
||||
self,
|
||||
method,
|
||||
url,
|
||||
data=None,
|
||||
headers=None,
|
||||
max_allowed_time=None,
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
**kwargs
|
||||
):
|
||||
raise NotImplementedError("Request must be implemented")
|
||||
|
||||
@abc.abstractmethod
|
||||
def close(self):
|
||||
raise NotImplementedError("Close must be implemented")
|
||||
337
venv/lib/python3.12/site-packages/google/auth/transport/grpc.py
Normal file
337
venv/lib/python3.12/site-packages/google/auth/transport/grpc.py
Normal file
@@ -0,0 +1,337 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Authorization support for gRPC."""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import logging
|
||||
|
||||
from google.auth import exceptions
|
||||
from google.auth.transport import _mtls_helper
|
||||
from google.oauth2 import service_account
|
||||
|
||||
try:
|
||||
import grpc # type: ignore
|
||||
except ImportError as caught_exc: # pragma: NO COVER
|
||||
raise ImportError(
|
||||
"gRPC is not installed from please install the grpcio package to use the gRPC transport."
|
||||
) from caught_exc
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthMetadataPlugin(grpc.AuthMetadataPlugin):
|
||||
"""A `gRPC AuthMetadataPlugin`_ that inserts the credentials into each
|
||||
request.
|
||||
|
||||
.. _gRPC AuthMetadataPlugin:
|
||||
http://www.grpc.io/grpc/python/grpc.html#grpc.AuthMetadataPlugin
|
||||
|
||||
Args:
|
||||
credentials (google.auth.credentials.Credentials): The credentials to
|
||||
add to requests.
|
||||
request (google.auth.transport.Request): A HTTP transport request
|
||||
object used to refresh credentials as needed.
|
||||
default_host (Optional[str]): A host like "pubsub.googleapis.com".
|
||||
This is used when a self-signed JWT is created from service
|
||||
account credentials.
|
||||
"""
|
||||
|
||||
def __init__(self, credentials, request, default_host=None):
|
||||
# pylint: disable=no-value-for-parameter
|
||||
# pylint doesn't realize that the super method takes no arguments
|
||||
# because this class is the same name as the superclass.
|
||||
super(AuthMetadataPlugin, self).__init__()
|
||||
self._credentials = credentials
|
||||
self._request = request
|
||||
self._default_host = default_host
|
||||
|
||||
def _get_authorization_headers(self, context):
|
||||
"""Gets the authorization headers for a request.
|
||||
|
||||
Returns:
|
||||
Sequence[Tuple[str, str]]: A list of request headers (key, value)
|
||||
to add to the request.
|
||||
"""
|
||||
headers = {}
|
||||
|
||||
# https://google.aip.dev/auth/4111
|
||||
# Attempt to use self-signed JWTs when a service account is used.
|
||||
# A default host must be explicitly provided since it cannot always
|
||||
# be determined from the context.service_url.
|
||||
if isinstance(self._credentials, service_account.Credentials):
|
||||
self._credentials._create_self_signed_jwt(
|
||||
"https://{}/".format(self._default_host) if self._default_host else None
|
||||
)
|
||||
|
||||
self._credentials.before_request(
|
||||
self._request, context.method_name, context.service_url, headers
|
||||
)
|
||||
|
||||
return list(headers.items())
|
||||
|
||||
def __call__(self, context, callback):
|
||||
"""Passes authorization metadata into the given callback.
|
||||
|
||||
Args:
|
||||
context (grpc.AuthMetadataContext): The RPC context.
|
||||
callback (grpc.AuthMetadataPluginCallback): The callback that will
|
||||
be invoked to pass in the authorization metadata.
|
||||
"""
|
||||
callback(self._get_authorization_headers(context), None)
|
||||
|
||||
|
||||
def secure_authorized_channel(
|
||||
credentials,
|
||||
request,
|
||||
target,
|
||||
ssl_credentials=None,
|
||||
client_cert_callback=None,
|
||||
**kwargs
|
||||
):
|
||||
"""Creates a secure authorized gRPC channel.
|
||||
|
||||
This creates a channel with SSL and :class:`AuthMetadataPlugin`. This
|
||||
channel can be used to create a stub that can make authorized requests.
|
||||
Users can configure client certificate or rely on device certificates to
|
||||
establish a mutual TLS channel, if the `GOOGLE_API_USE_CLIENT_CERTIFICATE`
|
||||
variable is explicitly set to `true`.
|
||||
|
||||
Example::
|
||||
|
||||
import google.auth
|
||||
import google.auth.transport.grpc
|
||||
import google.auth.transport.requests
|
||||
from google.cloud.speech.v1 import cloud_speech_pb2
|
||||
|
||||
# Get credentials.
|
||||
credentials, _ = google.auth.default()
|
||||
|
||||
# Get an HTTP request function to refresh credentials.
|
||||
request = google.auth.transport.requests.Request()
|
||||
|
||||
# Create a channel.
|
||||
channel = google.auth.transport.grpc.secure_authorized_channel(
|
||||
credentials, regular_endpoint, request,
|
||||
ssl_credentials=grpc.ssl_channel_credentials())
|
||||
|
||||
# Use the channel to create a stub.
|
||||
cloud_speech.create_Speech_stub(channel)
|
||||
|
||||
Usage:
|
||||
|
||||
There are actually a couple of options to create a channel, depending on if
|
||||
you want to create a regular or mutual TLS channel.
|
||||
|
||||
First let's list the endpoints (regular vs mutual TLS) to choose from::
|
||||
|
||||
regular_endpoint = 'speech.googleapis.com:443'
|
||||
mtls_endpoint = 'speech.mtls.googleapis.com:443'
|
||||
|
||||
Option 1: create a regular (non-mutual) TLS channel by explicitly setting
|
||||
the ssl_credentials::
|
||||
|
||||
regular_ssl_credentials = grpc.ssl_channel_credentials()
|
||||
|
||||
channel = google.auth.transport.grpc.secure_authorized_channel(
|
||||
credentials, request, regular_endpoint,
|
||||
ssl_credentials=regular_ssl_credentials)
|
||||
|
||||
Option 2: create a mutual TLS channel by calling a callback which returns
|
||||
the client side certificate and the key (Note that
|
||||
`GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be explicitly
|
||||
set to `true`)::
|
||||
|
||||
def my_client_cert_callback():
|
||||
code_to_load_client_cert_and_key()
|
||||
if loaded:
|
||||
return (pem_cert_bytes, pem_key_bytes)
|
||||
raise MyClientCertFailureException()
|
||||
|
||||
try:
|
||||
channel = google.auth.transport.grpc.secure_authorized_channel(
|
||||
credentials, request, mtls_endpoint,
|
||||
client_cert_callback=my_client_cert_callback)
|
||||
except MyClientCertFailureException:
|
||||
# handle the exception
|
||||
|
||||
Option 3: use application default SSL credentials. It searches and uses
|
||||
the command in a context aware metadata file, which is available on devices
|
||||
with endpoint verification support (Note that
|
||||
`GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable must be explicitly
|
||||
set to `true`).
|
||||
See https://cloud.google.com/endpoint-verification/docs/overview::
|
||||
|
||||
try:
|
||||
default_ssl_credentials = SslCredentials()
|
||||
except:
|
||||
# Exception can be raised if the context aware metadata is malformed.
|
||||
# See :class:`SslCredentials` for the possible exceptions.
|
||||
|
||||
# Choose the endpoint based on the SSL credentials type.
|
||||
if default_ssl_credentials.is_mtls:
|
||||
endpoint_to_use = mtls_endpoint
|
||||
else:
|
||||
endpoint_to_use = regular_endpoint
|
||||
channel = google.auth.transport.grpc.secure_authorized_channel(
|
||||
credentials, request, endpoint_to_use,
|
||||
ssl_credentials=default_ssl_credentials)
|
||||
|
||||
Option 4: not setting ssl_credentials and client_cert_callback. For devices
|
||||
without endpoint verification support or `GOOGLE_API_USE_CLIENT_CERTIFICATE`
|
||||
environment variable is not `true`, a regular TLS channel is created;
|
||||
otherwise, a mutual TLS channel is created, however, the call should be
|
||||
wrapped in a try/except block in case of malformed context aware metadata.
|
||||
|
||||
The following code uses regular_endpoint, it works the same no matter the
|
||||
created channle is regular or mutual TLS. Regular endpoint ignores client
|
||||
certificate and key::
|
||||
|
||||
channel = google.auth.transport.grpc.secure_authorized_channel(
|
||||
credentials, request, regular_endpoint)
|
||||
|
||||
The following code uses mtls_endpoint, if the created channle is regular,
|
||||
and API mtls_endpoint is confgured to require client SSL credentials, API
|
||||
calls using this channel will be rejected::
|
||||
|
||||
channel = google.auth.transport.grpc.secure_authorized_channel(
|
||||
credentials, request, mtls_endpoint)
|
||||
|
||||
Args:
|
||||
credentials (google.auth.credentials.Credentials): The credentials to
|
||||
add to requests.
|
||||
request (google.auth.transport.Request): A HTTP transport request
|
||||
object used to refresh credentials as needed. Even though gRPC
|
||||
is a separate transport, there's no way to refresh the credentials
|
||||
without using a standard http transport.
|
||||
target (str): The host and port of the service.
|
||||
ssl_credentials (grpc.ChannelCredentials): Optional SSL channel
|
||||
credentials. This can be used to specify different certificates.
|
||||
This argument is mutually exclusive with client_cert_callback;
|
||||
providing both will raise an exception.
|
||||
If ssl_credentials and client_cert_callback are None, application
|
||||
default SSL credentials are used if `GOOGLE_API_USE_CLIENT_CERTIFICATE`
|
||||
environment variable is explicitly set to `true`, otherwise one way TLS
|
||||
SSL credentials are used.
|
||||
client_cert_callback (Callable[[], (bytes, bytes)]): Optional
|
||||
callback function to obtain client certicate and key for mutual TLS
|
||||
connection. This argument is mutually exclusive with
|
||||
ssl_credentials; providing both will raise an exception.
|
||||
This argument does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE`
|
||||
environment variable is explicitly set to `true`.
|
||||
kwargs: Additional arguments to pass to :func:`grpc.secure_channel`.
|
||||
|
||||
Returns:
|
||||
grpc.Channel: The created gRPC channel.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel
|
||||
creation failed for any reason.
|
||||
"""
|
||||
# Create the metadata plugin for inserting the authorization header.
|
||||
metadata_plugin = AuthMetadataPlugin(credentials, request)
|
||||
|
||||
# Create a set of grpc.CallCredentials using the metadata plugin.
|
||||
google_auth_credentials = grpc.metadata_call_credentials(metadata_plugin)
|
||||
|
||||
if ssl_credentials and client_cert_callback:
|
||||
raise exceptions.MalformedError(
|
||||
"Received both ssl_credentials and client_cert_callback; "
|
||||
"these are mutually exclusive."
|
||||
)
|
||||
|
||||
# If SSL credentials are not explicitly set, try client_cert_callback and ADC.
|
||||
if not ssl_credentials:
|
||||
use_client_cert = _mtls_helper.check_use_client_cert()
|
||||
if use_client_cert and client_cert_callback:
|
||||
# Use the callback if provided.
|
||||
cert, key = client_cert_callback()
|
||||
ssl_credentials = grpc.ssl_channel_credentials(
|
||||
certificate_chain=cert, private_key=key
|
||||
)
|
||||
elif use_client_cert:
|
||||
# Use application default SSL credentials.
|
||||
adc_ssl_credentils = SslCredentials()
|
||||
ssl_credentials = adc_ssl_credentils.ssl_credentials
|
||||
else:
|
||||
ssl_credentials = grpc.ssl_channel_credentials()
|
||||
|
||||
# Combine the ssl credentials and the authorization credentials.
|
||||
composite_credentials = grpc.composite_channel_credentials(
|
||||
ssl_credentials, google_auth_credentials
|
||||
)
|
||||
|
||||
return grpc.secure_channel(target, composite_credentials, **kwargs)
|
||||
|
||||
|
||||
class SslCredentials:
|
||||
"""Class for application default SSL credentials.
|
||||
|
||||
The behavior is controlled by `GOOGLE_API_USE_CLIENT_CERTIFICATE` environment
|
||||
variable whose default value is `false`. Client certificate will not be used
|
||||
unless the environment variable is explicitly set to `true`. See
|
||||
https://google.aip.dev/auth/4114
|
||||
|
||||
If the environment variable is `true`, then for devices with endpoint verification
|
||||
support, a device certificate will be automatically loaded and mutual TLS will
|
||||
be established.
|
||||
See https://cloud.google.com/endpoint-verification/docs/overview.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
use_client_cert = _mtls_helper.check_use_client_cert()
|
||||
if not use_client_cert:
|
||||
self._is_mtls = False
|
||||
else:
|
||||
# Load client SSL credentials.
|
||||
metadata_path = _mtls_helper._check_config_path(
|
||||
_mtls_helper.CONTEXT_AWARE_METADATA_PATH
|
||||
)
|
||||
self._is_mtls = metadata_path is not None
|
||||
|
||||
@property
|
||||
def ssl_credentials(self):
|
||||
"""Get the created SSL channel credentials.
|
||||
|
||||
For devices with endpoint verification support, if the device certificate
|
||||
loading has any problems, corresponding exceptions will be raised. For
|
||||
a device without endpoint verification support, no exceptions will be
|
||||
raised.
|
||||
|
||||
Returns:
|
||||
grpc.ChannelCredentials: The created grpc channel credentials.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel
|
||||
creation failed for any reason.
|
||||
"""
|
||||
if self._is_mtls:
|
||||
try:
|
||||
_, cert, key, _ = _mtls_helper.get_client_ssl_credentials()
|
||||
self._ssl_credentials = grpc.ssl_channel_credentials(
|
||||
certificate_chain=cert, private_key=key
|
||||
)
|
||||
except exceptions.ClientCertError as caught_exc:
|
||||
new_exc = exceptions.MutualTLSChannelError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
else:
|
||||
self._ssl_credentials = grpc.ssl_channel_credentials()
|
||||
|
||||
return self._ssl_credentials
|
||||
|
||||
@property
|
||||
def is_mtls(self):
|
||||
"""Indicates if the created SSL channel credentials is mutual TLS."""
|
||||
return self._is_mtls
|
||||
142
venv/lib/python3.12/site-packages/google/auth/transport/mtls.py
Normal file
142
venv/lib/python3.12/site-packages/google/auth/transport/mtls.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# Copyright 2020 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.
|
||||
|
||||
"""Utilites for mutual TLS."""
|
||||
|
||||
from os import getenv
|
||||
|
||||
from google.auth import exceptions
|
||||
from google.auth.transport import _mtls_helper
|
||||
|
||||
|
||||
def has_default_client_cert_source(include_context_aware=True):
|
||||
"""Check if default client SSL credentials exists on the device.
|
||||
|
||||
Args:
|
||||
include_context_aware (bool): include_context_aware indicates if context_aware
|
||||
path location will be checked or should it be skipped.
|
||||
|
||||
Returns:
|
||||
bool: indicating if the default client cert source exists.
|
||||
"""
|
||||
if (
|
||||
include_context_aware
|
||||
and _mtls_helper._check_config_path(_mtls_helper.CONTEXT_AWARE_METADATA_PATH)
|
||||
is not None
|
||||
):
|
||||
return True
|
||||
if (
|
||||
_mtls_helper._check_config_path(
|
||||
_mtls_helper.CERTIFICATE_CONFIGURATION_DEFAULT_PATH
|
||||
)
|
||||
is not None
|
||||
):
|
||||
return True
|
||||
cert_config_path = getenv("GOOGLE_API_CERTIFICATE_CONFIG")
|
||||
if (
|
||||
cert_config_path
|
||||
and _mtls_helper._check_config_path(cert_config_path) is not None
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def default_client_cert_source():
|
||||
"""Get a callback which returns the default client SSL credentials.
|
||||
|
||||
Returns:
|
||||
Callable[[], [bytes, bytes]]: A callback which returns the default
|
||||
client certificate bytes and private key bytes, both in PEM format.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.DefaultClientCertSourceError: If the default
|
||||
client SSL credentials don't exist or are malformed.
|
||||
"""
|
||||
if not has_default_client_cert_source(include_context_aware=True):
|
||||
raise exceptions.MutualTLSChannelError(
|
||||
"Default client cert source doesn't exist"
|
||||
)
|
||||
|
||||
def callback():
|
||||
try:
|
||||
_, cert_bytes, key_bytes = _mtls_helper.get_client_cert_and_key()
|
||||
except (OSError, RuntimeError, ValueError) as caught_exc:
|
||||
new_exc = exceptions.MutualTLSChannelError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
return cert_bytes, key_bytes
|
||||
|
||||
return callback
|
||||
|
||||
|
||||
def default_client_encrypted_cert_source(cert_path, key_path):
|
||||
"""Get a callback which returns the default encrpyted client SSL credentials.
|
||||
|
||||
Args:
|
||||
cert_path (str): The cert file path. The default client certificate will
|
||||
be written to this file when the returned callback is called.
|
||||
key_path (str): The key file path. The default encrypted client key will
|
||||
be written to this file when the returned callback is called.
|
||||
|
||||
Returns:
|
||||
Callable[[], [str, str, bytes]]: A callback which generates the default
|
||||
client certificate, encrpyted private key and passphrase. It writes
|
||||
the certificate and private key into the cert_path and key_path, and
|
||||
returns the cert_path, key_path and passphrase bytes.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.DefaultClientCertSourceError: If any problem
|
||||
occurs when loading or saving the client certificate and key.
|
||||
"""
|
||||
if not has_default_client_cert_source(include_context_aware=True):
|
||||
raise exceptions.MutualTLSChannelError(
|
||||
"Default client encrypted cert source doesn't exist"
|
||||
)
|
||||
|
||||
def callback():
|
||||
try:
|
||||
(
|
||||
_,
|
||||
cert_bytes,
|
||||
key_bytes,
|
||||
passphrase_bytes,
|
||||
) = _mtls_helper.get_client_ssl_credentials(generate_encrypted_key=True)
|
||||
with open(cert_path, "wb") as cert_file:
|
||||
cert_file.write(cert_bytes)
|
||||
with open(key_path, "wb") as key_file:
|
||||
key_file.write(key_bytes)
|
||||
except (exceptions.ClientCertError, OSError) as caught_exc:
|
||||
new_exc = exceptions.MutualTLSChannelError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
return cert_path, key_path, passphrase_bytes
|
||||
|
||||
return callback
|
||||
|
||||
|
||||
def should_use_client_cert():
|
||||
"""Returns boolean for whether the client certificate should be used for mTLS.
|
||||
|
||||
This is a wrapper around _mtls_helper.check_use_client_cert().
|
||||
If GOOGLE_API_USE_CLIENT_CERTIFICATE is set to true or false, a corresponding
|
||||
bool value will be returned
|
||||
If GOOGLE_API_USE_CLIENT_CERTIFICATE is unset, the value will be inferred by
|
||||
reading a file pointed at by GOOGLE_API_CERTIFICATE_CONFIG, and verifying it
|
||||
contains a "workload" section. If so, the function will return True,
|
||||
otherwise False.
|
||||
|
||||
Returns:
|
||||
bool: indicating whether the client certificate should be used for mTLS.
|
||||
"""
|
||||
return _mtls_helper.check_use_client_cert()
|
||||
@@ -0,0 +1,634 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Transport adapter for Requests."""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import functools
|
||||
import http.client as http_client
|
||||
import logging
|
||||
import numbers
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError as caught_exc: # pragma: NO COVER
|
||||
raise ImportError(
|
||||
"The requests library is not installed from please install the requests package to use the requests transport."
|
||||
) from caught_exc
|
||||
import requests.adapters # pylint: disable=ungrouped-imports
|
||||
import requests.exceptions # pylint: disable=ungrouped-imports
|
||||
from requests.packages.urllib3.util.ssl_ import ( # type: ignore
|
||||
create_urllib3_context,
|
||||
) # pylint: disable=ungrouped-imports
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import exceptions
|
||||
from google.auth import transport
|
||||
from google.auth.transport import _mtls_helper
|
||||
import google.auth.transport._mtls_helper
|
||||
from google.oauth2 import service_account
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT = 120 # in seconds
|
||||
|
||||
|
||||
class _Response(transport.Response):
|
||||
"""Requests transport response adapter.
|
||||
|
||||
Args:
|
||||
response (requests.Response): The raw Requests response.
|
||||
"""
|
||||
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self._response.status_code
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return self._response.headers
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._response.content
|
||||
|
||||
|
||||
class TimeoutGuard(object):
|
||||
"""A context manager raising an error if the suite execution took too long.
|
||||
|
||||
Args:
|
||||
timeout (Union[None, Union[float, Tuple[float, float]]]):
|
||||
The maximum number of seconds a suite can run without the context
|
||||
manager raising a timeout exception on exit. If passed as a tuple,
|
||||
the smaller of the values is taken as a timeout. If ``None``, a
|
||||
timeout error is never raised.
|
||||
timeout_error_type (Optional[Exception]):
|
||||
The type of the error to raise on timeout. Defaults to
|
||||
:class:`requests.exceptions.Timeout`.
|
||||
"""
|
||||
|
||||
def __init__(self, timeout, timeout_error_type=requests.exceptions.Timeout):
|
||||
self._timeout = timeout
|
||||
self.remaining_timeout = timeout
|
||||
self._timeout_error_type = timeout_error_type
|
||||
|
||||
def __enter__(self):
|
||||
self._start = time.time()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
if exc_value:
|
||||
return # let the error bubble up automatically
|
||||
|
||||
if self._timeout is None:
|
||||
return # nothing to do, the timeout was not specified
|
||||
|
||||
elapsed = time.time() - self._start
|
||||
deadline_hit = False
|
||||
|
||||
if isinstance(self._timeout, numbers.Number):
|
||||
self.remaining_timeout = self._timeout - elapsed
|
||||
deadline_hit = self.remaining_timeout <= 0
|
||||
else:
|
||||
self.remaining_timeout = tuple(x - elapsed for x in self._timeout)
|
||||
deadline_hit = min(self.remaining_timeout) <= 0
|
||||
|
||||
if deadline_hit:
|
||||
raise self._timeout_error_type()
|
||||
|
||||
|
||||
class Request(transport.Request):
|
||||
"""Requests request adapter.
|
||||
|
||||
This class is used internally for making requests using various transports
|
||||
in a consistent way. If you use :class:`AuthorizedSession` you do not need
|
||||
to construct or use this class directly.
|
||||
|
||||
This class can be useful if you want to manually refresh a
|
||||
:class:`~google.auth.credentials.Credentials` instance::
|
||||
|
||||
import google.auth.transport.requests
|
||||
import requests
|
||||
|
||||
request = google.auth.transport.requests.Request()
|
||||
|
||||
credentials.refresh(request)
|
||||
|
||||
Args:
|
||||
session (requests.Session): An instance :class:`requests.Session` used
|
||||
to make HTTP requests. If not specified, a session will be created.
|
||||
|
||||
.. automethod:: __call__
|
||||
"""
|
||||
|
||||
def __init__(self, session: Optional[requests.Session] = None) -> None:
|
||||
if not session:
|
||||
session = requests.Session()
|
||||
|
||||
self.session = session
|
||||
|
||||
def __del__(self):
|
||||
try:
|
||||
if hasattr(self, "session") and self.session is not None:
|
||||
self.session.close()
|
||||
except TypeError:
|
||||
# NOTE: For certain Python binary built, the queue.Empty exception
|
||||
# might not be considered a normal Python exception causing
|
||||
# TypeError.
|
||||
pass
|
||||
|
||||
def __call__(
|
||||
self,
|
||||
url,
|
||||
method="GET",
|
||||
body=None,
|
||||
headers=None,
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
**kwargs
|
||||
):
|
||||
"""Make an HTTP request using requests.
|
||||
|
||||
Args:
|
||||
url (str): The URI to be requested.
|
||||
method (str): The HTTP method to use for the request. Defaults
|
||||
to 'GET'.
|
||||
body (bytes): The payload or body in HTTP request.
|
||||
headers (Mapping[str, str]): Request headers.
|
||||
timeout (Optional[int]): The number of seconds to wait for a
|
||||
response from the server. If not specified or if None, the
|
||||
requests default timeout will be used.
|
||||
kwargs: Additional arguments passed through to the underlying
|
||||
requests :meth:`~requests.Session.request` method.
|
||||
|
||||
Returns:
|
||||
google.auth.transport.Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: If any exception occurred.
|
||||
"""
|
||||
try:
|
||||
_helpers.request_log(_LOGGER, method, url, body, headers)
|
||||
response = self.session.request(
|
||||
method, url, data=body, headers=headers, timeout=timeout, **kwargs
|
||||
)
|
||||
_helpers.response_log(_LOGGER, response)
|
||||
return _Response(response)
|
||||
except requests.exceptions.RequestException as caught_exc:
|
||||
new_exc = exceptions.TransportError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
|
||||
class _MutualTlsAdapter(requests.adapters.HTTPAdapter):
|
||||
"""
|
||||
A TransportAdapter that enables mutual TLS.
|
||||
|
||||
Args:
|
||||
cert (bytes): client certificate in PEM format
|
||||
key (bytes): client private key in PEM format
|
||||
|
||||
Raises:
|
||||
ImportError: if certifi or pyOpenSSL is not installed
|
||||
OpenSSL.crypto.Error: if client cert or key is invalid
|
||||
"""
|
||||
|
||||
def __init__(self, cert, key):
|
||||
import certifi
|
||||
from OpenSSL import crypto
|
||||
import urllib3.contrib.pyopenssl # type: ignore
|
||||
|
||||
urllib3.contrib.pyopenssl.inject_into_urllib3()
|
||||
|
||||
pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
|
||||
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
|
||||
|
||||
ctx_poolmanager = create_urllib3_context()
|
||||
ctx_poolmanager.load_verify_locations(cafile=certifi.where())
|
||||
ctx_poolmanager._ctx.use_certificate(x509)
|
||||
ctx_poolmanager._ctx.use_privatekey(pkey)
|
||||
self._ctx_poolmanager = ctx_poolmanager
|
||||
|
||||
ctx_proxymanager = create_urllib3_context()
|
||||
ctx_proxymanager.load_verify_locations(cafile=certifi.where())
|
||||
ctx_proxymanager._ctx.use_certificate(x509)
|
||||
ctx_proxymanager._ctx.use_privatekey(pkey)
|
||||
self._ctx_proxymanager = ctx_proxymanager
|
||||
|
||||
super(_MutualTlsAdapter, self).__init__()
|
||||
|
||||
def init_poolmanager(self, *args, **kwargs):
|
||||
kwargs["ssl_context"] = self._ctx_poolmanager
|
||||
super(_MutualTlsAdapter, self).init_poolmanager(*args, **kwargs)
|
||||
|
||||
def proxy_manager_for(self, *args, **kwargs):
|
||||
kwargs["ssl_context"] = self._ctx_proxymanager
|
||||
return super(_MutualTlsAdapter, self).proxy_manager_for(*args, **kwargs)
|
||||
|
||||
|
||||
class _MutualTlsOffloadAdapter(requests.adapters.HTTPAdapter):
|
||||
"""
|
||||
A TransportAdapter that enables mutual TLS and offloads the client side
|
||||
signing operation to the signing library.
|
||||
|
||||
Args:
|
||||
enterprise_cert_file_path (str): the path to a enterprise cert JSON
|
||||
file. The file should contain the following field:
|
||||
|
||||
{
|
||||
"libs": {
|
||||
"signer_library": "...",
|
||||
"offload_library": "..."
|
||||
}
|
||||
}
|
||||
|
||||
Raises:
|
||||
ImportError: if certifi or pyOpenSSL is not installed
|
||||
google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel
|
||||
creation failed for any reason.
|
||||
"""
|
||||
|
||||
def __init__(self, enterprise_cert_file_path):
|
||||
import certifi
|
||||
from google.auth.transport import _custom_tls_signer
|
||||
|
||||
self.signer = _custom_tls_signer.CustomTlsSigner(enterprise_cert_file_path)
|
||||
self.signer.load_libraries()
|
||||
|
||||
import urllib3.contrib.pyopenssl
|
||||
|
||||
urllib3.contrib.pyopenssl.inject_into_urllib3()
|
||||
|
||||
poolmanager = create_urllib3_context()
|
||||
poolmanager.load_verify_locations(cafile=certifi.where())
|
||||
self.signer.attach_to_ssl_context(poolmanager)
|
||||
self._ctx_poolmanager = poolmanager
|
||||
|
||||
proxymanager = create_urllib3_context()
|
||||
proxymanager.load_verify_locations(cafile=certifi.where())
|
||||
self.signer.attach_to_ssl_context(proxymanager)
|
||||
self._ctx_proxymanager = proxymanager
|
||||
|
||||
super(_MutualTlsOffloadAdapter, self).__init__()
|
||||
|
||||
def init_poolmanager(self, *args, **kwargs):
|
||||
kwargs["ssl_context"] = self._ctx_poolmanager
|
||||
super(_MutualTlsOffloadAdapter, self).init_poolmanager(*args, **kwargs)
|
||||
|
||||
def proxy_manager_for(self, *args, **kwargs):
|
||||
kwargs["ssl_context"] = self._ctx_proxymanager
|
||||
return super(_MutualTlsOffloadAdapter, self).proxy_manager_for(*args, **kwargs)
|
||||
|
||||
|
||||
class AuthorizedSession(requests.Session):
|
||||
"""A Requests Session class with credentials.
|
||||
|
||||
This class is used to perform requests to API endpoints that require
|
||||
authorization::
|
||||
|
||||
from google.auth.transport.requests import AuthorizedSession
|
||||
|
||||
authed_session = AuthorizedSession(credentials)
|
||||
|
||||
response = authed_session.request(
|
||||
'GET', 'https://www.googleapis.com/storage/v1/b')
|
||||
|
||||
|
||||
The underlying :meth:`request` implementation handles adding the
|
||||
credentials' headers to the request and refreshing credentials as needed.
|
||||
|
||||
This class also supports mutual TLS via :meth:`configure_mtls_channel`
|
||||
method. In order to use this method, the `GOOGLE_API_USE_CLIENT_CERTIFICATE`
|
||||
environment variable must be explicitly set to ``true``, otherwise it does
|
||||
nothing. Assume the environment is set to ``true``, the method behaves in the
|
||||
following manner:
|
||||
|
||||
If client_cert_callback is provided, client certificate and private
|
||||
key are loaded using the callback; if client_cert_callback is None,
|
||||
application default SSL credentials will be used. Exceptions are raised if
|
||||
there are problems with the certificate, private key, or the loading process,
|
||||
so it should be called within a try/except block.
|
||||
|
||||
First we set the environment variable to ``true``, then create an :class:`AuthorizedSession`
|
||||
instance and specify the endpoints::
|
||||
|
||||
regular_endpoint = 'https://pubsub.googleapis.com/v1/projects/{my_project_id}/topics'
|
||||
mtls_endpoint = 'https://pubsub.mtls.googleapis.com/v1/projects/{my_project_id}/topics'
|
||||
|
||||
authed_session = AuthorizedSession(credentials)
|
||||
|
||||
Now we can pass a callback to :meth:`configure_mtls_channel`::
|
||||
|
||||
def my_cert_callback():
|
||||
# some code to load client cert bytes and private key bytes, both in
|
||||
# PEM format.
|
||||
some_code_to_load_client_cert_and_key()
|
||||
if loaded:
|
||||
return cert, key
|
||||
raise MyClientCertFailureException()
|
||||
|
||||
# Always call configure_mtls_channel within a try/except block.
|
||||
try:
|
||||
authed_session.configure_mtls_channel(my_cert_callback)
|
||||
except:
|
||||
# handle exceptions.
|
||||
|
||||
if authed_session.is_mtls:
|
||||
response = authed_session.request('GET', mtls_endpoint)
|
||||
else:
|
||||
response = authed_session.request('GET', regular_endpoint)
|
||||
|
||||
|
||||
You can alternatively use application default SSL credentials like this::
|
||||
|
||||
try:
|
||||
authed_session.configure_mtls_channel()
|
||||
except:
|
||||
# handle exceptions.
|
||||
|
||||
Args:
|
||||
credentials (google.auth.credentials.Credentials): The credentials to
|
||||
add to the request.
|
||||
refresh_status_codes (Sequence[int]): Which HTTP status codes indicate
|
||||
that credentials should be refreshed and the request should be
|
||||
retried.
|
||||
max_refresh_attempts (int): The maximum number of times to attempt to
|
||||
refresh the credentials and retry the request.
|
||||
refresh_timeout (Optional[int]): The timeout value in seconds for
|
||||
credential refresh HTTP requests.
|
||||
auth_request (google.auth.transport.requests.Request):
|
||||
(Optional) An instance of
|
||||
:class:`~google.auth.transport.requests.Request` used when
|
||||
refreshing credentials. If not passed,
|
||||
an instance of :class:`~google.auth.transport.requests.Request`
|
||||
is created.
|
||||
default_host (Optional[str]): A host like "pubsub.googleapis.com".
|
||||
This is used when a self-signed JWT is created from service
|
||||
account credentials.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credentials,
|
||||
refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES,
|
||||
max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS,
|
||||
refresh_timeout=None,
|
||||
auth_request=None,
|
||||
default_host=None,
|
||||
):
|
||||
super(AuthorizedSession, self).__init__()
|
||||
self.credentials = credentials
|
||||
self._refresh_status_codes = refresh_status_codes
|
||||
self._max_refresh_attempts = max_refresh_attempts
|
||||
self._refresh_timeout = refresh_timeout
|
||||
self._is_mtls = False
|
||||
self._default_host = default_host
|
||||
|
||||
if auth_request is None:
|
||||
self._auth_request_session = requests.Session()
|
||||
|
||||
# Using an adapter to make HTTP requests robust to network errors.
|
||||
# This adapter retrys HTTP requests when network errors occur
|
||||
# and the requests seems safely retryable.
|
||||
retry_adapter = requests.adapters.HTTPAdapter(max_retries=3)
|
||||
self._auth_request_session.mount("https://", retry_adapter)
|
||||
|
||||
# Do not pass `self` as the session here, as it can lead to
|
||||
# infinite recursion.
|
||||
auth_request = Request(self._auth_request_session)
|
||||
else:
|
||||
self._auth_request_session = None
|
||||
|
||||
# Request instance used by internal methods (for example,
|
||||
# credentials.refresh).
|
||||
self._auth_request = auth_request
|
||||
|
||||
# https://google.aip.dev/auth/4111
|
||||
# Attempt to use self-signed JWTs when a service account is used.
|
||||
if isinstance(self.credentials, service_account.Credentials):
|
||||
self.credentials._create_self_signed_jwt(
|
||||
"https://{}/".format(self._default_host) if self._default_host else None
|
||||
)
|
||||
|
||||
def configure_mtls_channel(self, client_cert_callback=None):
|
||||
"""Configure the client certificate and key for SSL connection.
|
||||
|
||||
The function does nothing unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` is
|
||||
explicitly set to `true`. In this case if client certificate and key are
|
||||
successfully obtained (from the given client_cert_callback or from application
|
||||
default SSL credentials), a :class:`_MutualTlsAdapter` instance will be mounted
|
||||
to "https://" prefix.
|
||||
|
||||
Args:
|
||||
client_cert_callback (Optional[Callable[[], (bytes, bytes)]]):
|
||||
The optional callback returns the client certificate and private
|
||||
key bytes both in PEM format.
|
||||
If the callback is None, application default SSL credentials
|
||||
will be used.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel
|
||||
creation failed for any reason.
|
||||
"""
|
||||
use_client_cert = google.auth.transport._mtls_helper.check_use_client_cert()
|
||||
if not use_client_cert:
|
||||
self._is_mtls = False
|
||||
return
|
||||
try:
|
||||
import OpenSSL
|
||||
except ImportError as caught_exc:
|
||||
new_exc = exceptions.MutualTLSChannelError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
try:
|
||||
(
|
||||
self._is_mtls,
|
||||
cert,
|
||||
key,
|
||||
) = google.auth.transport._mtls_helper.get_client_cert_and_key(
|
||||
client_cert_callback
|
||||
)
|
||||
|
||||
if self._is_mtls:
|
||||
mtls_adapter = _MutualTlsAdapter(cert, key)
|
||||
self._cached_cert = cert
|
||||
self.mount("https://", mtls_adapter)
|
||||
except (
|
||||
exceptions.ClientCertError,
|
||||
ImportError,
|
||||
OpenSSL.crypto.Error,
|
||||
) as caught_exc:
|
||||
new_exc = exceptions.MutualTLSChannelError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
def request(
|
||||
self,
|
||||
method,
|
||||
url,
|
||||
data=None,
|
||||
headers=None,
|
||||
max_allowed_time=None,
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
**kwargs
|
||||
):
|
||||
"""Implementation of Requests' request.
|
||||
|
||||
Args:
|
||||
timeout (Optional[Union[float, Tuple[float, float]]]):
|
||||
The amount of time in seconds to wait for the server response
|
||||
with each individual request. Can also be passed as a tuple
|
||||
``(connect_timeout, read_timeout)``. See :meth:`requests.Session.request`
|
||||
documentation for details.
|
||||
max_allowed_time (Optional[float]):
|
||||
If the method runs longer than this, a ``Timeout`` exception is
|
||||
automatically raised. Unlike the ``timeout`` parameter, this
|
||||
value applies to the total method execution time, even if
|
||||
multiple requests are made under the hood.
|
||||
|
||||
Mind that it is not guaranteed that the timeout error is raised
|
||||
at ``max_allowed_time``. It might take longer, for example, if
|
||||
an underlying request takes a lot of time, but the request
|
||||
itself does not timeout, e.g. if a large file is being
|
||||
transmitted. The timeout error will be raised after such
|
||||
request completes.
|
||||
Raises:
|
||||
google.auth.exceptions.MutualTLSChannelError: If mutual TLS
|
||||
channel creation fails for any reason.
|
||||
ValueError: If the client certificate is invalid.
|
||||
"""
|
||||
# pylint: disable=arguments-differ
|
||||
# Requests has a ton of arguments to request, but only two
|
||||
# (method, url) are required. We pass through all of the other
|
||||
# arguments to super, so no need to exhaustively list them here.
|
||||
|
||||
# Use a kwarg for this instead of an attribute to maintain
|
||||
# thread-safety.
|
||||
_credential_refresh_attempt = kwargs.pop("_credential_refresh_attempt", 0)
|
||||
|
||||
# Make a copy of the headers. They will be modified by the credentials
|
||||
# and we want to pass the original headers if we recurse.
|
||||
request_headers = headers.copy() if headers is not None else {}
|
||||
|
||||
# Do not apply the timeout unconditionally in order to not override the
|
||||
# _auth_request's default timeout.
|
||||
auth_request = (
|
||||
self._auth_request
|
||||
if timeout is None
|
||||
else functools.partial(self._auth_request, timeout=timeout)
|
||||
)
|
||||
|
||||
remaining_time = max_allowed_time
|
||||
|
||||
with TimeoutGuard(remaining_time) as guard:
|
||||
self.credentials.before_request(auth_request, method, url, request_headers)
|
||||
remaining_time = guard.remaining_timeout
|
||||
|
||||
with TimeoutGuard(remaining_time) as guard:
|
||||
_helpers.request_log(_LOGGER, method, url, data, headers)
|
||||
response = super(AuthorizedSession, self).request(
|
||||
method,
|
||||
url,
|
||||
data=data,
|
||||
headers=request_headers,
|
||||
timeout=timeout,
|
||||
**kwargs
|
||||
)
|
||||
remaining_time = guard.remaining_timeout
|
||||
|
||||
# If the response indicated that the credentials needed to be
|
||||
# refreshed, then refresh the credentials and re-attempt the
|
||||
# request.
|
||||
# A stored token may expire between the time it is retrieved and
|
||||
# the time the request is made, so we may need to try twice.
|
||||
if (
|
||||
response.status_code in self._refresh_status_codes
|
||||
and _credential_refresh_attempt < self._max_refresh_attempts
|
||||
):
|
||||
# Handle unauthorized permission error(401 status code)
|
||||
if response.status_code == http_client.UNAUTHORIZED:
|
||||
if self.is_mtls:
|
||||
(
|
||||
call_cert_bytes,
|
||||
call_key_bytes,
|
||||
cached_fingerprint,
|
||||
current_cert_fingerprint,
|
||||
) = _mtls_helper.check_parameters_for_unauthorized_response(
|
||||
self._cached_cert
|
||||
)
|
||||
if cached_fingerprint != current_cert_fingerprint:
|
||||
try:
|
||||
_LOGGER.info(
|
||||
"Client certificate has changed, reconfiguring mTLS "
|
||||
"channel."
|
||||
)
|
||||
self.configure_mtls_channel(
|
||||
lambda: (call_cert_bytes, call_key_bytes)
|
||||
)
|
||||
except Exception as e:
|
||||
_LOGGER.error("Failed to reconfigure mTLS channel: %s", e)
|
||||
raise exceptions.MutualTLSChannelError(
|
||||
"Failed to reconfigure mTLS channel"
|
||||
) from e
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Skipping reconfiguration of mTLS channel because the client"
|
||||
" certificate has not changed."
|
||||
)
|
||||
_LOGGER.info(
|
||||
"Refreshing credentials due to a %s response. Attempt %s/%s.",
|
||||
response.status_code,
|
||||
_credential_refresh_attempt + 1,
|
||||
self._max_refresh_attempts,
|
||||
)
|
||||
|
||||
# Do not apply the timeout unconditionally in order to not override the
|
||||
# _auth_request's default timeout.
|
||||
auth_request = (
|
||||
self._auth_request
|
||||
if timeout is None
|
||||
else functools.partial(self._auth_request, timeout=timeout)
|
||||
)
|
||||
|
||||
with TimeoutGuard(remaining_time) as guard:
|
||||
self.credentials.refresh(auth_request)
|
||||
remaining_time = guard.remaining_timeout
|
||||
|
||||
# Recurse. Pass in the original headers, not our modified set, but
|
||||
# do pass the adjusted max allowed time (i.e. the remaining total time).
|
||||
return self.request(
|
||||
method,
|
||||
url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
max_allowed_time=remaining_time,
|
||||
timeout=timeout,
|
||||
_credential_refresh_attempt=_credential_refresh_attempt + 1,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@property
|
||||
def is_mtls(self):
|
||||
"""Indicates if the created SSL channel is mutual TLS."""
|
||||
return self._is_mtls
|
||||
|
||||
def close(self):
|
||||
if self._auth_request_session is not None:
|
||||
self._auth_request_session.close()
|
||||
super(AuthorizedSession, self).close()
|
||||
@@ -0,0 +1,493 @@
|
||||
# Copyright 2016 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.
|
||||
|
||||
"""Transport adapter for urllib3."""
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import http.client as http_client
|
||||
import logging
|
||||
import warnings
|
||||
|
||||
# Certifi is Mozilla's certificate bundle. Urllib3 needs a certificate bundle
|
||||
# to verify HTTPS requests, and certifi is the recommended and most reliable
|
||||
# way to get a root certificate bundle. See
|
||||
# http://urllib3.readthedocs.io/en/latest/user-guide.html\
|
||||
# #certificate-verification
|
||||
# For more details.
|
||||
try:
|
||||
import certifi
|
||||
except ImportError: # pragma: NO COVER
|
||||
certifi = None # type: ignore
|
||||
|
||||
try:
|
||||
import urllib3 # type: ignore
|
||||
import urllib3.exceptions # type: ignore
|
||||
from packaging import version # type: ignore
|
||||
except ImportError as caught_exc: # pragma: NO COVER
|
||||
raise ImportError(
|
||||
""
|
||||
f"Error: {caught_exc}."
|
||||
" The 'google-auth' library requires the extras installed "
|
||||
"for urllib3 network transport."
|
||||
"\n"
|
||||
"Please install the necessary dependencies using pip:\n"
|
||||
" pip install google-auth[urllib3]\n"
|
||||
"\n"
|
||||
"(Note: Using '[urllib3]' ensures the specific dependencies needed for this feature are installed. "
|
||||
"We recommend running this command in your virtual environment.)"
|
||||
) from caught_exc
|
||||
|
||||
|
||||
from google.auth import _helpers
|
||||
from google.auth import exceptions
|
||||
from google.auth import transport
|
||||
from google.auth.transport import _mtls_helper
|
||||
from google.oauth2 import service_account
|
||||
|
||||
if version.parse(urllib3.__version__) >= version.parse("2.0.0"): # pragma: NO COVER
|
||||
RequestMethods = urllib3._request_methods.RequestMethods # type: ignore
|
||||
else: # pragma: NO COVER
|
||||
RequestMethods = urllib3.request.RequestMethods # type: ignore
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _Response(transport.Response):
|
||||
"""urllib3 transport response adapter.
|
||||
|
||||
Args:
|
||||
response (urllib3.response.HTTPResponse): The raw urllib3 response.
|
||||
"""
|
||||
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self._response.status
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
return self._response.headers
|
||||
|
||||
@property
|
||||
def data(self):
|
||||
return self._response.data
|
||||
|
||||
|
||||
class Request(transport.Request):
|
||||
"""urllib3 request adapter.
|
||||
|
||||
This class is used internally for making requests using various transports
|
||||
in a consistent way. If you use :class:`AuthorizedHttp` you do not need
|
||||
to construct or use this class directly.
|
||||
|
||||
This class can be useful if you want to manually refresh a
|
||||
:class:`~google.auth.credentials.Credentials` instance::
|
||||
|
||||
import google.auth.transport.urllib3
|
||||
import urllib3
|
||||
|
||||
http = urllib3.PoolManager()
|
||||
request = google.auth.transport.urllib3.Request(http)
|
||||
|
||||
credentials.refresh(request)
|
||||
|
||||
Args:
|
||||
http (urllib3.PoolManager): An instance of a urllib3 class that implements
|
||||
the request interface (e.g. :class:`urllib3.PoolManager`).
|
||||
|
||||
.. automethod:: __call__
|
||||
"""
|
||||
|
||||
def __init__(self, http):
|
||||
self.http = http
|
||||
|
||||
def __call__(
|
||||
self, url, method="GET", body=None, headers=None, timeout=None, **kwargs
|
||||
):
|
||||
"""Make an HTTP request using urllib3.
|
||||
|
||||
Args:
|
||||
url (str): The URI to be requested.
|
||||
method (str): The HTTP method to use for the request. Defaults
|
||||
to 'GET'.
|
||||
body (bytes): The payload / body in HTTP request.
|
||||
headers (Mapping[str, str]): Request headers.
|
||||
timeout (Optional[int]): The number of seconds to wait for a
|
||||
response from the server. If not specified or if None, the
|
||||
urllib3 default timeout will be used.
|
||||
kwargs: Additional arguments passed throught to the underlying
|
||||
urllib3 :meth:`urlopen` method.
|
||||
|
||||
Returns:
|
||||
google.auth.transport.Response: The HTTP response.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.TransportError: If any exception occurred.
|
||||
"""
|
||||
# urllib3 uses a sentinel default value for timeout, so only set it if
|
||||
# specified.
|
||||
if timeout is not None:
|
||||
kwargs["timeout"] = timeout
|
||||
|
||||
try:
|
||||
_helpers.request_log(_LOGGER, method, url, body, headers)
|
||||
response = self.http.request(
|
||||
method, url, body=body, headers=headers, **kwargs
|
||||
)
|
||||
_helpers.response_log(_LOGGER, response)
|
||||
return _Response(response)
|
||||
except urllib3.exceptions.HTTPError as caught_exc:
|
||||
new_exc = exceptions.TransportError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
|
||||
def _make_default_http():
|
||||
if certifi is not None:
|
||||
return urllib3.PoolManager(cert_reqs="CERT_REQUIRED", ca_certs=certifi.where())
|
||||
else:
|
||||
return urllib3.PoolManager()
|
||||
|
||||
|
||||
def _make_mutual_tls_http(cert, key):
|
||||
"""Create a mutual TLS HTTP connection with the given client cert and key.
|
||||
See https://github.com/urllib3/urllib3/issues/474#issuecomment-253168415
|
||||
|
||||
Args:
|
||||
cert (bytes): client certificate in PEM format
|
||||
key (bytes): client private key in PEM format
|
||||
|
||||
Returns:
|
||||
urllib3.PoolManager: Mutual TLS HTTP connection.
|
||||
|
||||
Raises:
|
||||
ImportError: If certifi or pyOpenSSL is not installed.
|
||||
OpenSSL.crypto.Error: If the cert or key is invalid.
|
||||
"""
|
||||
import certifi
|
||||
from OpenSSL import crypto
|
||||
import urllib3.contrib.pyopenssl # type: ignore
|
||||
|
||||
urllib3.contrib.pyopenssl.inject_into_urllib3()
|
||||
ctx = urllib3.util.ssl_.create_urllib3_context()
|
||||
ctx.load_verify_locations(cafile=certifi.where())
|
||||
|
||||
pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key)
|
||||
x509 = crypto.load_certificate(crypto.FILETYPE_PEM, cert)
|
||||
|
||||
ctx._ctx.use_certificate(x509)
|
||||
ctx._ctx.use_privatekey(pkey)
|
||||
|
||||
http = urllib3.PoolManager(ssl_context=ctx)
|
||||
return http
|
||||
|
||||
|
||||
class AuthorizedHttp(RequestMethods): # type: ignore
|
||||
"""A urllib3 HTTP class with credentials.
|
||||
|
||||
This class is used to perform requests to API endpoints that require
|
||||
authorization::
|
||||
|
||||
from google.auth.transport.urllib3 import AuthorizedHttp
|
||||
|
||||
authed_http = AuthorizedHttp(credentials)
|
||||
|
||||
response = authed_http.request(
|
||||
'GET', 'https://www.googleapis.com/storage/v1/b')
|
||||
|
||||
This class implements the urllib3 request interface and can be
|
||||
used just like any other :class:`urllib3.PoolManager`.
|
||||
|
||||
The underlying :meth:`urlopen` implementation handles adding the
|
||||
credentials' headers to the request and refreshing credentials as needed.
|
||||
|
||||
This class also supports mutual TLS via :meth:`configure_mtls_channel`
|
||||
method. In order to use this method, the `GOOGLE_API_USE_CLIENT_CERTIFICATE`
|
||||
environment variable must be explicitly set to `true`, otherwise it does
|
||||
nothing. Assume the environment is set to `true`, the method behaves in the
|
||||
following manner:
|
||||
If client_cert_callback is provided, client certificate and private
|
||||
key are loaded using the callback; if client_cert_callback is None,
|
||||
application default SSL credentials will be used. Exceptions are raised if
|
||||
there are problems with the certificate, private key, or the loading process,
|
||||
so it should be called within a try/except block.
|
||||
|
||||
First we set the environment variable to `true`, then create an :class:`AuthorizedHttp`
|
||||
instance and specify the endpoints::
|
||||
|
||||
regular_endpoint = 'https://pubsub.googleapis.com/v1/projects/{my_project_id}/topics'
|
||||
mtls_endpoint = 'https://pubsub.mtls.googleapis.com/v1/projects/{my_project_id}/topics'
|
||||
|
||||
authed_http = AuthorizedHttp(credentials)
|
||||
|
||||
Now we can pass a callback to :meth:`configure_mtls_channel`::
|
||||
|
||||
def my_cert_callback():
|
||||
# some code to load client cert bytes and private key bytes, both in
|
||||
# PEM format.
|
||||
some_code_to_load_client_cert_and_key()
|
||||
if loaded:
|
||||
return cert, key
|
||||
raise MyClientCertFailureException()
|
||||
|
||||
# Always call configure_mtls_channel within a try/except block.
|
||||
try:
|
||||
is_mtls = authed_http.configure_mtls_channel(my_cert_callback)
|
||||
except:
|
||||
# handle exceptions.
|
||||
|
||||
if is_mtls:
|
||||
response = authed_http.request('GET', mtls_endpoint)
|
||||
else:
|
||||
response = authed_http.request('GET', regular_endpoint)
|
||||
|
||||
You can alternatively use application default SSL credentials like this::
|
||||
|
||||
try:
|
||||
is_mtls = authed_http.configure_mtls_channel()
|
||||
except:
|
||||
# handle exceptions.
|
||||
|
||||
Args:
|
||||
credentials (google.auth.credentials.Credentials): The credentials to
|
||||
add to the request.
|
||||
http (urllib3.PoolManager): The underlying HTTP object to
|
||||
use to make requests. If not specified, a
|
||||
:class:`urllib3.PoolManager` instance will be constructed with
|
||||
sane defaults.
|
||||
refresh_status_codes (Sequence[int]): Which HTTP status codes indicate
|
||||
that credentials should be refreshed and the request should be
|
||||
retried.
|
||||
max_refresh_attempts (int): The maximum number of times to attempt to
|
||||
refresh the credentials and retry the request.
|
||||
default_host (Optional[str]): A host like "pubsub.googleapis.com".
|
||||
This is used when a self-signed JWT is created from service
|
||||
account credentials.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
credentials,
|
||||
http=None,
|
||||
refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES,
|
||||
max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS,
|
||||
default_host=None,
|
||||
):
|
||||
if http is None:
|
||||
self.http = _make_default_http()
|
||||
self._has_user_provided_http = False
|
||||
else:
|
||||
self.http = http
|
||||
self._has_user_provided_http = True
|
||||
|
||||
self.credentials = credentials
|
||||
self._refresh_status_codes = refresh_status_codes
|
||||
self._max_refresh_attempts = max_refresh_attempts
|
||||
self._default_host = default_host
|
||||
# Request instance used by internal methods (for example,
|
||||
# credentials.refresh).
|
||||
self._request = Request(self.http)
|
||||
self._is_mtls = False
|
||||
|
||||
# https://google.aip.dev/auth/4111
|
||||
# Attempt to use self-signed JWTs when a service account is used.
|
||||
if isinstance(self.credentials, service_account.Credentials):
|
||||
self.credentials._create_self_signed_jwt(
|
||||
"https://{}/".format(self._default_host) if self._default_host else None
|
||||
)
|
||||
|
||||
super(AuthorizedHttp, self).__init__()
|
||||
|
||||
def configure_mtls_channel(self, client_cert_callback=None):
|
||||
"""Configures mutual TLS channel using the given client_cert_callback or
|
||||
application default SSL credentials. The behavior is controlled by
|
||||
`GOOGLE_API_USE_CLIENT_CERTIFICATE` environment variable.
|
||||
(1) If the environment variable value is `true`, the function returns True
|
||||
if the channel is mutual TLS and False otherwise. The `http` provided
|
||||
in the constructor will be overwritten.
|
||||
(2) If the environment variable is not set or `false`, the function does
|
||||
nothing and it always return False.
|
||||
|
||||
Args:
|
||||
client_cert_callback (Optional[Callable[[], (bytes, bytes)]]):
|
||||
The optional callback returns the client certificate and private
|
||||
key bytes both in PEM format.
|
||||
If the callback is None, application default SSL credentials
|
||||
will be used.
|
||||
|
||||
Returns:
|
||||
True if the channel is mutual TLS and False otherwise.
|
||||
|
||||
Raises:
|
||||
google.auth.exceptions.MutualTLSChannelError: If mutual TLS channel
|
||||
creation failed for any reason.
|
||||
"""
|
||||
use_client_cert = transport._mtls_helper.check_use_client_cert()
|
||||
if not use_client_cert:
|
||||
self._is_mtls = False
|
||||
return False
|
||||
else:
|
||||
self._is_mtls = True
|
||||
try:
|
||||
import OpenSSL
|
||||
except ImportError as caught_exc:
|
||||
new_exc = exceptions.MutualTLSChannelError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
try:
|
||||
found_cert_key, cert, key = transport._mtls_helper.get_client_cert_and_key(
|
||||
client_cert_callback
|
||||
)
|
||||
|
||||
if found_cert_key:
|
||||
self.http = _make_mutual_tls_http(cert, key)
|
||||
self._cached_cert = cert
|
||||
else:
|
||||
self.http = _make_default_http()
|
||||
except (
|
||||
exceptions.ClientCertError,
|
||||
ImportError,
|
||||
OpenSSL.crypto.Error,
|
||||
) as caught_exc:
|
||||
new_exc = exceptions.MutualTLSChannelError(caught_exc)
|
||||
raise new_exc from caught_exc
|
||||
|
||||
if self._has_user_provided_http:
|
||||
self._has_user_provided_http = False
|
||||
warnings.warn(
|
||||
"`http` provided in the constructor is overwritten", UserWarning
|
||||
)
|
||||
|
||||
return found_cert_key
|
||||
|
||||
def urlopen(self, method, url, body=None, headers=None, **kwargs):
|
||||
"""Implementation of urllib3's urlopen."""
|
||||
# pylint: disable=arguments-differ
|
||||
# We use kwargs to collect additional args that we don't need to
|
||||
# introspect here. However, we do explicitly collect the two
|
||||
# positional arguments.
|
||||
|
||||
# Use a kwarg for this instead of an attribute to maintain
|
||||
# thread-safety.
|
||||
_credential_refresh_attempt = kwargs.pop("_credential_refresh_attempt", 0)
|
||||
|
||||
if headers is None:
|
||||
headers = self.headers
|
||||
|
||||
use_mtls = False
|
||||
if self._is_mtls:
|
||||
MTLS_URL_PREFIXES = ["mtls.googleapis.com", "mtls.sandbox.googleapis.com"]
|
||||
use_mtls = any([prefix in url for prefix in MTLS_URL_PREFIXES])
|
||||
|
||||
# Make a copy of the headers. They will be modified by the credentials
|
||||
# and we want to pass the original headers if we recurse.
|
||||
request_headers = headers.copy()
|
||||
|
||||
self.credentials.before_request(self._request, method, url, request_headers)
|
||||
|
||||
response = self.http.urlopen(
|
||||
method, url, body=body, headers=request_headers, **kwargs
|
||||
)
|
||||
|
||||
# If the response indicated that the credentials needed to be
|
||||
# refreshed, then refresh the credentials and re-attempt the
|
||||
# request.
|
||||
# A stored token may expire between the time it is retrieved and
|
||||
# the time the request is made, so we may need to try twice.
|
||||
# The reason urllib3's retries aren't used is because they
|
||||
# don't allow you to modify the request headers. :/
|
||||
if (
|
||||
response.status in self._refresh_status_codes
|
||||
and _credential_refresh_attempt < self._max_refresh_attempts
|
||||
):
|
||||
if response.status == http_client.UNAUTHORIZED:
|
||||
if use_mtls:
|
||||
(
|
||||
call_cert_bytes,
|
||||
call_key_bytes,
|
||||
cached_fingerprint,
|
||||
current_cert_fingerprint,
|
||||
) = _mtls_helper.check_parameters_for_unauthorized_response(
|
||||
self._cached_cert
|
||||
)
|
||||
if cached_fingerprint != current_cert_fingerprint:
|
||||
try:
|
||||
_LOGGER.info(
|
||||
"Client certificate has changed, reconfiguring mTLS "
|
||||
"channel."
|
||||
)
|
||||
self.configure_mtls_channel(
|
||||
client_cert_callback=lambda: (
|
||||
call_cert_bytes,
|
||||
call_key_bytes,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
_LOGGER.error("Failed to reconfigure mTLS channel: %s", e)
|
||||
raise exceptions.MutualTLSChannelError(
|
||||
"Failed to reconfigure mTLS channel"
|
||||
) from e
|
||||
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Skipping reconfiguration of mTLS channel because the "
|
||||
"client certificate has not changed."
|
||||
)
|
||||
|
||||
_LOGGER.info(
|
||||
"Refreshing credentials due to a %s response. Attempt %s/%s.",
|
||||
response.status,
|
||||
_credential_refresh_attempt + 1,
|
||||
self._max_refresh_attempts,
|
||||
)
|
||||
|
||||
self.credentials.refresh(self._request)
|
||||
|
||||
# Recurse. Pass in the original headers, not our modified set.
|
||||
return self.urlopen(
|
||||
method,
|
||||
url,
|
||||
body=body,
|
||||
headers=headers,
|
||||
_credential_refresh_attempt=_credential_refresh_attempt + 1,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
# Proxy methods for compliance with the urllib3.PoolManager interface
|
||||
|
||||
def __enter__(self):
|
||||
"""Proxy to ``self.http``."""
|
||||
return self.http.__enter__()
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Proxy to ``self.http``."""
|
||||
return self.http.__exit__(exc_type, exc_val, exc_tb)
|
||||
|
||||
def __del__(self):
|
||||
if hasattr(self, "http") and self.http is not None:
|
||||
self.http.clear()
|
||||
|
||||
@property
|
||||
def headers(self):
|
||||
"""Proxy to ``self.http``."""
|
||||
return self.http.headers
|
||||
|
||||
@headers.setter
|
||||
def headers(self, value):
|
||||
"""Proxy to ``self.http``."""
|
||||
self.http.headers = value
|
||||
15
venv/lib/python3.12/site-packages/google/auth/version.py
Normal file
15
venv/lib/python3.12/site-packages/google/auth/version.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# 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.
|
||||
|
||||
__version__ = "2.49.2"
|
||||
26
venv/lib/python3.12/site-packages/google/genai/__init__.py
Normal file
26
venv/lib/python3.12/site-packages/google/genai/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
"""Google Gen AI SDK"""
|
||||
|
||||
from . import interactions
|
||||
from . import types
|
||||
from . import version
|
||||
from .client import Client
|
||||
|
||||
|
||||
__version__ = version.__version__
|
||||
|
||||
__all__ = ['Client']
|
||||
55
venv/lib/python3.12/site-packages/google/genai/_adapters.py
Normal file
55
venv/lib/python3.12/site-packages/google/genai/_adapters.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# Copyright 2025 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 typing
|
||||
|
||||
from ._mcp_utils import mcp_to_gemini_tools
|
||||
from .types import FunctionCall, Tool
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from mcp import types as mcp_types
|
||||
from mcp import ClientSession
|
||||
|
||||
|
||||
class McpToGenAiToolAdapter:
|
||||
"""Adapter for working with MCP tools in a GenAI client."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: "mcp.ClientSession", # type: ignore # noqa: F821
|
||||
list_tools_result: "mcp_types.ListToolsResult", # type: ignore
|
||||
) -> None:
|
||||
self._mcp_session = session
|
||||
self._list_tools_result = list_tools_result
|
||||
|
||||
async def call_tool(
|
||||
self, function_call: FunctionCall
|
||||
) -> "mcp_types.CallToolResult": # type: ignore
|
||||
"""Calls a function on the MCP server."""
|
||||
name = function_call.name if function_call.name else ""
|
||||
arguments = dict(function_call.args) if function_call.args else {}
|
||||
|
||||
return typing.cast(
|
||||
"mcp_types.CallToolResult",
|
||||
await self._mcp_session.call_tool(
|
||||
name=name,
|
||||
arguments=arguments,
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def tools(self) -> list[Tool]:
|
||||
"""Returns a list of Google GenAI tools."""
|
||||
return mcp_to_gemini_tools(self._list_tools_result.tools)
|
||||
2157
venv/lib/python3.12/site-packages/google/genai/_api_client.py
Normal file
2157
venv/lib/python3.12/site-packages/google/genai/_api_client.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
"""Utilities for the API Modules of the Google Gen AI SDK."""
|
||||
|
||||
from typing import Optional
|
||||
from . import _api_client
|
||||
|
||||
|
||||
class BaseModule:
|
||||
|
||||
def __init__(self, api_client_: _api_client.BaseApiClient):
|
||||
self._api_client = api_client_
|
||||
|
||||
@property
|
||||
def vertexai(self) -> Optional[bool]:
|
||||
return self._api_client.vertexai
|
||||
@@ -0,0 +1,325 @@
|
||||
# Copyright 2025 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 inspect
|
||||
import sys
|
||||
import types as builtin_types
|
||||
import typing
|
||||
from typing import _GenericAlias, Any, Callable, get_args, get_origin, Literal, Optional, Union # type: ignore[attr-defined]
|
||||
|
||||
import pydantic
|
||||
|
||||
from . import _extra_utils
|
||||
from . import types
|
||||
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
VersionedUnionType = builtin_types.UnionType
|
||||
else:
|
||||
VersionedUnionType = typing._UnionGenericAlias # type: ignore[attr-defined]
|
||||
|
||||
|
||||
__all__ = [
|
||||
'_py_builtin_type_to_schema_type',
|
||||
'_raise_for_unsupported_param',
|
||||
'_handle_params_as_deferred_annotations',
|
||||
'_add_unevaluated_items_to_fixed_len_tuple_schema',
|
||||
'_is_builtin_primitive_or_compound',
|
||||
'_is_default_value_compatible',
|
||||
'_parse_schema_from_parameter',
|
||||
'_get_required_fields',
|
||||
]
|
||||
|
||||
_py_builtin_type_to_schema_type = {
|
||||
str: types.Type.STRING,
|
||||
int: types.Type.INTEGER,
|
||||
float: types.Type.NUMBER,
|
||||
bool: types.Type.BOOLEAN,
|
||||
list: types.Type.ARRAY,
|
||||
dict: types.Type.OBJECT,
|
||||
None: types.Type.NULL,
|
||||
}
|
||||
|
||||
|
||||
def _raise_for_unsupported_param(
|
||||
param: inspect.Parameter, func_name: str, exception: Union[Exception, type[Exception]]
|
||||
) -> None:
|
||||
raise ValueError(
|
||||
f'Failed to parse the parameter {param} of function {func_name} for'
|
||||
' automatic function calling.Automatic function calling works best with'
|
||||
' simpler function signature schema, consider manually parsing your'
|
||||
f' function declaration for function {func_name}.'
|
||||
) from exception
|
||||
|
||||
|
||||
def _handle_params_as_deferred_annotations(param: inspect.Parameter, annotation_under_future: dict[str, Any], name: str) -> inspect.Parameter:
|
||||
"""Catches the case when type hints are stored as strings."""
|
||||
if isinstance(param.annotation, str):
|
||||
param = param.replace(annotation=annotation_under_future[name])
|
||||
return param
|
||||
|
||||
|
||||
def _add_unevaluated_items_to_fixed_len_tuple_schema(
|
||||
json_schema: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
if (
|
||||
json_schema.get('maxItems')
|
||||
and (
|
||||
json_schema.get('prefixItems')
|
||||
and len(json_schema['prefixItems']) == json_schema['maxItems']
|
||||
)
|
||||
and json_schema.get('type') == 'array'
|
||||
):
|
||||
json_schema['unevaluatedItems'] = False
|
||||
return json_schema
|
||||
|
||||
|
||||
def _is_builtin_primitive_or_compound(
|
||||
annotation: inspect.Parameter.annotation, # type: ignore[valid-type]
|
||||
) -> bool:
|
||||
return annotation in _py_builtin_type_to_schema_type.keys()
|
||||
|
||||
|
||||
def _is_default_value_compatible(
|
||||
default_value: Any, annotation: inspect.Parameter.annotation # type: ignore[valid-type]
|
||||
) -> bool:
|
||||
# None type is expected to be handled external to this function
|
||||
if _is_builtin_primitive_or_compound(annotation):
|
||||
return isinstance(default_value, annotation)
|
||||
|
||||
if (
|
||||
isinstance(annotation, _GenericAlias)
|
||||
or isinstance(annotation, builtin_types.GenericAlias)
|
||||
or isinstance(annotation, VersionedUnionType)
|
||||
):
|
||||
origin = get_origin(annotation)
|
||||
if origin in (Union, VersionedUnionType): # type: ignore[comparison-overlap]
|
||||
return any(
|
||||
_is_default_value_compatible(default_value, arg)
|
||||
for arg in get_args(annotation)
|
||||
)
|
||||
|
||||
if origin is dict: # type: ignore[comparison-overlap]
|
||||
return isinstance(default_value, dict)
|
||||
|
||||
if origin is list: # type: ignore[comparison-overlap]
|
||||
if not isinstance(default_value, list):
|
||||
return False
|
||||
# most tricky case, element in list is union type
|
||||
# need to apply any logic within all
|
||||
# see test case test_generic_alias_complex_array_with_default_value
|
||||
# a: typing.List[int | str | float | bool]
|
||||
# default_value: [1, 'a', 1.1, True]
|
||||
return all(
|
||||
any(
|
||||
_is_default_value_compatible(item, arg)
|
||||
for arg in get_args(annotation)
|
||||
)
|
||||
for item in default_value
|
||||
)
|
||||
|
||||
if origin is Literal: # type: ignore[comparison-overlap]
|
||||
return default_value in get_args(annotation)
|
||||
|
||||
# return False for any other unrecognized annotation
|
||||
return False
|
||||
|
||||
|
||||
def _parse_schema_from_parameter( # type: ignore[return]
|
||||
api_option: Literal['VERTEX_AI', 'GEMINI_API'],
|
||||
param: inspect.Parameter,
|
||||
func_name: str,
|
||||
) -> types.Schema:
|
||||
"""parse schema from parameter.
|
||||
|
||||
from the simplest case to the most complex case.
|
||||
"""
|
||||
schema = types.Schema()
|
||||
default_value_error_msg = (
|
||||
f'Default value {param.default} of parameter {param} of function'
|
||||
f' {func_name} is not compatible with the parameter annotation'
|
||||
f' {param.annotation}.'
|
||||
)
|
||||
if _is_builtin_primitive_or_compound(param.annotation):
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
if not _is_default_value_compatible(param.default, param.annotation):
|
||||
raise ValueError(default_value_error_msg)
|
||||
schema.default = param.default
|
||||
schema.type = _py_builtin_type_to_schema_type[param.annotation]
|
||||
return schema
|
||||
if (
|
||||
isinstance(param.annotation, VersionedUnionType)
|
||||
# only parse simple UnionType, example int | str | float | bool
|
||||
# complex UnionType will be invoked in raise branch
|
||||
and all(
|
||||
(_is_builtin_primitive_or_compound(arg) or arg is type(None))
|
||||
for arg in get_args(param.annotation)
|
||||
)
|
||||
):
|
||||
schema.type = _py_builtin_type_to_schema_type[dict]
|
||||
schema.any_of = []
|
||||
unique_types = set()
|
||||
for arg in get_args(param.annotation):
|
||||
if arg.__name__ == 'NoneType': # Optional type
|
||||
schema.nullable = True
|
||||
continue
|
||||
schema_in_any_of = _parse_schema_from_parameter(
|
||||
api_option,
|
||||
inspect.Parameter(
|
||||
'item', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=arg
|
||||
),
|
||||
func_name,
|
||||
)
|
||||
if (
|
||||
schema_in_any_of.model_dump_json(exclude_none=True)
|
||||
not in unique_types
|
||||
):
|
||||
schema.any_of.append(schema_in_any_of)
|
||||
unique_types.add(schema_in_any_of.model_dump_json(exclude_none=True))
|
||||
if len(schema.any_of) == 1: # param: list | None -> Array
|
||||
schema.type = schema.any_of[0].type
|
||||
schema.any_of = None
|
||||
if (
|
||||
param.default is not inspect.Parameter.empty
|
||||
and param.default is not None
|
||||
):
|
||||
if not _is_default_value_compatible(param.default, param.annotation):
|
||||
raise ValueError(default_value_error_msg)
|
||||
schema.default = param.default
|
||||
return schema
|
||||
if isinstance(param.annotation, _GenericAlias) or isinstance(
|
||||
param.annotation, builtin_types.GenericAlias
|
||||
):
|
||||
origin = get_origin(param.annotation)
|
||||
args = get_args(param.annotation)
|
||||
if origin is dict:
|
||||
schema.type = _py_builtin_type_to_schema_type[dict]
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
if not _is_default_value_compatible(param.default, param.annotation):
|
||||
raise ValueError(default_value_error_msg)
|
||||
schema.default = param.default
|
||||
return schema
|
||||
if origin is Literal:
|
||||
if not all(isinstance(arg, str) for arg in args):
|
||||
raise ValueError(
|
||||
f'Literal type {param.annotation} must be a list of strings.'
|
||||
)
|
||||
schema.type = _py_builtin_type_to_schema_type[str]
|
||||
schema.enum = list(args)
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
if not _is_default_value_compatible(param.default, param.annotation):
|
||||
raise ValueError(default_value_error_msg)
|
||||
schema.default = param.default
|
||||
return schema
|
||||
if origin is list:
|
||||
schema.type = _py_builtin_type_to_schema_type[list]
|
||||
schema.items = _parse_schema_from_parameter(
|
||||
api_option,
|
||||
inspect.Parameter(
|
||||
'item',
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
annotation=args[0],
|
||||
),
|
||||
func_name,
|
||||
)
|
||||
if param.default is not inspect.Parameter.empty:
|
||||
if not _is_default_value_compatible(param.default, param.annotation):
|
||||
raise ValueError(default_value_error_msg)
|
||||
schema.default = param.default
|
||||
return schema
|
||||
if origin is Union:
|
||||
schema.any_of = []
|
||||
schema.type = _py_builtin_type_to_schema_type[dict]
|
||||
unique_types = set()
|
||||
for arg in args:
|
||||
# The first check is for NoneType in Python 3.9, since the __name__
|
||||
# attribute is not available in Python 3.9
|
||||
if type(arg) is type(None) or (
|
||||
hasattr(arg, '__name__') and arg.__name__ == 'NoneType'
|
||||
): # Optional type
|
||||
schema.nullable = True
|
||||
continue
|
||||
schema_in_any_of = _parse_schema_from_parameter(
|
||||
api_option,
|
||||
inspect.Parameter(
|
||||
'item',
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
annotation=arg,
|
||||
),
|
||||
func_name,
|
||||
)
|
||||
if (
|
||||
len(param.annotation.__args__) == 2
|
||||
and type(None) in param.annotation.__args__
|
||||
): # Optional type
|
||||
for optional_arg in param.annotation.__args__:
|
||||
if (
|
||||
hasattr(optional_arg, '__origin__')
|
||||
and optional_arg.__origin__ is list
|
||||
):
|
||||
# Optional type with list, for example Optional[list[str]]
|
||||
schema.items = schema_in_any_of.items
|
||||
if (
|
||||
schema_in_any_of.model_dump_json(exclude_none=True)
|
||||
not in unique_types
|
||||
):
|
||||
schema.any_of.append(schema_in_any_of)
|
||||
unique_types.add(schema_in_any_of.model_dump_json(exclude_none=True))
|
||||
if len(schema.any_of) == 1: # param: Union[List, None] -> Array
|
||||
schema.type = schema.any_of[0].type
|
||||
schema.any_of = None
|
||||
if (
|
||||
param.default is not None
|
||||
and param.default is not inspect.Parameter.empty
|
||||
):
|
||||
if not _is_default_value_compatible(param.default, param.annotation):
|
||||
raise ValueError(default_value_error_msg)
|
||||
schema.default = param.default
|
||||
return schema
|
||||
# all other generic alias will be invoked in raise branch
|
||||
if (
|
||||
# for user defined class, we only support pydantic model
|
||||
_extra_utils.is_annotation_pydantic_model(param.annotation)
|
||||
):
|
||||
if (
|
||||
param.default is not inspect.Parameter.empty
|
||||
and param.default is not None
|
||||
):
|
||||
schema.default = param.default
|
||||
schema.type = _py_builtin_type_to_schema_type[dict]
|
||||
schema.properties = {}
|
||||
for field_name, field_info in param.annotation.model_fields.items():
|
||||
schema.properties[field_name] = _parse_schema_from_parameter(
|
||||
api_option,
|
||||
inspect.Parameter(
|
||||
field_name,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
annotation=field_info.annotation,
|
||||
),
|
||||
func_name,
|
||||
)
|
||||
schema.required = _get_required_fields(schema)
|
||||
return schema
|
||||
_raise_for_unsupported_param(param, func_name, ValueError)
|
||||
|
||||
|
||||
def _get_required_fields(schema: types.Schema) -> Optional[list[str]]:
|
||||
if not schema.properties:
|
||||
return None
|
||||
return [
|
||||
field_name
|
||||
for field_name, field_schema in schema.properties.items()
|
||||
if not field_schema.nullable and field_schema.default is None
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
"""Base transformers for Google GenAI SDK."""
|
||||
import base64
|
||||
|
||||
# Some fields don't accept url safe base64 encoding.
|
||||
# We shouldn't use this transformer if the backend adhere to Cloud Type
|
||||
# format https://cloud.google.com/docs/discovery/type-format.
|
||||
# TODO(b/389133914,b/390320301): Remove the hack after backend fix the issue.
|
||||
def t_bytes(data: bytes) -> str:
|
||||
if not isinstance(data, bytes):
|
||||
return data
|
||||
return base64.b64encode(data).decode('ascii')
|
||||
50
venv/lib/python3.12/site-packages/google/genai/_base_url.py
Normal file
50
venv/lib/python3.12/site-packages/google/genai/_base_url.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Copyright 2025 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 os
|
||||
from typing import Optional
|
||||
|
||||
from .types import HttpOptions
|
||||
|
||||
_default_base_gemini_url = None
|
||||
_default_base_vertex_url = None
|
||||
|
||||
|
||||
def set_default_base_urls(
|
||||
gemini_url: Optional[str], vertex_url: Optional[str]
|
||||
) -> None:
|
||||
"""Overrides the base URLs for the Gemini API and Vertex AI API."""
|
||||
global _default_base_gemini_url, _default_base_vertex_url
|
||||
_default_base_gemini_url = gemini_url
|
||||
_default_base_vertex_url = vertex_url
|
||||
|
||||
|
||||
def get_base_url(
|
||||
vertexai: bool,
|
||||
http_options: Optional[HttpOptions] = None,
|
||||
) -> Optional[str]:
|
||||
"""Returns the default base URL based on the following priority.
|
||||
|
||||
1. Base URLs set via HttpOptions.
|
||||
2. Base URLs set via the latest call to setDefaultBaseUrls.
|
||||
3. Base URLs set via environment variables.
|
||||
"""
|
||||
if http_options and http_options.base_url:
|
||||
return http_options.base_url
|
||||
|
||||
if vertexai:
|
||||
return _default_base_vertex_url or os.getenv('GOOGLE_VERTEX_BASE_URL')
|
||||
else:
|
||||
return _default_base_gemini_url or os.getenv('GOOGLE_GEMINI_BASE_URL')
|
||||
847
venv/lib/python3.12/site-packages/google/genai/_common.py
Normal file
847
venv/lib/python3.12/site-packages/google/genai/_common.py
Normal file
@@ -0,0 +1,847 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
"""Common utilities for the SDK."""
|
||||
|
||||
import base64
|
||||
import collections.abc
|
||||
import datetime
|
||||
import enum
|
||||
import functools
|
||||
import logging
|
||||
import re
|
||||
import typing
|
||||
from typing import Any, Callable, FrozenSet, Optional, Union, get_args, get_origin
|
||||
import uuid
|
||||
import warnings
|
||||
import pydantic
|
||||
from pydantic import alias_generators
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
logger = logging.getLogger('google_genai._common')
|
||||
|
||||
StringDict: TypeAlias = dict[str, Any]
|
||||
|
||||
|
||||
class ExperimentalWarning(Warning):
|
||||
"""Warning for experimental features."""
|
||||
|
||||
|
||||
def set_value_by_path(
|
||||
data: Optional[dict[Any, Any]], keys: list[str], value: Any
|
||||
) -> None:
|
||||
"""Examples:
|
||||
|
||||
set_value_by_path({}, ['a', 'b'], v)
|
||||
-> {'a': {'b': v}}
|
||||
set_value_by_path({}, ['a', 'b[]', c], [v1, v2])
|
||||
-> {'a': {'b': [{'c': v1}, {'c': v2}]}}
|
||||
set_value_by_path({'a': {'b': [{'c': v1}, {'c': v2}]}}, ['a', 'b[]', 'd'], v3)
|
||||
-> {'a': {'b': [{'c': v1, 'd': v3}, {'c': v2, 'd': v3}]}}
|
||||
"""
|
||||
if value is None:
|
||||
return
|
||||
for i, key in enumerate(keys[:-1]):
|
||||
if key.endswith('[]'):
|
||||
key_name = key[:-2]
|
||||
if data is not None and key_name not in data:
|
||||
if isinstance(value, list):
|
||||
data[key_name] = [{} for _ in range(len(value))]
|
||||
else:
|
||||
raise ValueError(
|
||||
f'value {value} must be a list given an array path {key}'
|
||||
)
|
||||
if isinstance(value, list) and data is not None:
|
||||
for j, d in enumerate(data[key_name]):
|
||||
set_value_by_path(d, keys[i + 1 :], value[j])
|
||||
else:
|
||||
if data is not None:
|
||||
for d in data[key_name]:
|
||||
set_value_by_path(d, keys[i + 1 :], value)
|
||||
return
|
||||
elif key.endswith('[0]'):
|
||||
key_name = key[:-3]
|
||||
if data is not None and key_name not in data:
|
||||
data[key_name] = [{}]
|
||||
if data is not None:
|
||||
set_value_by_path(data[key_name][0], keys[i + 1 :], value)
|
||||
return
|
||||
if data is not None:
|
||||
data = data.setdefault(key, {})
|
||||
|
||||
if data is not None:
|
||||
existing_data = data.get(keys[-1])
|
||||
# If there is an existing value, merge, not overwrite.
|
||||
if existing_data is not None:
|
||||
# Don't overwrite existing non-empty value with new empty value.
|
||||
# This is triggered when handling tuning datasets.
|
||||
if not value:
|
||||
pass
|
||||
# Don't fail when overwriting value with same value
|
||||
elif value == existing_data:
|
||||
pass
|
||||
# Instead of overwriting dictionary with another dictionary, merge them.
|
||||
# This is important for handling training and validation datasets in tuning.
|
||||
elif isinstance(existing_data, dict) and isinstance(value, dict):
|
||||
# Merging dictionaries. Consider deep merging in the future.
|
||||
existing_data.update(value)
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Cannot set value for an existing key. Key: {keys[-1]};'
|
||||
f' Existing value: {existing_data}; New value: {value}.'
|
||||
)
|
||||
else:
|
||||
if (
|
||||
keys[-1] == '_self'
|
||||
and isinstance(data, dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
data.update(value)
|
||||
else:
|
||||
data[keys[-1]] = value
|
||||
|
||||
|
||||
def get_value_by_path(
|
||||
data: Any, keys: list[str], *, default_value: Any = None
|
||||
) -> Any:
|
||||
"""Examples:
|
||||
|
||||
get_value_by_path({'a': {'b': v}}, ['a', 'b'])
|
||||
-> v
|
||||
get_value_by_path({'a': {'b': [{'c': v1}, {'c': v2}]}}, ['a', 'b[]', 'c'])
|
||||
-> [v1, v2]
|
||||
"""
|
||||
if keys == ['_self']:
|
||||
return data
|
||||
for i, key in enumerate(keys):
|
||||
if not data:
|
||||
return default_value
|
||||
if key.endswith('[]'):
|
||||
key_name = key[:-2]
|
||||
if key_name in data:
|
||||
return [
|
||||
get_value_by_path(d, keys[i + 1 :], default_value=default_value)
|
||||
for d in data[key_name]
|
||||
]
|
||||
else:
|
||||
return default_value
|
||||
elif key.endswith('[0]'):
|
||||
key_name = key[:-3]
|
||||
if key_name in data and data[key_name]:
|
||||
return get_value_by_path(
|
||||
data[key_name][0], keys[i + 1 :], default_value=default_value
|
||||
)
|
||||
else:
|
||||
return default_value
|
||||
else:
|
||||
if key in data:
|
||||
data = data[key]
|
||||
elif isinstance(data, BaseModel) and hasattr(data, key):
|
||||
data = getattr(data, key)
|
||||
else:
|
||||
return default_value
|
||||
return data
|
||||
|
||||
|
||||
def move_value_by_path(data: Any, paths: dict[str, str]) -> None:
|
||||
"""Moves values from source paths to destination paths.
|
||||
|
||||
Examples:
|
||||
move_value_by_path(
|
||||
{'requests': [{'content': v1}, {'content': v2}]},
|
||||
{'requests[].*': 'requests[].request.*'}
|
||||
)
|
||||
-> {'requests': [{'request': {'content': v1}}, {'request': {'content':
|
||||
v2}}]}
|
||||
"""
|
||||
for source_path, dest_path in paths.items():
|
||||
source_keys = source_path.split('.')
|
||||
dest_keys = dest_path.split('.')
|
||||
|
||||
# Determine keys to exclude from wildcard to avoid cyclic references
|
||||
exclude_keys = set()
|
||||
wildcard_idx = -1
|
||||
for i, key in enumerate(source_keys):
|
||||
if key == '*':
|
||||
wildcard_idx = i
|
||||
break
|
||||
|
||||
if wildcard_idx != -1 and len(dest_keys) > wildcard_idx:
|
||||
# Extract the intermediate key between source and dest paths
|
||||
# Example: source=['requests[]', '*'], dest=['requests[]', 'request', '*']
|
||||
# We want to exclude 'request'
|
||||
for i in range(wildcard_idx, len(dest_keys)):
|
||||
key = dest_keys[i]
|
||||
if key != '*' and not key.endswith('[]') and not key.endswith('[0]'):
|
||||
exclude_keys.add(key)
|
||||
|
||||
# Move values recursively
|
||||
_move_value_recursive(data, source_keys, dest_keys, 0, exclude_keys)
|
||||
|
||||
|
||||
def _move_value_recursive(
|
||||
data: Any,
|
||||
source_keys: list[str],
|
||||
dest_keys: list[str],
|
||||
key_idx: int,
|
||||
exclude_keys: set[str],
|
||||
) -> None:
|
||||
"""Recursively moves values from source path to destination path."""
|
||||
if key_idx >= len(source_keys):
|
||||
return
|
||||
|
||||
key = source_keys[key_idx]
|
||||
|
||||
if key.endswith('[]'):
|
||||
# Handle array iteration
|
||||
key_name = key[:-2]
|
||||
if key_name in data and isinstance(data[key_name], list):
|
||||
for item in data[key_name]:
|
||||
_move_value_recursive(
|
||||
item, source_keys, dest_keys, key_idx + 1, exclude_keys
|
||||
)
|
||||
elif key == '*':
|
||||
# Handle wildcard - move all fields
|
||||
if isinstance(data, dict):
|
||||
# Get all keys to move (excluding specified keys)
|
||||
keys_to_move = [
|
||||
k
|
||||
for k in list(data.keys())
|
||||
if not k.startswith('_') and k not in exclude_keys
|
||||
]
|
||||
|
||||
# Collect values to move
|
||||
values_to_move = {k: data[k] for k in keys_to_move}
|
||||
|
||||
# Set values at destination
|
||||
for k, v in values_to_move.items():
|
||||
# Build destination keys with the field name
|
||||
new_dest_keys = []
|
||||
for dk in dest_keys[key_idx:]:
|
||||
if dk == '*':
|
||||
new_dest_keys.append(k)
|
||||
else:
|
||||
new_dest_keys.append(dk)
|
||||
set_value_by_path(data, new_dest_keys, v)
|
||||
|
||||
# Delete from source
|
||||
for k in keys_to_move:
|
||||
del data[k]
|
||||
else:
|
||||
# Navigate to next level
|
||||
if key in data:
|
||||
_move_value_recursive(
|
||||
data[key], source_keys, dest_keys, key_idx + 1, exclude_keys
|
||||
)
|
||||
|
||||
|
||||
def maybe_snake_to_camel(snake_str: str, convert: bool = True) -> str:
|
||||
"""Converts a snake_case string to CamelCase, if convert is True."""
|
||||
if not convert:
|
||||
return snake_str
|
||||
return re.sub(r'_([a-zA-Z])', lambda match: match.group(1).upper(), snake_str)
|
||||
|
||||
|
||||
def convert_to_dict(obj: object, convert_keys: bool = False) -> Any:
|
||||
"""Recursively converts a given object to a dictionary.
|
||||
|
||||
If the object is a Pydantic model, it uses the model's `model_dump()` method.
|
||||
|
||||
Args:
|
||||
obj: The object to convert.
|
||||
convert_keys: Whether to convert the keys from snake case to camel case.
|
||||
|
||||
Returns:
|
||||
A dictionary representation of the object, a list of objects if a list is
|
||||
passed, or the object itself if it is not a dictionary, list, or Pydantic
|
||||
model.
|
||||
"""
|
||||
if isinstance(obj, pydantic.BaseModel):
|
||||
return convert_to_dict(obj.model_dump(exclude_none=True), convert_keys)
|
||||
elif isinstance(obj, dict):
|
||||
return {
|
||||
maybe_snake_to_camel(key, convert_keys): convert_to_dict(value)
|
||||
for key, value in obj.items()
|
||||
}
|
||||
elif isinstance(obj, list):
|
||||
return [convert_to_dict(item, convert_keys) for item in obj]
|
||||
else:
|
||||
return obj
|
||||
|
||||
|
||||
def _is_struct_type(annotation: type) -> bool:
|
||||
"""Checks if the given annotation is list[dict[str, typing.Any]]
|
||||
|
||||
or typing.List[typing.Dict[str, typing.Any]].
|
||||
|
||||
This maps to Struct type in the API.
|
||||
"""
|
||||
outer_origin = get_origin(annotation)
|
||||
outer_args = get_args(annotation)
|
||||
|
||||
if outer_origin is not list: # Python 3.9+ normalizes list
|
||||
return False
|
||||
|
||||
if not outer_args or len(outer_args) != 1:
|
||||
return False
|
||||
|
||||
inner_annotation = outer_args[0]
|
||||
|
||||
inner_origin = get_origin(inner_annotation)
|
||||
inner_args = get_args(inner_annotation)
|
||||
|
||||
if inner_origin is not dict: # Python 3.9+ normalizes to dict
|
||||
return False
|
||||
|
||||
if not inner_args or len(inner_args) != 2:
|
||||
# dict should have exactly two type arguments
|
||||
return False
|
||||
|
||||
# Check if the dict arguments are str and typing.Any
|
||||
key_type, value_type = inner_args
|
||||
return key_type is str and value_type is typing.Any
|
||||
|
||||
|
||||
def _remove_extra_fields(model: Any, response: dict[str, object]) -> None:
|
||||
"""Removes extra fields from the response that are not in the model.
|
||||
|
||||
Mutates the response in place.
|
||||
"""
|
||||
|
||||
key_values = list(response.items())
|
||||
|
||||
for key, value in key_values:
|
||||
# Need to convert to snake case to match model fields names
|
||||
# ex: UsageMetadata
|
||||
alias_map = {
|
||||
field_info.alias: key for key, field_info in model.model_fields.items()
|
||||
}
|
||||
|
||||
if key not in model.model_fields and key not in alias_map:
|
||||
response.pop(key)
|
||||
continue
|
||||
|
||||
key = alias_map.get(key, key)
|
||||
|
||||
annotation = model.model_fields[key].annotation
|
||||
|
||||
# Get the BaseModel if Optional
|
||||
if typing.get_origin(annotation) is Union:
|
||||
annotation = typing.get_args(annotation)[0]
|
||||
|
||||
# if dict, assume BaseModel but also check that field type is not dict
|
||||
# example: FunctionCall.args
|
||||
if isinstance(value, dict) and typing.get_origin(annotation) is not dict:
|
||||
_remove_extra_fields(annotation, value)
|
||||
elif isinstance(value, list):
|
||||
if _is_struct_type(annotation):
|
||||
continue
|
||||
|
||||
for item in value:
|
||||
# assume a list of dict is list of BaseModel
|
||||
if isinstance(item, dict):
|
||||
_remove_extra_fields(typing.get_args(annotation)[0], item)
|
||||
|
||||
|
||||
T = typing.TypeVar('T', bound='BaseModel')
|
||||
|
||||
|
||||
def _pretty_repr(
|
||||
obj: Any,
|
||||
*,
|
||||
indent_level: int = 0,
|
||||
indent_delta: int = 2,
|
||||
max_len: int = 100,
|
||||
max_items: int = 5,
|
||||
depth: int = 6,
|
||||
visited: Optional[FrozenSet[int]] = None,
|
||||
) -> str:
|
||||
"""Returns a representation of the given object."""
|
||||
if visited is None:
|
||||
visited = frozenset()
|
||||
|
||||
obj_id = id(obj)
|
||||
if obj_id in visited:
|
||||
return '<... Circular reference ...>'
|
||||
|
||||
if depth < 0:
|
||||
return '<... Max depth ...>'
|
||||
|
||||
visited = frozenset(list(visited) + [obj_id])
|
||||
|
||||
indent = ' ' * indent_level
|
||||
next_indent_str = ' ' * (indent_level + indent_delta)
|
||||
|
||||
if isinstance(obj, pydantic.BaseModel):
|
||||
cls_name = obj.__class__.__name__
|
||||
items = []
|
||||
# Sort fields for consistent output
|
||||
fields = sorted(type(obj).model_fields)
|
||||
|
||||
for field_name in fields:
|
||||
field_info = type(obj).model_fields[field_name]
|
||||
if not field_info.repr: # Respect Field(repr=False)
|
||||
continue
|
||||
|
||||
try:
|
||||
value = getattr(obj, field_name)
|
||||
except AttributeError:
|
||||
continue
|
||||
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
value_repr = _pretty_repr(
|
||||
value,
|
||||
indent_level=indent_level + indent_delta,
|
||||
indent_delta=indent_delta,
|
||||
max_len=max_len,
|
||||
max_items=max_items,
|
||||
depth=depth - 1,
|
||||
visited=visited,
|
||||
)
|
||||
items.append(f'{next_indent_str}{field_name}={value_repr}')
|
||||
|
||||
if not items:
|
||||
return f'{cls_name}()'
|
||||
return f'{cls_name}(\n' + ',\n'.join(items) + f'\n{indent})'
|
||||
elif isinstance(obj, str):
|
||||
if '\n' in obj:
|
||||
escaped = obj.replace('"""', '\\"\\"\\"')
|
||||
# Indent the multi-line string block contents
|
||||
return f'"""{escaped}"""'
|
||||
return repr(obj)
|
||||
elif isinstance(obj, bytes):
|
||||
if len(obj) > max_len:
|
||||
return f"{repr(obj[:max_len-3])[:-1]}...'"
|
||||
return repr(obj)
|
||||
elif isinstance(obj, collections.abc.Mapping):
|
||||
if not obj:
|
||||
return '{}'
|
||||
|
||||
# Check if the next level of recursion for keys/values will exceed the depth limit.
|
||||
if depth <= 0:
|
||||
item_count_str = f"{len(obj)} item{'s' if len(obj) != 1 else ''}"
|
||||
return f'{{<... {item_count_str} at Max depth ...>}}'
|
||||
|
||||
if len(obj) > max_items:
|
||||
return f'<dict len={len(obj)}>'
|
||||
|
||||
items = []
|
||||
try:
|
||||
sorted_keys = sorted(obj.keys(), key=str)
|
||||
except TypeError:
|
||||
sorted_keys = list(obj.keys())
|
||||
|
||||
for k in sorted_keys:
|
||||
v = obj[k]
|
||||
k_repr = _pretty_repr(
|
||||
k,
|
||||
indent_level=indent_level + indent_delta,
|
||||
indent_delta=indent_delta,
|
||||
max_len=max_len,
|
||||
max_items=max_items,
|
||||
depth=depth - 1,
|
||||
visited=visited,
|
||||
)
|
||||
v_repr = _pretty_repr(
|
||||
v,
|
||||
indent_level=indent_level + indent_delta,
|
||||
indent_delta=indent_delta,
|
||||
max_len=max_len,
|
||||
max_items=max_items,
|
||||
depth=depth - 1,
|
||||
visited=visited,
|
||||
)
|
||||
items.append(f'{next_indent_str}{k_repr}: {v_repr}')
|
||||
return f'{{\n' + ',\n'.join(items) + f'\n{indent}}}'
|
||||
elif isinstance(obj, (list, tuple, set)):
|
||||
return _format_collection(
|
||||
obj,
|
||||
indent_level=indent_level,
|
||||
indent_delta=indent_delta,
|
||||
max_len=max_len,
|
||||
max_items=max_items,
|
||||
depth=depth,
|
||||
visited=visited,
|
||||
)
|
||||
else:
|
||||
# Fallback to standard repr, indenting subsequent lines only
|
||||
raw_repr = repr(obj)
|
||||
# Replace newlines with newline + indent
|
||||
return raw_repr.replace('\n', f'\n{next_indent_str}')
|
||||
|
||||
|
||||
def _format_collection(
|
||||
obj: Any,
|
||||
*,
|
||||
indent_level: int,
|
||||
indent_delta: int,
|
||||
max_len: int,
|
||||
max_items: int,
|
||||
depth: int,
|
||||
visited: FrozenSet[int],
|
||||
) -> str:
|
||||
"""Formats a collection (list, tuple, set)."""
|
||||
if isinstance(obj, list):
|
||||
brackets = ('[', ']')
|
||||
internal_obj = obj
|
||||
elif isinstance(obj, tuple):
|
||||
brackets = ('(', ')')
|
||||
internal_obj = list(obj)
|
||||
elif isinstance(obj, set):
|
||||
internal_obj = list(obj)
|
||||
if obj:
|
||||
brackets = ('{', '}')
|
||||
else:
|
||||
brackets = ('set(', ')')
|
||||
else:
|
||||
raise ValueError(f'Unsupported collection type: {type(obj)}')
|
||||
|
||||
if not internal_obj:
|
||||
return brackets[0] + brackets[1]
|
||||
|
||||
# If the call to _pretty_repr for elements will have depth < 0
|
||||
if depth <= 0:
|
||||
item_count_str = f"{len(internal_obj)} item{'s'*(len(internal_obj)!=1)}"
|
||||
return f'{brackets[0]}<... {item_count_str} at Max depth ...>{brackets[1]}'
|
||||
|
||||
indent = ' ' * indent_level
|
||||
next_indent_str = ' ' * (indent_level + indent_delta)
|
||||
elements = []
|
||||
num_to_show = min(len(internal_obj), max_items)
|
||||
|
||||
for i in range(num_to_show):
|
||||
elem = internal_obj[i]
|
||||
elements.append(
|
||||
next_indent_str
|
||||
+ _pretty_repr(
|
||||
elem,
|
||||
indent_level=indent_level + indent_delta,
|
||||
indent_delta=indent_delta,
|
||||
max_len=max_len,
|
||||
max_items=max_items,
|
||||
depth=depth - 1,
|
||||
visited=visited,
|
||||
)
|
||||
)
|
||||
|
||||
if len(internal_obj) > max_items:
|
||||
elements.append(
|
||||
f'{next_indent_str}<... {len(internal_obj) - max_items} more items ...>'
|
||||
)
|
||||
|
||||
return f'{brackets[0]}\n' + ',\n'.join(elements) + f',\n{indent}{brackets[1]}'
|
||||
|
||||
|
||||
class BaseModel(pydantic.BaseModel):
|
||||
|
||||
model_config = pydantic.ConfigDict(
|
||||
alias_generator=alias_generators.to_camel,
|
||||
populate_by_name=True,
|
||||
from_attributes=True,
|
||||
protected_namespaces=(),
|
||||
extra='forbid',
|
||||
# This allows us to use arbitrary types in the model. E.g. PIL.Image.
|
||||
arbitrary_types_allowed=True,
|
||||
ser_json_bytes='base64',
|
||||
val_json_bytes='base64',
|
||||
ignored_types=(typing.TypeVar,),
|
||||
)
|
||||
|
||||
@pydantic.model_validator(mode='before')
|
||||
@classmethod
|
||||
def _check_field_type_mismatches(cls, data: Any) -> Any:
|
||||
"""Check for type mismatches and warn before Pydantic processes the data."""
|
||||
# Handle both dict and Pydantic model inputs
|
||||
if not isinstance(data, (dict, pydantic.BaseModel)):
|
||||
return data
|
||||
|
||||
for field_name, field_info in cls.model_fields.items():
|
||||
if isinstance(data, dict):
|
||||
value = data.get(field_name)
|
||||
else:
|
||||
value = getattr(data, field_name, None)
|
||||
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
expected_type = field_info.annotation
|
||||
origin = get_origin(expected_type)
|
||||
|
||||
if origin is Union:
|
||||
args = get_args(expected_type)
|
||||
non_none_types = [arg for arg in args if arg is not type(None)]
|
||||
if len(non_none_types) == 1:
|
||||
expected_type = non_none_types[0]
|
||||
|
||||
if (isinstance(expected_type, type) and
|
||||
get_origin(expected_type) is None and
|
||||
issubclass(expected_type, pydantic.BaseModel) and
|
||||
isinstance(value, pydantic.BaseModel) and
|
||||
not isinstance(value, expected_type)):
|
||||
logger.warning(
|
||||
f"Type mismatch in {cls.__name__}.{field_name}: "
|
||||
f"expected {expected_type.__name__}, got {type(value).__name__}"
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def __repr__(self) -> str:
|
||||
try:
|
||||
return _pretty_repr(self)
|
||||
except Exception:
|
||||
return super().__repr__()
|
||||
|
||||
@classmethod
|
||||
def _from_response(
|
||||
cls: typing.Type[T],
|
||||
*,
|
||||
response: dict[str, object],
|
||||
kwargs: dict[str, object],
|
||||
) -> T:
|
||||
# To maintain forward compatibility, we need to remove extra fields from
|
||||
# the response.
|
||||
# We will provide another mechanism to allow users to access these fields.
|
||||
|
||||
# For Agent Engine we don't want to call _remove_all_fields because the
|
||||
# user may pass a dict that is not a subclass of BaseModel.
|
||||
# If more modules require we skip this, we may want a different approach
|
||||
should_skip_removing_fields = (
|
||||
kwargs is not None
|
||||
and 'config' in kwargs
|
||||
and kwargs['config'] is not None
|
||||
and isinstance(kwargs['config'], dict)
|
||||
and 'include_all_fields' in kwargs['config']
|
||||
and kwargs['config']['include_all_fields']
|
||||
)
|
||||
|
||||
if not should_skip_removing_fields:
|
||||
_remove_extra_fields(cls, response)
|
||||
validated_response = cls.model_validate(response)
|
||||
return validated_response
|
||||
|
||||
def to_json_dict(self) -> dict[str, object]:
|
||||
return self.model_dump(exclude_none=True, mode='json')
|
||||
|
||||
|
||||
class CaseInSensitiveEnum(str, enum.Enum):
|
||||
"""Case insensitive enum."""
|
||||
|
||||
@classmethod
|
||||
def _missing_(cls, value: Any) -> Any:
|
||||
try:
|
||||
return cls[value.upper()] # Try to access directly with uppercase
|
||||
except KeyError:
|
||||
try:
|
||||
return cls[value.lower()] # Try to access directly with lowercase
|
||||
except KeyError:
|
||||
warnings.warn(f'{value} is not a valid {cls.__name__}')
|
||||
try:
|
||||
# Creating a enum instance based on the value
|
||||
# We need to use super() to avoid infinite recursion.
|
||||
unknown_enum_val = super().__new__(cls, value)
|
||||
unknown_enum_val._name_ = str(value) # pylint: disable=protected-access
|
||||
unknown_enum_val._value_ = value # pylint: disable=protected-access
|
||||
return unknown_enum_val
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
def timestamped_unique_name() -> str:
|
||||
"""Composes a timestamped unique name.
|
||||
|
||||
Returns:
|
||||
A string representing a unique name.
|
||||
"""
|
||||
timestamp = datetime.datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
unique_id = uuid.uuid4().hex[0:5]
|
||||
return f'{timestamp}_{unique_id}'
|
||||
|
||||
|
||||
def encode_unserializable_types(data: dict[str, object]) -> dict[str, object]:
|
||||
"""Converts unserializable types in dict to json.dumps() compatible types.
|
||||
|
||||
This function is called in models.py after calling convert_to_dict(). The
|
||||
convert_to_dict() can convert pydantic object to dict. However, the input to
|
||||
convert_to_dict() is dict mixed of pydantic object and nested dict(the output
|
||||
of converters). So they may be bytes in the dict and they are out of
|
||||
`ser_json_bytes` control in model_dump(mode='json') called in
|
||||
`convert_to_dict`, as well as datetime deserialization in Pydantic json mode.
|
||||
|
||||
Returns:
|
||||
A dictionary with json.dumps() incompatible type (e.g. bytes datetime)
|
||||
to compatible type (e.g. base64 encoded string, isoformat date string).
|
||||
"""
|
||||
processed_data: dict[str, object] = {}
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
for key, value in data.items():
|
||||
if isinstance(value, bytes):
|
||||
processed_data[key] = base64.urlsafe_b64encode(value).decode('ascii')
|
||||
elif isinstance(value, datetime.datetime):
|
||||
processed_data[key] = value.isoformat()
|
||||
elif isinstance(value, dict):
|
||||
processed_data[key] = encode_unserializable_types(value)
|
||||
elif isinstance(value, list):
|
||||
if all(isinstance(v, bytes) for v in value):
|
||||
processed_data[key] = [
|
||||
base64.urlsafe_b64encode(v).decode('ascii') for v in value
|
||||
]
|
||||
if all(isinstance(v, datetime.datetime) for v in value):
|
||||
processed_data[key] = [v.isoformat() for v in value]
|
||||
else:
|
||||
processed_data[key] = [encode_unserializable_types(v) for v in value]
|
||||
else:
|
||||
processed_data[key] = value
|
||||
return processed_data
|
||||
|
||||
|
||||
def experimental_warning(
|
||||
message: str,
|
||||
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
||||
"""Experimental warning, only warns once."""
|
||||
|
||||
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
warning_done = False
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
nonlocal warning_done
|
||||
if not warning_done:
|
||||
warning_done = True
|
||||
warnings.warn(
|
||||
message=message,
|
||||
category=ExperimentalWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _normalize_key_for_matching(key_str: str) -> str:
|
||||
"""Normalizes a key for case-insensitive and snake/camel matching."""
|
||||
return key_str.replace('_', '').lower()
|
||||
|
||||
|
||||
def align_key_case(
|
||||
target_dict: StringDict, update_dict: StringDict
|
||||
) -> StringDict:
|
||||
"""Aligns the keys of update_dict to the case of target_dict keys.
|
||||
|
||||
Args:
|
||||
target_dict: The dictionary with the target key casing.
|
||||
update_dict: The dictionary whose keys need to be aligned.
|
||||
|
||||
Returns:
|
||||
A new dictionary with keys aligned to target_dict's key casing.
|
||||
"""
|
||||
aligned_update_dict: StringDict = {}
|
||||
target_keys_map = {
|
||||
_normalize_key_for_matching(key): key for key in target_dict.keys()
|
||||
}
|
||||
|
||||
for key, value in update_dict.items():
|
||||
normalized_update_key = _normalize_key_for_matching(key)
|
||||
|
||||
if normalized_update_key in target_keys_map:
|
||||
aligned_key = target_keys_map[normalized_update_key]
|
||||
else:
|
||||
aligned_key = key
|
||||
|
||||
if isinstance(value, dict) and isinstance(
|
||||
target_dict.get(aligned_key), dict
|
||||
):
|
||||
aligned_update_dict[aligned_key] = align_key_case(
|
||||
target_dict[aligned_key], value
|
||||
)
|
||||
elif isinstance(value, list) and isinstance(
|
||||
target_dict.get(aligned_key), list
|
||||
):
|
||||
# Direct assign as we treat update_dict list values as golden source.
|
||||
aligned_update_dict[aligned_key] = value
|
||||
else:
|
||||
aligned_update_dict[aligned_key] = value
|
||||
return aligned_update_dict
|
||||
|
||||
|
||||
def recursive_dict_update(
|
||||
target_dict: StringDict, update_dict: StringDict
|
||||
) -> None:
|
||||
"""Recursively updates a target dictionary with values from an update dictionary.
|
||||
|
||||
We don't enforce the updated dict values to have the same type with the
|
||||
target_dict values except log warnings.
|
||||
Users providing the update_dict should be responsible for constructing correct
|
||||
data.
|
||||
|
||||
Args:
|
||||
target_dict (dict): The dictionary to be updated.
|
||||
update_dict (dict): The dictionary containing updates.
|
||||
"""
|
||||
# Python SDK http request may change in camel case or snake case:
|
||||
# If the field is directly set via setv() function, then it is camel case;
|
||||
# otherwise it is snake case.
|
||||
# Align the update_dict key case to target_dict to ensure correct dict update.
|
||||
aligned_update_dict = align_key_case(target_dict, update_dict)
|
||||
for key, value in aligned_update_dict.items():
|
||||
if (
|
||||
key in target_dict
|
||||
and isinstance(target_dict[key], dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
recursive_dict_update(target_dict[key], value)
|
||||
elif key in target_dict and not isinstance(target_dict[key], type(value)):
|
||||
logger.warning(
|
||||
f"Type mismatch for key '{key}'. Existing type:"
|
||||
f' {type(target_dict[key])}, new type: {type(value)}. Overwriting.'
|
||||
)
|
||||
target_dict[key] = value
|
||||
else:
|
||||
target_dict[key] = value
|
||||
|
||||
|
||||
def is_duck_type_of(obj: Any, cls: type[pydantic.BaseModel]) -> bool:
|
||||
"""Checks if an object has all of the fields of a Pydantic model.
|
||||
|
||||
This is a duck-typing alternative to `isinstance` to solve dual-import
|
||||
problems. It returns False for dictionaries, which should be handled by
|
||||
`isinstance(obj, dict)`.
|
||||
|
||||
Args:
|
||||
obj: The object to check.
|
||||
cls: The Pydantic model class to duck-type against.
|
||||
|
||||
Returns:
|
||||
True if the object has all the fields defined in the Pydantic model, False
|
||||
otherwise.
|
||||
"""
|
||||
if isinstance(obj, dict) or not hasattr(cls, 'model_fields'):
|
||||
return False
|
||||
|
||||
# Check if the object has all of the Pydantic model's defined fields.
|
||||
all_matched = all(hasattr(obj, field) for field in cls.model_fields)
|
||||
if not all_matched and isinstance(obj, pydantic.BaseModel):
|
||||
# Check the other way around if obj is a Pydantic model.
|
||||
# Check if the Pydantic model has all of the object's defined fields.
|
||||
try:
|
||||
obj_private = cls()
|
||||
all_matched = all(hasattr(obj_private, f) for f in type(obj).model_fields)
|
||||
except ValueError:
|
||||
return False
|
||||
return all_matched
|
||||
679
venv/lib/python3.12/site-packages/google/genai/_extra_utils.py
Normal file
679
venv/lib/python3.12/site-packages/google/genai/_extra_utils.py
Normal file
@@ -0,0 +1,679 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
"""Extra utils depending on types that are shared between sync and async modules."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
import typing
|
||||
from typing import Any, Callable, Dict, Optional, Union, get_args, get_origin
|
||||
import mimetypes
|
||||
import os
|
||||
import pydantic
|
||||
|
||||
from . import _common
|
||||
from . import _mcp_utils
|
||||
from . import _transformers as t
|
||||
from . import errors
|
||||
from . import types
|
||||
from ._adapters import McpToGenAiToolAdapter
|
||||
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
from types import UnionType
|
||||
else:
|
||||
UnionType = typing._UnionGenericAlias # type: ignore[attr-defined]
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from mcp import ClientSession as McpClientSession
|
||||
from mcp.types import Tool as McpTool
|
||||
else:
|
||||
McpClientSession: typing.Type = Any
|
||||
McpTool: typing.Type = Any
|
||||
try:
|
||||
from mcp import ClientSession as McpClientSession
|
||||
from mcp.types import Tool as McpTool
|
||||
except ImportError:
|
||||
McpClientSession = None
|
||||
McpTool = None
|
||||
|
||||
_DEFAULT_MAX_REMOTE_CALLS_AFC = 10
|
||||
|
||||
logger = logging.getLogger('google_genai.models')
|
||||
|
||||
|
||||
def _create_generate_content_config_model(
|
||||
config: types.GenerateContentConfigOrDict,
|
||||
) -> types.GenerateContentConfig:
|
||||
if isinstance(config, dict):
|
||||
return types.GenerateContentConfig(**config)
|
||||
else:
|
||||
return config
|
||||
|
||||
|
||||
def _get_gcs_uri(
|
||||
src: Union[str, types.BatchJobSourceOrDict]
|
||||
) -> Optional[str]:
|
||||
"""Extracts the first GCS URI from the source, if available."""
|
||||
if isinstance(src, str) and src.startswith('gs://'):
|
||||
return src
|
||||
elif isinstance(src, dict) and src.get('gcs_uri'):
|
||||
return src['gcs_uri'][0] if src['gcs_uri'] else None
|
||||
elif isinstance(src, types.BatchJobSource) and src.gcs_uri:
|
||||
return src.gcs_uri[0] if src.gcs_uri else None
|
||||
return None
|
||||
|
||||
|
||||
def _get_bigquery_uri(
|
||||
src: Union[str, types.BatchJobSourceOrDict]
|
||||
) -> Optional[str]:
|
||||
"""Extracts the BigQuery URI from the source, if available."""
|
||||
if isinstance(src, str) and src.startswith('bq://'):
|
||||
return src
|
||||
elif isinstance(src, dict) and src.get('bigquery_uri'):
|
||||
return src['bigquery_uri']
|
||||
elif isinstance(src, types.BatchJobSource) and src.bigquery_uri:
|
||||
return src.bigquery_uri
|
||||
return None
|
||||
|
||||
|
||||
def format_destination(
|
||||
src: Union[str, types.BatchJobSource],
|
||||
config: Optional[types.CreateBatchJobConfig] = None,
|
||||
) -> types.CreateBatchJobConfig:
|
||||
"""Formats the destination uri based on the source uri for Vertex AI."""
|
||||
if config is None:
|
||||
config = types.CreateBatchJobConfig()
|
||||
|
||||
unique_name = None
|
||||
if not config.display_name:
|
||||
unique_name = _common.timestamped_unique_name()
|
||||
config.display_name = f'genai_batch_job_{unique_name}'
|
||||
|
||||
if not config.dest:
|
||||
gcs_source_uri = _get_gcs_uri(src)
|
||||
bigquery_source_uri = _get_bigquery_uri(src)
|
||||
|
||||
if gcs_source_uri and gcs_source_uri.endswith('.jsonl'):
|
||||
config.dest = f'{gcs_source_uri[:-6]}/dest'
|
||||
elif bigquery_source_uri:
|
||||
unique_name = unique_name or _common.timestamped_unique_name()
|
||||
config.dest = f'{bigquery_source_uri}_dest_{unique_name}'
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def find_afc_incompatible_tool_indexes(
|
||||
config: Optional[types.GenerateContentConfigOrDict] = None,
|
||||
) -> list[int]:
|
||||
"""Checks if the config contains any AFC incompatible tools.
|
||||
|
||||
A `types.Tool` object that contains `function_declarations` is considered a
|
||||
non-AFC tool for this execution path.
|
||||
|
||||
Args:
|
||||
config: The GenerateContentConfig to check for incompatible tools.
|
||||
|
||||
Returns:
|
||||
A list of indexes of the incompatible tools in the config.
|
||||
"""
|
||||
if not config:
|
||||
return []
|
||||
config_model = _create_generate_content_config_model(config)
|
||||
incompatible_tools_indexes: list[int] = []
|
||||
|
||||
if not config_model or not config_model.tools:
|
||||
return incompatible_tools_indexes
|
||||
|
||||
for index, tool in enumerate(config_model.tools):
|
||||
if not isinstance(tool, types.Tool):
|
||||
continue
|
||||
if tool.function_declarations:
|
||||
incompatible_tools_indexes.append(index)
|
||||
if tool.mcp_servers:
|
||||
incompatible_tools_indexes.append(index)
|
||||
return incompatible_tools_indexes
|
||||
|
||||
|
||||
def get_function_map(
|
||||
config: Optional[types.GenerateContentConfigOrDict] = None,
|
||||
mcp_to_genai_tool_adapters: Optional[
|
||||
dict[str, McpToGenAiToolAdapter]
|
||||
] = None,
|
||||
is_caller_method_async: bool = False,
|
||||
) -> dict[str, Union[Callable[..., Any], McpToGenAiToolAdapter]]:
|
||||
"""Returns a function map from the config."""
|
||||
function_map: dict[str, Union[Callable[..., Any], McpToGenAiToolAdapter]] = {}
|
||||
if not config:
|
||||
return function_map
|
||||
config_model = _create_generate_content_config_model(config)
|
||||
if config_model.tools:
|
||||
for tool in config_model.tools:
|
||||
if callable(tool):
|
||||
if inspect.iscoroutinefunction(tool) and not is_caller_method_async:
|
||||
raise errors.UnsupportedFunctionError(
|
||||
f'Function {tool.__name__} is a coroutine function, which is not'
|
||||
' supported for automatic function calling. Please manually'
|
||||
f' invoke {tool.__name__} to get the function response.'
|
||||
)
|
||||
function_map[tool.__name__] = tool
|
||||
if mcp_to_genai_tool_adapters:
|
||||
if not is_caller_method_async:
|
||||
raise errors.UnsupportedFunctionError(
|
||||
'MCP tools are not supported in synchronous methods.'
|
||||
)
|
||||
for tool_name, _ in mcp_to_genai_tool_adapters.items():
|
||||
if function_map.get(tool_name):
|
||||
raise ValueError(
|
||||
f'Tool {tool_name} is already defined for the request.'
|
||||
)
|
||||
function_map.update(mcp_to_genai_tool_adapters)
|
||||
return function_map
|
||||
|
||||
|
||||
def convert_number_values_for_dict_function_call_args(
|
||||
args: _common.StringDict,
|
||||
) -> _common.StringDict:
|
||||
"""Converts float values in dict with no decimal to integers."""
|
||||
return {
|
||||
key: convert_number_values_for_function_call_args(value)
|
||||
for key, value in args.items()
|
||||
}
|
||||
|
||||
|
||||
def convert_number_values_for_function_call_args(
|
||||
args: Union[dict[str, object], list[object], object],
|
||||
) -> Union[dict[str, object], list[object], object]:
|
||||
"""Converts float values with no decimal to integers."""
|
||||
if isinstance(args, float) and args.is_integer():
|
||||
return int(args)
|
||||
if isinstance(args, dict):
|
||||
return {
|
||||
key: convert_number_values_for_function_call_args(value)
|
||||
for key, value in args.items()
|
||||
}
|
||||
if isinstance(args, list):
|
||||
return [
|
||||
convert_number_values_for_function_call_args(value) for value in args
|
||||
]
|
||||
return args
|
||||
|
||||
|
||||
def is_annotation_pydantic_model(annotation: Any) -> bool:
|
||||
try:
|
||||
return inspect.isclass(annotation) and issubclass(
|
||||
annotation, pydantic.BaseModel
|
||||
)
|
||||
# for python 3.10 and below, inspect.isclass(annotation) has inconsistent
|
||||
# results with versions above. for example, inspect.isclass(dict[str, int]) is
|
||||
# True in 3.10 and below but False in 3.11 and above.
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
def convert_if_exist_pydantic_model(
|
||||
value: Any, annotation: Any, param_name: str, func_name: str
|
||||
) -> Any:
|
||||
if isinstance(value, dict) and is_annotation_pydantic_model(annotation):
|
||||
try:
|
||||
return annotation(**value)
|
||||
except pydantic.ValidationError as e:
|
||||
raise errors.UnknownFunctionCallArgumentError(
|
||||
f'Failed to parse parameter {param_name} for function'
|
||||
f' {func_name} from function call part because function call argument'
|
||||
f' value {value} is not compatible with parameter annotation'
|
||||
f' {annotation}, due to error {e}'
|
||||
)
|
||||
if isinstance(value, list) and get_origin(annotation) == list:
|
||||
item_type = get_args(annotation)[0]
|
||||
return [
|
||||
convert_if_exist_pydantic_model(item, item_type, param_name, func_name)
|
||||
for item in value
|
||||
]
|
||||
if isinstance(value, dict) and get_origin(annotation) == dict:
|
||||
_, value_type = get_args(annotation)
|
||||
return {
|
||||
k: convert_if_exist_pydantic_model(v, value_type, param_name, func_name)
|
||||
for k, v in value.items()
|
||||
}
|
||||
# example 1: typing.Union[int, float]
|
||||
# example 2: int | float equivalent to UnionType[int, float]
|
||||
if get_origin(annotation) in (Union, UnionType):
|
||||
for arg in get_args(annotation):
|
||||
if (
|
||||
(get_args(arg) and get_origin(arg) is list)
|
||||
or isinstance(value, arg)
|
||||
or (isinstance(value, dict) and is_annotation_pydantic_model(arg))
|
||||
):
|
||||
try:
|
||||
return convert_if_exist_pydantic_model(
|
||||
value, arg, param_name, func_name
|
||||
)
|
||||
# do not raise here because there could be multiple pydantic model types
|
||||
# in the union type.
|
||||
except pydantic.ValidationError:
|
||||
continue
|
||||
# if none of the union type is matched, raise error
|
||||
raise errors.UnknownFunctionCallArgumentError(
|
||||
f'Failed to parse parameter {param_name} for function'
|
||||
f' {func_name} from function call part because function call argument'
|
||||
f' value {value} cannot be converted to parameter annotation'
|
||||
f' {annotation}.'
|
||||
)
|
||||
# the only exception for value and annotation type to be different is int and
|
||||
# float. see convert_number_values_for_function_call_args function for context
|
||||
if isinstance(value, int) and annotation is float:
|
||||
return value
|
||||
if not isinstance(value, annotation):
|
||||
raise errors.UnknownFunctionCallArgumentError(
|
||||
f'Failed to parse parameter {param_name} for function {func_name} from'
|
||||
f' function call part because function call argument value {value} is'
|
||||
f' not compatible with parameter annotation {annotation}.'
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def convert_argument_from_function(
|
||||
args: _common.StringDict, function: Callable[..., Any]
|
||||
) -> _common.StringDict:
|
||||
signature = inspect.signature(function)
|
||||
func_name = function.__name__
|
||||
converted_args = {}
|
||||
for param_name, param in signature.parameters.items():
|
||||
if param_name in args:
|
||||
converted_args[param_name] = convert_if_exist_pydantic_model(
|
||||
args[param_name],
|
||||
param.annotation,
|
||||
param_name,
|
||||
func_name,
|
||||
)
|
||||
return converted_args
|
||||
|
||||
|
||||
def invoke_function_from_dict_args(
|
||||
args: _common.StringDict, function_to_invoke: Callable[..., Any]
|
||||
) -> Any:
|
||||
converted_args = convert_argument_from_function(args, function_to_invoke)
|
||||
try:
|
||||
return function_to_invoke(**converted_args)
|
||||
except Exception as e:
|
||||
raise errors.FunctionInvocationError(
|
||||
f'Failed to invoke function {function_to_invoke.__name__} with'
|
||||
f' converted arguments {converted_args} from model returned function'
|
||||
f' call argument {args} because of error {e}'
|
||||
)
|
||||
|
||||
|
||||
async def invoke_function_from_dict_args_async(
|
||||
args: _common.StringDict, function_to_invoke: Callable[..., Any]
|
||||
) -> Any:
|
||||
converted_args = convert_argument_from_function(args, function_to_invoke)
|
||||
try:
|
||||
return await function_to_invoke(**converted_args)
|
||||
except Exception as e:
|
||||
raise errors.FunctionInvocationError(
|
||||
f'Failed to invoke function {function_to_invoke.__name__} with'
|
||||
f' converted arguments {converted_args} from model returned function'
|
||||
f' call argument {args} because of error {e}'
|
||||
)
|
||||
|
||||
|
||||
def get_function_response_parts(
|
||||
response: types.GenerateContentResponse,
|
||||
function_map: dict[str, Union[Callable[..., Any], McpToGenAiToolAdapter]],
|
||||
) -> list[types.Part]:
|
||||
"""Returns the function response parts from the response."""
|
||||
func_response_parts = []
|
||||
if (
|
||||
response.candidates is not None
|
||||
and isinstance(response.candidates[0].content, types.Content)
|
||||
and response.candidates[0].content.parts is not None
|
||||
):
|
||||
for part in response.candidates[0].content.parts:
|
||||
if not part.function_call:
|
||||
continue
|
||||
func_name = part.function_call.name
|
||||
if func_name is not None and part.function_call.args is not None:
|
||||
func = function_map[func_name]
|
||||
args = convert_number_values_for_dict_function_call_args(
|
||||
part.function_call.args
|
||||
)
|
||||
func_response: _common.StringDict
|
||||
try:
|
||||
if not isinstance(func, McpToGenAiToolAdapter):
|
||||
func_response = {
|
||||
'result': invoke_function_from_dict_args(args, func)
|
||||
}
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
func_response = {'error': str(e)}
|
||||
func_response_part = types.Part.from_function_response(
|
||||
name=func_name, response=func_response
|
||||
)
|
||||
func_response_parts.append(func_response_part)
|
||||
return func_response_parts
|
||||
|
||||
|
||||
async def get_function_response_parts_async(
|
||||
response: types.GenerateContentResponse,
|
||||
function_map: dict[str, Union[Callable[..., Any], McpToGenAiToolAdapter]],
|
||||
) -> list[types.Part]:
|
||||
"""Returns the function response parts from the response."""
|
||||
func_response_parts = []
|
||||
if (
|
||||
response.candidates is not None
|
||||
and isinstance(response.candidates[0].content, types.Content)
|
||||
and response.candidates[0].content.parts is not None
|
||||
):
|
||||
for part in response.candidates[0].content.parts:
|
||||
if not part.function_call:
|
||||
continue
|
||||
func_name = part.function_call.name
|
||||
if func_name is not None and part.function_call.args is not None:
|
||||
func = function_map[func_name]
|
||||
args = convert_number_values_for_dict_function_call_args(
|
||||
part.function_call.args
|
||||
)
|
||||
func_response: _common.StringDict
|
||||
try:
|
||||
if isinstance(func, McpToGenAiToolAdapter):
|
||||
mcp_tool_response = await func.call_tool(
|
||||
types.FunctionCall(name=func_name, args=args)
|
||||
)
|
||||
if mcp_tool_response.isError:
|
||||
func_response = {'error': mcp_tool_response}
|
||||
else:
|
||||
func_response = {'result': mcp_tool_response}
|
||||
elif inspect.iscoroutinefunction(func):
|
||||
func_response = {
|
||||
'result': await invoke_function_from_dict_args_async(args, func)
|
||||
}
|
||||
else:
|
||||
func_response = {
|
||||
'result': await asyncio.to_thread(
|
||||
invoke_function_from_dict_args, args, func
|
||||
)
|
||||
}
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
func_response = {'error': str(e)}
|
||||
func_response_part = types.Part.from_function_response(
|
||||
name=func_name, response=func_response
|
||||
)
|
||||
func_response_parts.append(func_response_part)
|
||||
return func_response_parts
|
||||
|
||||
|
||||
def should_disable_afc(
|
||||
config: Optional[types.GenerateContentConfigOrDict] = None,
|
||||
) -> bool:
|
||||
"""Returns whether automatic function calling is enabled."""
|
||||
if not config:
|
||||
return False
|
||||
config_model = _create_generate_content_config_model(config)
|
||||
# If max_remote_calls is less or equal to 0, warn and disable AFC.
|
||||
if (
|
||||
config_model
|
||||
and config_model.automatic_function_calling
|
||||
and config_model.automatic_function_calling.maximum_remote_calls
|
||||
is not None
|
||||
and int(config_model.automatic_function_calling.maximum_remote_calls) <= 0
|
||||
):
|
||||
logger.warning(
|
||||
'max_remote_calls in automatic_function_calling_config'
|
||||
f' {config_model.automatic_function_calling.maximum_remote_calls} is'
|
||||
' less than or equal to 0. Disabling automatic function calling.'
|
||||
' Please set max_remote_calls to a positive integer.'
|
||||
)
|
||||
return True
|
||||
|
||||
# Default to enable AFC if not specified.
|
||||
if (
|
||||
not config_model.automatic_function_calling
|
||||
or config_model.automatic_function_calling.disable is None
|
||||
):
|
||||
return False
|
||||
|
||||
if (
|
||||
config_model.automatic_function_calling.disable
|
||||
and config_model.automatic_function_calling.maximum_remote_calls
|
||||
is not None
|
||||
# exclude the case where max_remote_calls is set to 10 by default.
|
||||
and 'maximum_remote_calls'
|
||||
in config_model.automatic_function_calling.model_fields_set
|
||||
and int(config_model.automatic_function_calling.maximum_remote_calls) > 0
|
||||
):
|
||||
logger.warning(
|
||||
'`automatic_function_calling.disable` is set to `True`. And'
|
||||
' `automatic_function_calling.maximum_remote_calls` is a'
|
||||
' positive number'
|
||||
f' {config_model.automatic_function_calling.maximum_remote_calls}.'
|
||||
' Disabling automatic function calling. If you want to enable'
|
||||
' automatic function calling, please set'
|
||||
' `automatic_function_calling.disable` to `False` or leave it unset,'
|
||||
' and set `automatic_function_calling.maximum_remote_calls` to a'
|
||||
' positive integer or leave'
|
||||
' `automatic_function_calling.maximum_remote_calls` unset.'
|
||||
)
|
||||
|
||||
return config_model.automatic_function_calling.disable
|
||||
|
||||
|
||||
def get_max_remote_calls_afc(
|
||||
config: Optional[types.GenerateContentConfigOrDict] = None,
|
||||
) -> int:
|
||||
if not config:
|
||||
return _DEFAULT_MAX_REMOTE_CALLS_AFC
|
||||
"""Returns the remaining remote calls for automatic function calling."""
|
||||
if should_disable_afc(config):
|
||||
raise ValueError(
|
||||
'automatic function calling is not enabled, but SDK is trying to get'
|
||||
' max remote calls.'
|
||||
)
|
||||
config_model = _create_generate_content_config_model(config)
|
||||
if (
|
||||
not config_model.automatic_function_calling
|
||||
or config_model.automatic_function_calling.maximum_remote_calls is None
|
||||
):
|
||||
return _DEFAULT_MAX_REMOTE_CALLS_AFC
|
||||
return int(config_model.automatic_function_calling.maximum_remote_calls)
|
||||
|
||||
|
||||
def raise_error_for_afc_incompatible_config(config: Optional[types.GenerateContentConfig]
|
||||
) -> None:
|
||||
"""Raises an error if the config is not compatible with AFC."""
|
||||
if (
|
||||
not config
|
||||
or not config.tool_config
|
||||
or not config.tool_config.function_calling_config
|
||||
):
|
||||
return
|
||||
afc_config = config.automatic_function_calling
|
||||
disable_afc_config = afc_config.disable if afc_config else False
|
||||
stream_function_call = (
|
||||
config.tool_config.function_calling_config.stream_function_call_arguments
|
||||
)
|
||||
|
||||
if stream_function_call and not disable_afc_config:
|
||||
raise ValueError(
|
||||
'Running in streaming mode with stream_function_call_arguments'
|
||||
' enabled, this feature is not compatible with automatic function'
|
||||
' calling (AFC). Please set config.automatic_function_calling.disable'
|
||||
' to True to disable AFC or leave config.tool_config.'
|
||||
' function_calling_config.stream_function_call_arguments to be empty'
|
||||
' or set to False to disable streaming function call arguments.'
|
||||
)
|
||||
|
||||
def should_append_afc_history(
|
||||
config: Optional[types.GenerateContentConfigOrDict] = None,
|
||||
) -> bool:
|
||||
if not config:
|
||||
return True
|
||||
config_model = _create_generate_content_config_model(config)
|
||||
if not config_model.automatic_function_calling:
|
||||
return True
|
||||
return not config_model.automatic_function_calling.ignore_call_history
|
||||
|
||||
|
||||
def parse_config_for_mcp_usage(
|
||||
config: Optional[types.GenerateContentConfigOrDict] = None,
|
||||
) -> Optional[types.GenerateContentConfig]:
|
||||
"""Returns a parsed config with an appended MCP header if MCP tools or sessions are used."""
|
||||
if not config:
|
||||
return None
|
||||
config_model = _create_generate_content_config_model(config)
|
||||
# Create a copy of the config model with the tools field cleared since some
|
||||
# tools may not be pickleable.
|
||||
config_model_copy = config_model.model_copy(update={'tools': None})
|
||||
config_model_copy.tools = config_model.tools
|
||||
if config_model.tools and _mcp_utils.has_mcp_tool_usage(config_model.tools):
|
||||
if config_model_copy.http_options is None:
|
||||
config_model_copy.http_options = types.HttpOptions(headers={})
|
||||
if config_model_copy.http_options.headers is None:
|
||||
config_model_copy.http_options.headers = {}
|
||||
_mcp_utils.set_mcp_usage_header(config_model_copy.http_options.headers)
|
||||
|
||||
return config_model_copy
|
||||
|
||||
|
||||
async def parse_config_for_mcp_sessions(
|
||||
config: Optional[types.GenerateContentConfigOrDict] = None,
|
||||
) -> tuple[
|
||||
Optional[types.GenerateContentConfig],
|
||||
dict[str, McpToGenAiToolAdapter],
|
||||
]:
|
||||
"""Returns a parsed config with MCP sessions converted to GenAI tools.
|
||||
|
||||
Also returns a map of MCP tools to GenAI tool adapters to be used for AFC.
|
||||
"""
|
||||
mcp_to_genai_tool_adapters: dict[str, McpToGenAiToolAdapter] = {}
|
||||
parsed_config = parse_config_for_mcp_usage(config)
|
||||
if not parsed_config:
|
||||
return None, mcp_to_genai_tool_adapters
|
||||
# Create a copy of the config model with the tools field cleared as they will
|
||||
# be replaced with the MCP tools converted to GenAI tools.
|
||||
parsed_config_copy = parsed_config.model_copy(update={'tools': None})
|
||||
if parsed_config.tools:
|
||||
parsed_config_copy.tools = []
|
||||
for tool in parsed_config.tools:
|
||||
if McpClientSession is not None and isinstance(tool, McpClientSession):
|
||||
mcp_to_genai_tool_adapter = McpToGenAiToolAdapter(
|
||||
tool, await tool.list_tools()
|
||||
)
|
||||
# Extend the config with the MCP session tools converted to GenAI tools.
|
||||
parsed_config_copy.tools.extend(mcp_to_genai_tool_adapter.tools)
|
||||
for genai_tool in mcp_to_genai_tool_adapter.tools:
|
||||
if genai_tool.function_declarations:
|
||||
for function_declaration in genai_tool.function_declarations:
|
||||
if function_declaration.name:
|
||||
if mcp_to_genai_tool_adapters.get(function_declaration.name):
|
||||
raise ValueError(
|
||||
f'Tool {function_declaration.name} is already defined for'
|
||||
' the request.'
|
||||
)
|
||||
mcp_to_genai_tool_adapters[function_declaration.name] = (
|
||||
mcp_to_genai_tool_adapter
|
||||
)
|
||||
else:
|
||||
parsed_config_copy.tools.append(tool)
|
||||
|
||||
return parsed_config_copy, mcp_to_genai_tool_adapters
|
||||
|
||||
|
||||
def append_chunk_contents(
|
||||
contents: Union[types.ContentListUnion, types.ContentListUnionDict],
|
||||
chunk: types.GenerateContentResponse,
|
||||
) -> Union[types.ContentListUnion, types.ContentListUnionDict]:
|
||||
"""Appends the contents of the chunk to the contents list and returns it."""
|
||||
if chunk is not None and chunk.candidates is not None:
|
||||
chunk_content = chunk.candidates[0].content
|
||||
contents = t.t_contents(contents) # type: ignore[assignment]
|
||||
if isinstance(contents, list) and chunk_content is not None:
|
||||
contents.append(chunk_content) # type: ignore[arg-type]
|
||||
return contents
|
||||
|
||||
|
||||
def prepare_resumable_upload(
|
||||
file: Union[str, os.PathLike[str], io.IOBase],
|
||||
user_http_options: Optional[types.HttpOptionsOrDict] = None,
|
||||
user_mime_type: Optional[str] = None,
|
||||
) -> tuple[
|
||||
types.HttpOptions,
|
||||
int,
|
||||
str,
|
||||
]:
|
||||
"""Prepares the HTTP options, file bytes size and mime type for a resumable upload.
|
||||
|
||||
This function inspects a file (from a path or an in-memory object) to
|
||||
determine its size and MIME type. It then constructs the necessary HTTP
|
||||
headers and options required to initiate a resumable upload session.
|
||||
"""
|
||||
size_bytes = None
|
||||
mime_type = user_mime_type
|
||||
if isinstance(file, io.IOBase):
|
||||
if mime_type is None:
|
||||
raise ValueError(
|
||||
'Unknown mime type: Could not determine the mimetype for your'
|
||||
' file\n please set the `mime_type` argument'
|
||||
)
|
||||
if hasattr(file, 'mode'):
|
||||
if 'b' not in file.mode:
|
||||
raise ValueError('The file must be opened in binary mode.')
|
||||
offset = file.tell()
|
||||
file.seek(0, os.SEEK_END)
|
||||
size_bytes = file.tell() - offset
|
||||
file.seek(offset, os.SEEK_SET)
|
||||
else:
|
||||
fs_path = os.fspath(file)
|
||||
if not fs_path or not os.path.isfile(fs_path):
|
||||
raise FileNotFoundError(f'{file} is not a valid file path.')
|
||||
size_bytes = os.path.getsize(fs_path)
|
||||
if mime_type is None:
|
||||
mime_type, _ = mimetypes.guess_type(fs_path)
|
||||
if mime_type is None:
|
||||
raise ValueError(
|
||||
'Unknown mime type: Could not determine the mimetype for your'
|
||||
' file\n please set the `mime_type` argument'
|
||||
)
|
||||
http_options: types.HttpOptions
|
||||
if user_http_options:
|
||||
if isinstance(user_http_options, dict):
|
||||
user_http_options = types.HttpOptions(**user_http_options)
|
||||
http_options = user_http_options
|
||||
http_options.api_version = ''
|
||||
http_options.headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Upload-Protocol': 'resumable',
|
||||
'X-Goog-Upload-Command': 'start',
|
||||
'X-Goog-Upload-Header-Content-Length': f'{size_bytes}',
|
||||
'X-Goog-Upload-Header-Content-Type': f'{mime_type}',
|
||||
}
|
||||
else:
|
||||
http_options = types.HttpOptions(
|
||||
api_version='',
|
||||
headers={
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Upload-Protocol': 'resumable',
|
||||
'X-Goog-Upload-Command': 'start',
|
||||
'X-Goog-Upload-Header-Content-Length': f'{size_bytes}',
|
||||
'X-Goog-Upload-Header-Content-Type': f'{mime_type}',
|
||||
},
|
||||
)
|
||||
if isinstance(file, (str, os.PathLike)):
|
||||
if http_options.headers is None:
|
||||
http_options.headers = {}
|
||||
http_options.headers['X-Goog-Upload-File-Name'] = os.path.basename(file)
|
||||
return http_options, size_bytes, mime_type
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
|
||||
|
||||
import typing as _t
|
||||
|
||||
from . import types
|
||||
from ._types import NOT_GIVEN, Omit, NoneType, NotGiven, Transport, ProxiesTypes, omit, not_given
|
||||
from ._utils import file_from_path
|
||||
from ._client import (
|
||||
Client,
|
||||
Stream,
|
||||
Timeout,
|
||||
Transport,
|
||||
AsyncClient,
|
||||
AsyncStream,
|
||||
RequestOptions,
|
||||
GeminiNextGenAPIClient,
|
||||
AsyncGeminiNextGenAPIClient,
|
||||
)
|
||||
from ._models import BaseModel
|
||||
from ._version import __title__, __version__
|
||||
from ._response import APIResponse as APIResponse, AsyncAPIResponse as AsyncAPIResponse
|
||||
from ._constants import DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES, DEFAULT_CONNECTION_LIMITS
|
||||
from ._exceptions import (
|
||||
APIError,
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
APIStatusError,
|
||||
RateLimitError,
|
||||
APITimeoutError,
|
||||
BadRequestError,
|
||||
APIConnectionError,
|
||||
AuthenticationError,
|
||||
InternalServerError,
|
||||
PermissionDeniedError,
|
||||
UnprocessableEntityError,
|
||||
APIResponseValidationError,
|
||||
GeminiNextGenAPIClientError,
|
||||
)
|
||||
from ._base_client import DefaultHttpxClient, DefaultAioHttpClient, DefaultAsyncHttpxClient
|
||||
from ._utils._logs import setup_logging as _setup_logging
|
||||
from ._client_adapter import GeminiNextGenAPIClientAdapter, AsyncGeminiNextGenAPIClientAdapter
|
||||
|
||||
__all__ = [
|
||||
"types",
|
||||
"__version__",
|
||||
"__title__",
|
||||
"NoneType",
|
||||
"Transport",
|
||||
"ProxiesTypes",
|
||||
"NotGiven",
|
||||
"NOT_GIVEN",
|
||||
"not_given",
|
||||
"Omit",
|
||||
"omit",
|
||||
"GeminiNextGenAPIClientError",
|
||||
"APIError",
|
||||
"APIStatusError",
|
||||
"APITimeoutError",
|
||||
"APIConnectionError",
|
||||
"APIResponseValidationError",
|
||||
"BadRequestError",
|
||||
"AuthenticationError",
|
||||
"PermissionDeniedError",
|
||||
"NotFoundError",
|
||||
"ConflictError",
|
||||
"UnprocessableEntityError",
|
||||
"RateLimitError",
|
||||
"InternalServerError",
|
||||
"Timeout",
|
||||
"RequestOptions",
|
||||
"Client",
|
||||
"AsyncClient",
|
||||
"Stream",
|
||||
"AsyncStream",
|
||||
"GeminiNextGenAPIClient",
|
||||
"AsyncGeminiNextGenAPIClient",
|
||||
"file_from_path",
|
||||
"BaseModel",
|
||||
"DEFAULT_TIMEOUT",
|
||||
"DEFAULT_MAX_RETRIES",
|
||||
"DEFAULT_CONNECTION_LIMITS",
|
||||
"DefaultHttpxClient",
|
||||
"DefaultAsyncHttpxClient",
|
||||
"DefaultAioHttpClient",
|
||||
"AsyncGeminiNextGenAPIClientAdapter",
|
||||
"GeminiNextGenAPIClientAdapter"
|
||||
]
|
||||
|
||||
if not _t.TYPE_CHECKING:
|
||||
from ._utils._resources_proxy import resources as resources
|
||||
|
||||
_setup_logging()
|
||||
|
||||
# Update the __module__ attribute for exported symbols so that
|
||||
# error messages point to this module instead of the module
|
||||
# it was originally defined in, e.g.
|
||||
# google.genai._interactions._exceptions.NotFoundError -> google.genai._interactions.NotFoundError
|
||||
__locals = locals()
|
||||
for __name in __all__:
|
||||
if not __name.startswith("__"):
|
||||
try:
|
||||
__locals[__name].__module__ = "google.genai._interactions"
|
||||
except (TypeError, AttributeError):
|
||||
# Some of our exported symbols are builtins which we can't set attributes for.
|
||||
pass
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,600 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, Mapping
|
||||
from typing_extensions import Self, override
|
||||
|
||||
import httpx
|
||||
|
||||
from . import _exceptions
|
||||
from ._qs import Querystring
|
||||
from ._types import (
|
||||
Omit,
|
||||
Headers,
|
||||
Timeout,
|
||||
NotGiven,
|
||||
Transport,
|
||||
ProxiesTypes,
|
||||
RequestOptions,
|
||||
not_given,
|
||||
)
|
||||
from ._utils import is_given
|
||||
from ._compat import cached_property
|
||||
from ._models import FinalRequestOptions
|
||||
from ._version import __version__
|
||||
from ._streaming import Stream as Stream, AsyncStream as AsyncStream
|
||||
from ._exceptions import APIStatusError
|
||||
from ._base_client import (
|
||||
DEFAULT_MAX_RETRIES,
|
||||
SyncAPIClient,
|
||||
AsyncAPIClient,
|
||||
)
|
||||
from ._client_adapter import GeminiNextGenAPIClientAdapter, AsyncGeminiNextGenAPIClientAdapter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .resources import webhooks, interactions
|
||||
from .resources.webhooks import WebhooksResource, AsyncWebhooksResource
|
||||
from .resources.interactions import InteractionsResource, AsyncInteractionsResource
|
||||
|
||||
__all__ = [
|
||||
"Timeout",
|
||||
"Transport",
|
||||
"ProxiesTypes",
|
||||
"RequestOptions",
|
||||
"GeminiNextGenAPIClient",
|
||||
"AsyncGeminiNextGenAPIClient",
|
||||
"Client",
|
||||
"AsyncClient",
|
||||
]
|
||||
|
||||
|
||||
class GeminiNextGenAPIClient(SyncAPIClient):
|
||||
# client options
|
||||
api_key: str | None
|
||||
api_version: str
|
||||
client_adapter: GeminiNextGenAPIClientAdapter | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
api_version: str | None = "v1beta",
|
||||
base_url: str | httpx.URL | None = None,
|
||||
timeout: float | Timeout | None | NotGiven = not_given,
|
||||
max_retries: int = DEFAULT_MAX_RETRIES,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
default_query: Mapping[str, object] | None = None,
|
||||
# Configure a custom httpx client.
|
||||
# We provide a `DefaultHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`.
|
||||
# See the [httpx documentation](https://www.python-httpx.org/api/#client) for more details.
|
||||
http_client: httpx.Client | None = None,
|
||||
client_adapter: GeminiNextGenAPIClientAdapter | None = None,
|
||||
# Enable or disable schema validation for data returned by the API.
|
||||
# When enabled an error APIResponseValidationError is raised
|
||||
# if the API responds with invalid data for the expected schema.
|
||||
#
|
||||
# This parameter may be removed or changed in the future.
|
||||
# If you rely on this feature, please open a GitHub issue
|
||||
# outlining your use-case to help us decide if it should be
|
||||
# part of our public interface in the future.
|
||||
_strict_response_validation: bool = False,
|
||||
) -> None:
|
||||
"""Construct a new synchronous GeminiNextGenAPIClient client instance.
|
||||
|
||||
This automatically infers the `api_key` argument from the `GEMINI_API_KEY` environment variable if it is not provided.
|
||||
"""
|
||||
if api_key is None:
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
self.api_key = api_key
|
||||
|
||||
if api_version is None:
|
||||
api_version = "v1beta"
|
||||
self.api_version = api_version
|
||||
|
||||
if base_url is None:
|
||||
base_url = os.environ.get("GEMINI_NEXT_GEN_API_BASE_URL")
|
||||
if base_url is None:
|
||||
base_url = f"https://generativelanguage.googleapis.com"
|
||||
|
||||
self.client_adapter = client_adapter
|
||||
|
||||
super().__init__(
|
||||
version=__version__,
|
||||
base_url=base_url,
|
||||
max_retries=max_retries,
|
||||
timeout=timeout,
|
||||
http_client=http_client,
|
||||
custom_headers=default_headers,
|
||||
custom_query=default_query,
|
||||
_strict_response_validation=_strict_response_validation,
|
||||
)
|
||||
|
||||
self._default_stream_cls = Stream
|
||||
|
||||
@cached_property
|
||||
def interactions(self) -> InteractionsResource:
|
||||
from .resources.interactions import InteractionsResource
|
||||
|
||||
return InteractionsResource(self)
|
||||
|
||||
@cached_property
|
||||
def webhooks(self) -> WebhooksResource:
|
||||
from .resources.webhooks import WebhooksResource
|
||||
|
||||
return WebhooksResource(self)
|
||||
|
||||
@cached_property
|
||||
def with_raw_response(self) -> GeminiNextGenAPIClientWithRawResponse:
|
||||
return GeminiNextGenAPIClientWithRawResponse(self)
|
||||
|
||||
@cached_property
|
||||
def with_streaming_response(self) -> GeminiNextGenAPIClientWithStreamedResponse:
|
||||
return GeminiNextGenAPIClientWithStreamedResponse(self)
|
||||
|
||||
@property
|
||||
@override
|
||||
def qs(self) -> Querystring:
|
||||
return Querystring(array_format="comma")
|
||||
|
||||
@property
|
||||
@override
|
||||
def auth_headers(self) -> dict[str, str]:
|
||||
api_key = self.api_key
|
||||
if api_key is None:
|
||||
return {}
|
||||
return {"x-goog-api-key": api_key}
|
||||
|
||||
@property
|
||||
@override
|
||||
def default_headers(self) -> dict[str, str | Omit]:
|
||||
return {
|
||||
**super().default_headers,
|
||||
**self._custom_headers,
|
||||
}
|
||||
|
||||
@override
|
||||
def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None:
|
||||
if headers.get("Authorization") or custom_headers.get("Authorization") or isinstance(custom_headers.get("Authorization"), Omit):
|
||||
return
|
||||
if self.api_key and headers.get("x-goog-api-key"):
|
||||
return
|
||||
if custom_headers.get("x-goog-api-key") or isinstance(custom_headers.get("x-goog-api-key"), Omit):
|
||||
return
|
||||
|
||||
raise TypeError(
|
||||
'"Could not resolve authentication method. Expected the api_key to be set. Or for the `x-goog-api-key` headers to be explicitly omitted"'
|
||||
)
|
||||
|
||||
@override
|
||||
def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
|
||||
if not self.client_adapter or not self.client_adapter.is_vertex_ai():
|
||||
return options
|
||||
|
||||
headers = options.headers or {}
|
||||
has_auth = headers.get("Authorization") or headers.get("x-goog-api-key") # pytype: disable=attribute-error
|
||||
if has_auth:
|
||||
return options
|
||||
|
||||
adapted_headers = self.client_adapter.get_auth_headers()
|
||||
if adapted_headers:
|
||||
options.headers = {
|
||||
**adapted_headers,
|
||||
**headers
|
||||
}
|
||||
return options
|
||||
|
||||
def copy(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
api_version: str | None = None,
|
||||
base_url: str | httpx.URL | None = None,
|
||||
timeout: float | Timeout | None | NotGiven = not_given,
|
||||
http_client: httpx.Client | None = None,
|
||||
max_retries: int | NotGiven = not_given,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
set_default_headers: Mapping[str, str] | None = None,
|
||||
default_query: Mapping[str, object] | None = None,
|
||||
set_default_query: Mapping[str, object] | None = None,
|
||||
client_adapter: GeminiNextGenAPIClientAdapter | None = None,
|
||||
_extra_kwargs: Mapping[str, Any] = {},
|
||||
) -> Self:
|
||||
"""
|
||||
Create a new client instance re-using the same options given to the current client with optional overriding.
|
||||
"""
|
||||
if default_headers is not None and set_default_headers is not None:
|
||||
raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive")
|
||||
|
||||
if default_query is not None and set_default_query is not None:
|
||||
raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive")
|
||||
|
||||
headers = self._custom_headers
|
||||
if default_headers is not None:
|
||||
headers = {**headers, **default_headers}
|
||||
elif set_default_headers is not None:
|
||||
headers = set_default_headers
|
||||
|
||||
params = self._custom_query
|
||||
if default_query is not None:
|
||||
params = {**params, **default_query}
|
||||
elif set_default_query is not None:
|
||||
params = set_default_query
|
||||
|
||||
http_client = http_client or self._client
|
||||
return self.__class__(
|
||||
api_key=api_key or self.api_key,
|
||||
api_version=api_version or self.api_version,
|
||||
base_url=base_url or self.base_url,
|
||||
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
|
||||
http_client=http_client,
|
||||
max_retries=max_retries if is_given(max_retries) else self.max_retries,
|
||||
default_headers=headers,
|
||||
default_query=params,
|
||||
client_adapter=self.client_adapter or client_adapter,
|
||||
**_extra_kwargs,
|
||||
)
|
||||
|
||||
# Alias for `copy` for nicer inline usage, e.g.
|
||||
# client.with_options(timeout=10).foo.create(...)
|
||||
with_options = copy
|
||||
|
||||
def _get_api_version_path_param(self) -> str:
|
||||
return self.api_version
|
||||
|
||||
@override
|
||||
def _make_status_error(
|
||||
self,
|
||||
err_msg: str,
|
||||
*,
|
||||
body: object,
|
||||
response: httpx.Response,
|
||||
) -> APIStatusError:
|
||||
if response.status_code == 400:
|
||||
return _exceptions.BadRequestError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 401:
|
||||
return _exceptions.AuthenticationError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 403:
|
||||
return _exceptions.PermissionDeniedError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 404:
|
||||
return _exceptions.NotFoundError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 409:
|
||||
return _exceptions.ConflictError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 422:
|
||||
return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 429:
|
||||
return _exceptions.RateLimitError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code >= 500:
|
||||
return _exceptions.InternalServerError(err_msg, response=response, body=body)
|
||||
return APIStatusError(err_msg, response=response, body=body)
|
||||
|
||||
|
||||
class AsyncGeminiNextGenAPIClient(AsyncAPIClient):
|
||||
# client options
|
||||
api_key: str | None
|
||||
api_version: str
|
||||
client_adapter: AsyncGeminiNextGenAPIClientAdapter | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
api_version: str | None = "v1beta",
|
||||
base_url: str | httpx.URL | None = None,
|
||||
timeout: float | Timeout | None | NotGiven = not_given,
|
||||
max_retries: int = DEFAULT_MAX_RETRIES,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
default_query: Mapping[str, object] | None = None,
|
||||
# Configure a custom httpx client.
|
||||
# We provide a `DefaultAsyncHttpxClient` class that you can pass to retain the default values we use for `limits`, `timeout` & `follow_redirects`.
|
||||
# See the [httpx documentation](https://www.python-httpx.org/api/#asyncclient) for more details.
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
client_adapter: AsyncGeminiNextGenAPIClientAdapter | None = None,
|
||||
# Enable or disable schema validation for data returned by the API.
|
||||
# When enabled an error APIResponseValidationError is raised
|
||||
# if the API responds with invalid data for the expected schema.
|
||||
#
|
||||
# This parameter may be removed or changed in the future.
|
||||
# If you rely on this feature, please open a GitHub issue
|
||||
# outlining your use-case to help us decide if it should be
|
||||
# part of our public interface in the future.
|
||||
_strict_response_validation: bool = False,
|
||||
) -> None:
|
||||
"""Construct a new async AsyncGeminiNextGenAPIClient client instance.
|
||||
|
||||
This automatically infers the `api_key` argument from the `GEMINI_API_KEY` environment variable if it is not provided.
|
||||
"""
|
||||
if api_key is None:
|
||||
api_key = os.environ.get("GEMINI_API_KEY")
|
||||
self.api_key = api_key
|
||||
|
||||
if api_version is None:
|
||||
api_version = "v1beta"
|
||||
self.api_version = api_version
|
||||
|
||||
if base_url is None:
|
||||
base_url = os.environ.get("GEMINI_NEXT_GEN_API_BASE_URL")
|
||||
if base_url is None:
|
||||
base_url = f"https://generativelanguage.googleapis.com"
|
||||
|
||||
self.client_adapter = client_adapter
|
||||
|
||||
super().__init__(
|
||||
version=__version__,
|
||||
base_url=base_url,
|
||||
max_retries=max_retries,
|
||||
timeout=timeout,
|
||||
http_client=http_client,
|
||||
custom_headers=default_headers,
|
||||
custom_query=default_query,
|
||||
_strict_response_validation=_strict_response_validation,
|
||||
)
|
||||
|
||||
self._default_stream_cls = AsyncStream
|
||||
|
||||
@cached_property
|
||||
def interactions(self) -> AsyncInteractionsResource:
|
||||
from .resources.interactions import AsyncInteractionsResource
|
||||
|
||||
return AsyncInteractionsResource(self)
|
||||
|
||||
@cached_property
|
||||
def webhooks(self) -> AsyncWebhooksResource:
|
||||
from .resources.webhooks import AsyncWebhooksResource
|
||||
|
||||
return AsyncWebhooksResource(self)
|
||||
|
||||
@cached_property
|
||||
def with_raw_response(self) -> AsyncGeminiNextGenAPIClientWithRawResponse:
|
||||
return AsyncGeminiNextGenAPIClientWithRawResponse(self)
|
||||
|
||||
@cached_property
|
||||
def with_streaming_response(self) -> AsyncGeminiNextGenAPIClientWithStreamedResponse:
|
||||
return AsyncGeminiNextGenAPIClientWithStreamedResponse(self)
|
||||
|
||||
@property
|
||||
@override
|
||||
def qs(self) -> Querystring:
|
||||
return Querystring(array_format="comma")
|
||||
|
||||
@property
|
||||
@override
|
||||
def auth_headers(self) -> dict[str, str]:
|
||||
api_key = self.api_key
|
||||
if api_key is None:
|
||||
return {}
|
||||
return {"x-goog-api-key": api_key}
|
||||
|
||||
@property
|
||||
@override
|
||||
def default_headers(self) -> dict[str, str | Omit]:
|
||||
return {
|
||||
**super().default_headers,
|
||||
**self._custom_headers,
|
||||
}
|
||||
|
||||
@override
|
||||
def _validate_headers(self, headers: Headers, custom_headers: Headers) -> None:
|
||||
if headers.get("Authorization") or custom_headers.get("Authorization") or isinstance(custom_headers.get("Authorization"), Omit):
|
||||
return
|
||||
if self.api_key and headers.get("x-goog-api-key"):
|
||||
return
|
||||
if custom_headers.get("x-goog-api-key") or isinstance(custom_headers.get("x-goog-api-key"), Omit):
|
||||
return
|
||||
|
||||
raise TypeError(
|
||||
'"Could not resolve authentication method. Expected the api_key to be set. Or for the `x-goog-api-key` headers to be explicitly omitted"'
|
||||
)
|
||||
|
||||
@override
|
||||
async def _prepare_options(self, options: FinalRequestOptions) -> FinalRequestOptions:
|
||||
if not self.client_adapter or not self.client_adapter.is_vertex_ai():
|
||||
return options
|
||||
|
||||
headers = options.headers or {}
|
||||
has_auth = headers.get("Authorization") or headers.get("x-goog-api-key") # pytype: disable=attribute-error
|
||||
if has_auth:
|
||||
return options
|
||||
|
||||
adapted_headers = await self.client_adapter.async_get_auth_headers()
|
||||
if adapted_headers:
|
||||
options.headers = {
|
||||
**adapted_headers,
|
||||
**headers
|
||||
}
|
||||
return options
|
||||
|
||||
def copy(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
api_version: str | None = None,
|
||||
base_url: str | httpx.URL | None = None,
|
||||
timeout: float | Timeout | None | NotGiven = not_given,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
max_retries: int | NotGiven = not_given,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
set_default_headers: Mapping[str, str] | None = None,
|
||||
default_query: Mapping[str, object] | None = None,
|
||||
set_default_query: Mapping[str, object] | None = None,
|
||||
client_adapter: AsyncGeminiNextGenAPIClientAdapter | None = None,
|
||||
_extra_kwargs: Mapping[str, Any] = {},
|
||||
) -> Self:
|
||||
"""
|
||||
Create a new client instance re-using the same options given to the current client with optional overriding.
|
||||
"""
|
||||
if default_headers is not None and set_default_headers is not None:
|
||||
raise ValueError("The `default_headers` and `set_default_headers` arguments are mutually exclusive")
|
||||
|
||||
if default_query is not None and set_default_query is not None:
|
||||
raise ValueError("The `default_query` and `set_default_query` arguments are mutually exclusive")
|
||||
|
||||
headers = self._custom_headers
|
||||
if default_headers is not None:
|
||||
headers = {**headers, **default_headers}
|
||||
elif set_default_headers is not None:
|
||||
headers = set_default_headers
|
||||
|
||||
params = self._custom_query
|
||||
if default_query is not None:
|
||||
params = {**params, **default_query}
|
||||
elif set_default_query is not None:
|
||||
params = set_default_query
|
||||
|
||||
http_client = http_client or self._client
|
||||
return self.__class__(
|
||||
api_key=api_key or self.api_key,
|
||||
api_version=api_version or self.api_version,
|
||||
base_url=base_url or self.base_url,
|
||||
timeout=self.timeout if isinstance(timeout, NotGiven) else timeout,
|
||||
http_client=http_client,
|
||||
max_retries=max_retries if is_given(max_retries) else self.max_retries,
|
||||
default_headers=headers,
|
||||
default_query=params,
|
||||
client_adapter=self.client_adapter or client_adapter,
|
||||
**_extra_kwargs,
|
||||
)
|
||||
|
||||
# Alias for `copy` for nicer inline usage, e.g.
|
||||
# client.with_options(timeout=10).foo.create(...)
|
||||
with_options = copy
|
||||
|
||||
def _get_api_version_path_param(self) -> str:
|
||||
return self.api_version
|
||||
|
||||
@override
|
||||
def _make_status_error(
|
||||
self,
|
||||
err_msg: str,
|
||||
*,
|
||||
body: object,
|
||||
response: httpx.Response,
|
||||
) -> APIStatusError:
|
||||
if response.status_code == 400:
|
||||
return _exceptions.BadRequestError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 401:
|
||||
return _exceptions.AuthenticationError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 403:
|
||||
return _exceptions.PermissionDeniedError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 404:
|
||||
return _exceptions.NotFoundError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 409:
|
||||
return _exceptions.ConflictError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 422:
|
||||
return _exceptions.UnprocessableEntityError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code == 429:
|
||||
return _exceptions.RateLimitError(err_msg, response=response, body=body)
|
||||
|
||||
if response.status_code >= 500:
|
||||
return _exceptions.InternalServerError(err_msg, response=response, body=body)
|
||||
return APIStatusError(err_msg, response=response, body=body)
|
||||
|
||||
|
||||
class GeminiNextGenAPIClientWithRawResponse:
|
||||
_client: GeminiNextGenAPIClient
|
||||
|
||||
def __init__(self, client: GeminiNextGenAPIClient) -> None:
|
||||
self._client = client
|
||||
|
||||
@cached_property
|
||||
def interactions(self) -> interactions.InteractionsResourceWithRawResponse:
|
||||
from .resources.interactions import InteractionsResourceWithRawResponse
|
||||
|
||||
return InteractionsResourceWithRawResponse(self._client.interactions)
|
||||
|
||||
@cached_property
|
||||
def webhooks(self) -> webhooks.WebhooksResourceWithRawResponse:
|
||||
from .resources.webhooks import WebhooksResourceWithRawResponse
|
||||
|
||||
return WebhooksResourceWithRawResponse(self._client.webhooks)
|
||||
|
||||
|
||||
class AsyncGeminiNextGenAPIClientWithRawResponse:
|
||||
_client: AsyncGeminiNextGenAPIClient
|
||||
|
||||
def __init__(self, client: AsyncGeminiNextGenAPIClient) -> None:
|
||||
self._client = client
|
||||
|
||||
@cached_property
|
||||
def interactions(self) -> interactions.AsyncInteractionsResourceWithRawResponse:
|
||||
from .resources.interactions import AsyncInteractionsResourceWithRawResponse
|
||||
|
||||
return AsyncInteractionsResourceWithRawResponse(self._client.interactions)
|
||||
|
||||
@cached_property
|
||||
def webhooks(self) -> webhooks.AsyncWebhooksResourceWithRawResponse:
|
||||
from .resources.webhooks import AsyncWebhooksResourceWithRawResponse
|
||||
|
||||
return AsyncWebhooksResourceWithRawResponse(self._client.webhooks)
|
||||
|
||||
|
||||
class GeminiNextGenAPIClientWithStreamedResponse:
|
||||
_client: GeminiNextGenAPIClient
|
||||
|
||||
def __init__(self, client: GeminiNextGenAPIClient) -> None:
|
||||
self._client = client
|
||||
|
||||
@cached_property
|
||||
def interactions(self) -> interactions.InteractionsResourceWithStreamingResponse:
|
||||
from .resources.interactions import InteractionsResourceWithStreamingResponse
|
||||
|
||||
return InteractionsResourceWithStreamingResponse(self._client.interactions)
|
||||
|
||||
@cached_property
|
||||
def webhooks(self) -> webhooks.WebhooksResourceWithStreamingResponse:
|
||||
from .resources.webhooks import WebhooksResourceWithStreamingResponse
|
||||
|
||||
return WebhooksResourceWithStreamingResponse(self._client.webhooks)
|
||||
|
||||
|
||||
class AsyncGeminiNextGenAPIClientWithStreamedResponse:
|
||||
_client: AsyncGeminiNextGenAPIClient
|
||||
|
||||
def __init__(self, client: AsyncGeminiNextGenAPIClient) -> None:
|
||||
self._client = client
|
||||
|
||||
@cached_property
|
||||
def interactions(self) -> interactions.AsyncInteractionsResourceWithStreamingResponse:
|
||||
from .resources.interactions import AsyncInteractionsResourceWithStreamingResponse
|
||||
|
||||
return AsyncInteractionsResourceWithStreamingResponse(self._client.interactions)
|
||||
|
||||
@cached_property
|
||||
def webhooks(self) -> webhooks.AsyncWebhooksResourceWithStreamingResponse:
|
||||
from .resources.webhooks import AsyncWebhooksResourceWithStreamingResponse
|
||||
|
||||
return AsyncWebhooksResourceWithStreamingResponse(self._client.webhooks)
|
||||
|
||||
|
||||
Client = GeminiNextGenAPIClient
|
||||
|
||||
AsyncClient = AsyncGeminiNextGenAPIClient
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
__all__ = [
|
||||
"GeminiNextGenAPIClientAdapter",
|
||||
"AsyncGeminiNextGenAPIClientAdapter"
|
||||
]
|
||||
|
||||
class BaseGeminiNextGenAPIClientAdapter(ABC):
|
||||
@abstractmethod
|
||||
def is_vertex_ai(self) -> bool:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_project(self) -> str | None:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def get_location(self) -> str | None:
|
||||
...
|
||||
|
||||
|
||||
class AsyncGeminiNextGenAPIClientAdapter(BaseGeminiNextGenAPIClientAdapter):
|
||||
@abstractmethod
|
||||
async def async_get_auth_headers(self) -> dict[str, str] | None:
|
||||
...
|
||||
|
||||
|
||||
class GeminiNextGenAPIClientAdapter(BaseGeminiNextGenAPIClientAdapter):
|
||||
@abstractmethod
|
||||
def get_auth_headers(self) -> dict[str, str] | None:
|
||||
...
|
||||
@@ -0,0 +1,241 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Union, Generic, TypeVar, Callable, cast, overload
|
||||
from datetime import date, datetime
|
||||
from typing_extensions import Self, Literal, TypedDict
|
||||
|
||||
import pydantic
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from ._types import IncEx, StrBytesIntFloat
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_ModelT = TypeVar("_ModelT", bound=pydantic.BaseModel)
|
||||
|
||||
# --------------- Pydantic v2, v3 compatibility ---------------
|
||||
|
||||
# Pyright incorrectly reports some of our functions as overriding a method when they don't
|
||||
# pyright: reportIncompatibleMethodOverride=false
|
||||
|
||||
PYDANTIC_V1 = pydantic.VERSION.startswith("1.")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
def parse_date(value: date | StrBytesIntFloat) -> date: # noqa: ARG001
|
||||
...
|
||||
|
||||
def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime: # noqa: ARG001
|
||||
...
|
||||
|
||||
def get_args(t: type[Any]) -> tuple[Any, ...]: # noqa: ARG001
|
||||
...
|
||||
|
||||
def is_union(tp: type[Any] | None) -> bool: # noqa: ARG001
|
||||
...
|
||||
|
||||
def get_origin(t: type[Any]) -> type[Any] | None: # noqa: ARG001
|
||||
...
|
||||
|
||||
def is_literal_type(type_: type[Any]) -> bool: # noqa: ARG001
|
||||
...
|
||||
|
||||
def is_typeddict(type_: type[Any]) -> bool: # noqa: ARG001
|
||||
...
|
||||
|
||||
else:
|
||||
# v1 re-exports
|
||||
if PYDANTIC_V1:
|
||||
from pydantic.typing import (
|
||||
get_args as get_args,
|
||||
is_union as is_union,
|
||||
get_origin as get_origin,
|
||||
is_typeddict as is_typeddict,
|
||||
is_literal_type as is_literal_type,
|
||||
)
|
||||
from pydantic.datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime
|
||||
else:
|
||||
from ._utils import (
|
||||
get_args as get_args,
|
||||
is_union as is_union,
|
||||
get_origin as get_origin,
|
||||
parse_date as parse_date,
|
||||
is_typeddict as is_typeddict,
|
||||
parse_datetime as parse_datetime,
|
||||
is_literal_type as is_literal_type,
|
||||
)
|
||||
|
||||
|
||||
# refactored config
|
||||
if TYPE_CHECKING:
|
||||
from pydantic import ConfigDict as ConfigDict
|
||||
else:
|
||||
if PYDANTIC_V1:
|
||||
# TODO: provide an error message here?
|
||||
ConfigDict = None
|
||||
else:
|
||||
from pydantic import ConfigDict as ConfigDict
|
||||
|
||||
|
||||
# renamed methods / properties
|
||||
def parse_obj(model: type[_ModelT], value: object) -> _ModelT:
|
||||
if PYDANTIC_V1:
|
||||
return cast(_ModelT, model.parse_obj(value)) # pyright: ignore[reportDeprecated, reportUnnecessaryCast]
|
||||
else:
|
||||
return model.model_validate(value)
|
||||
|
||||
|
||||
def field_is_required(field: FieldInfo) -> bool:
|
||||
if PYDANTIC_V1:
|
||||
return field.required # type: ignore
|
||||
return field.is_required()
|
||||
|
||||
|
||||
def field_get_default(field: FieldInfo) -> Any:
|
||||
value = field.get_default()
|
||||
if PYDANTIC_V1:
|
||||
return value
|
||||
from pydantic_core import PydanticUndefined
|
||||
|
||||
if value == PydanticUndefined:
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def field_outer_type(field: FieldInfo) -> Any:
|
||||
if PYDANTIC_V1:
|
||||
return field.outer_type_ # type: ignore
|
||||
return field.annotation
|
||||
|
||||
|
||||
def get_model_config(model: type[pydantic.BaseModel]) -> Any:
|
||||
if PYDANTIC_V1:
|
||||
return model.__config__ # type: ignore
|
||||
return model.model_config
|
||||
|
||||
|
||||
def get_model_fields(model: type[pydantic.BaseModel]) -> dict[str, FieldInfo]:
|
||||
if PYDANTIC_V1:
|
||||
return model.__fields__ # type: ignore
|
||||
return model.model_fields
|
||||
|
||||
|
||||
def model_copy(model: _ModelT, *, deep: bool = False) -> _ModelT:
|
||||
if PYDANTIC_V1:
|
||||
return model.copy(deep=deep) # type: ignore
|
||||
return model.model_copy(deep=deep)
|
||||
|
||||
|
||||
def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str:
|
||||
if PYDANTIC_V1:
|
||||
return model.json(indent=indent) # type: ignore
|
||||
return model.model_dump_json(indent=indent)
|
||||
|
||||
|
||||
class _ModelDumpKwargs(TypedDict, total=False):
|
||||
by_alias: bool
|
||||
|
||||
|
||||
def model_dump(
|
||||
model: pydantic.BaseModel,
|
||||
*,
|
||||
exclude: IncEx | None = None,
|
||||
exclude_unset: bool = False,
|
||||
exclude_defaults: bool = False,
|
||||
warnings: bool = True,
|
||||
mode: Literal["json", "python"] = "python",
|
||||
by_alias: bool | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if (not PYDANTIC_V1) or hasattr(model, "model_dump"):
|
||||
kwargs: _ModelDumpKwargs = {}
|
||||
if by_alias is not None:
|
||||
kwargs["by_alias"] = by_alias
|
||||
return model.model_dump(
|
||||
mode=mode,
|
||||
exclude=exclude,
|
||||
exclude_unset=exclude_unset,
|
||||
exclude_defaults=exclude_defaults,
|
||||
# warnings are not supported in Pydantic v1
|
||||
warnings=True if PYDANTIC_V1 else warnings,
|
||||
**kwargs,
|
||||
)
|
||||
return cast(
|
||||
"dict[str, Any]",
|
||||
model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast]
|
||||
exclude=exclude, exclude_unset=exclude_unset, exclude_defaults=exclude_defaults, by_alias=bool(by_alias)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def model_parse(model: type[_ModelT], data: Any) -> _ModelT:
|
||||
if PYDANTIC_V1:
|
||||
return model.parse_obj(data) # pyright: ignore[reportDeprecated]
|
||||
return model.model_validate(data)
|
||||
|
||||
|
||||
# generic models
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class GenericModel(pydantic.BaseModel): ...
|
||||
|
||||
else:
|
||||
if PYDANTIC_V1:
|
||||
import pydantic.generics
|
||||
|
||||
class GenericModel(pydantic.generics.GenericModel, pydantic.BaseModel): ...
|
||||
else:
|
||||
# there no longer needs to be a distinction in v2 but
|
||||
# we still have to create our own subclass to avoid
|
||||
# inconsistent MRO ordering errors
|
||||
class GenericModel(pydantic.BaseModel): ...
|
||||
|
||||
|
||||
# cached properties
|
||||
if TYPE_CHECKING:
|
||||
cached_property = property
|
||||
|
||||
# we define a separate type (copied from typeshed)
|
||||
# that represents that `cached_property` is `set`able
|
||||
# at runtime, which differs from `@property`.
|
||||
#
|
||||
# this is a separate type as editors likely special case
|
||||
# `@property` and we don't want to cause issues just to have
|
||||
# more helpful internal types.
|
||||
|
||||
class typed_cached_property(Generic[_T]):
|
||||
func: Callable[[Any], _T]
|
||||
attrname: str | None
|
||||
|
||||
def __init__(self, func: Callable[[Any], _T]) -> None: ...
|
||||
|
||||
@overload
|
||||
def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ...
|
||||
|
||||
@overload
|
||||
def __get__(self, instance: object, owner: type[Any] | None = None) -> _T: ...
|
||||
|
||||
def __get__(self, instance: object, owner: type[Any] | None = None) -> _T | Self:
|
||||
raise NotImplementedError()
|
||||
|
||||
def __set_name__(self, owner: type[Any], name: str) -> None: ...
|
||||
|
||||
# __set__ is not defined at runtime, but @cached_property is designed to be settable
|
||||
def __set__(self, instance: object, value: _T) -> None: ...
|
||||
else:
|
||||
from functools import cached_property as cached_property
|
||||
|
||||
typed_cached_property = cached_property
|
||||
@@ -0,0 +1,29 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
|
||||
|
||||
import httpx
|
||||
|
||||
RAW_RESPONSE_HEADER = "X-Stainless-Raw-Response"
|
||||
OVERRIDE_CAST_TO_HEADER = "____stainless_override_cast_to"
|
||||
|
||||
# default timeout is 1 minute
|
||||
DEFAULT_TIMEOUT = httpx.Timeout(timeout=60, connect=5.0)
|
||||
DEFAULT_MAX_RETRIES = 2
|
||||
DEFAULT_CONNECTION_LIMITS = httpx.Limits(max_connections=100, max_keepalive_connections=20)
|
||||
|
||||
INITIAL_RETRY_DELAY = 0.5
|
||||
MAX_RETRY_DELAY = 8.0
|
||||
@@ -0,0 +1,123 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing_extensions import Literal
|
||||
|
||||
import httpx
|
||||
|
||||
__all__ = [
|
||||
"BadRequestError",
|
||||
"AuthenticationError",
|
||||
"PermissionDeniedError",
|
||||
"NotFoundError",
|
||||
"ConflictError",
|
||||
"UnprocessableEntityError",
|
||||
"RateLimitError",
|
||||
"InternalServerError",
|
||||
]
|
||||
|
||||
|
||||
class GeminiNextGenAPIClientError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class APIError(GeminiNextGenAPIClientError):
|
||||
message: str
|
||||
request: httpx.Request
|
||||
|
||||
body: object | None
|
||||
"""The API response body.
|
||||
|
||||
If the API responded with a valid JSON structure then this property will be the
|
||||
decoded result.
|
||||
|
||||
If it isn't a valid JSON structure then this will be the raw response.
|
||||
|
||||
If there was no response associated with this error then it will be `None`.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None: # noqa: ARG002
|
||||
super().__init__(message)
|
||||
self.request = request
|
||||
self.message = message
|
||||
self.body = body
|
||||
|
||||
|
||||
class APIResponseValidationError(APIError):
|
||||
response: httpx.Response
|
||||
status_code: int
|
||||
|
||||
def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None:
|
||||
super().__init__(message or "Data returned by API invalid for expected schema.", response.request, body=body)
|
||||
self.response = response
|
||||
self.status_code = response.status_code
|
||||
|
||||
|
||||
class APIStatusError(APIError):
|
||||
"""Raised when an API response has a status code of 4xx or 5xx."""
|
||||
|
||||
response: httpx.Response
|
||||
status_code: int
|
||||
|
||||
def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None:
|
||||
super().__init__(message, response.request, body=body)
|
||||
self.response = response
|
||||
self.status_code = response.status_code
|
||||
|
||||
|
||||
class APIConnectionError(APIError):
|
||||
def __init__(self, *, message: str = "Connection error.", request: httpx.Request) -> None:
|
||||
super().__init__(message, request, body=None)
|
||||
|
||||
|
||||
class APITimeoutError(APIConnectionError):
|
||||
def __init__(self, request: httpx.Request) -> None:
|
||||
super().__init__(message="Request timed out.", request=request)
|
||||
|
||||
|
||||
class BadRequestError(APIStatusError):
|
||||
status_code: Literal[400] = 400 # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class AuthenticationError(APIStatusError):
|
||||
status_code: Literal[401] = 401 # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class PermissionDeniedError(APIStatusError):
|
||||
status_code: Literal[403] = 403 # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class NotFoundError(APIStatusError):
|
||||
status_code: Literal[404] = 404 # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class ConflictError(APIStatusError):
|
||||
status_code: Literal[409] = 409 # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class UnprocessableEntityError(APIStatusError):
|
||||
status_code: Literal[422] = 422 # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class RateLimitError(APIStatusError):
|
||||
status_code: Literal[429] = 429 # pyright: ignore[reportIncompatibleVariableOverride]
|
||||
|
||||
|
||||
class InternalServerError(APIStatusError):
|
||||
pass
|
||||
@@ -0,0 +1,139 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# mypy: ignore-errors
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import pathlib
|
||||
from typing import overload
|
||||
from typing_extensions import TypeGuard
|
||||
|
||||
import anyio
|
||||
|
||||
from ._types import (
|
||||
FileTypes,
|
||||
FileContent,
|
||||
RequestFiles,
|
||||
HttpxFileTypes,
|
||||
Base64FileInput,
|
||||
HttpxFileContent,
|
||||
HttpxRequestFiles,
|
||||
)
|
||||
from ._utils import is_tuple_t, is_mapping_t, is_sequence_t
|
||||
|
||||
|
||||
def is_base64_file_input(obj: object) -> TypeGuard[Base64FileInput]:
|
||||
return isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike)
|
||||
|
||||
|
||||
def is_file_content(obj: object) -> TypeGuard[FileContent]:
|
||||
return (
|
||||
isinstance(obj, bytes) or isinstance(obj, tuple) or isinstance(obj, io.IOBase) or isinstance(obj, os.PathLike)
|
||||
)
|
||||
|
||||
|
||||
def assert_is_file_content(obj: object, *, key: str | None = None) -> None:
|
||||
if not is_file_content(obj):
|
||||
prefix = f"Expected entry at `{key}`" if key is not None else f"Expected file input `{obj!r}`"
|
||||
raise RuntimeError(
|
||||
f"{prefix} to be bytes, an io.IOBase instance, PathLike or a tuple but received {type(obj)} instead."
|
||||
) from None
|
||||
|
||||
|
||||
@overload
|
||||
def to_httpx_files(files: None) -> None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ...
|
||||
|
||||
|
||||
def to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None:
|
||||
if files is None:
|
||||
return None
|
||||
|
||||
if is_mapping_t(files):
|
||||
files = {key: _transform_file(file) for key, file in files.items()}
|
||||
elif is_sequence_t(files):
|
||||
files = [(key, _transform_file(file)) for key, file in files]
|
||||
else:
|
||||
raise TypeError(f"Unexpected file type input {type(files)}, expected mapping or sequence")
|
||||
|
||||
return files
|
||||
|
||||
|
||||
def _transform_file(file: FileTypes) -> HttpxFileTypes:
|
||||
if is_file_content(file):
|
||||
if isinstance(file, os.PathLike):
|
||||
path = pathlib.Path(file)
|
||||
return (path.name, path.read_bytes())
|
||||
|
||||
return file
|
||||
|
||||
if is_tuple_t(file):
|
||||
return (file[0], read_file_content(file[1]), *file[2:])
|
||||
|
||||
raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple")
|
||||
|
||||
|
||||
def read_file_content(file: FileContent) -> HttpxFileContent:
|
||||
if isinstance(file, os.PathLike):
|
||||
return pathlib.Path(file).read_bytes()
|
||||
return file
|
||||
|
||||
|
||||
@overload
|
||||
async def async_to_httpx_files(files: None) -> None: ...
|
||||
|
||||
|
||||
@overload
|
||||
async def async_to_httpx_files(files: RequestFiles) -> HttpxRequestFiles: ...
|
||||
|
||||
|
||||
async def async_to_httpx_files(files: RequestFiles | None) -> HttpxRequestFiles | None:
|
||||
if files is None:
|
||||
return None
|
||||
|
||||
if is_mapping_t(files):
|
||||
files = {key: await _async_transform_file(file) for key, file in files.items()}
|
||||
elif is_sequence_t(files):
|
||||
files = [(key, await _async_transform_file(file)) for key, file in files]
|
||||
else:
|
||||
raise TypeError("Unexpected file type input {type(files)}, expected mapping or sequence")
|
||||
|
||||
return files
|
||||
|
||||
|
||||
async def _async_transform_file(file: FileTypes) -> HttpxFileTypes:
|
||||
if is_file_content(file):
|
||||
if isinstance(file, os.PathLike):
|
||||
path = anyio.Path(file)
|
||||
return (path.name, await path.read_bytes())
|
||||
|
||||
return file
|
||||
|
||||
if is_tuple_t(file):
|
||||
return (file[0], await async_read_file_content(file[1]), *file[2:])
|
||||
|
||||
raise TypeError(f"Expected file types input to be a FileContent type or to be a tuple")
|
||||
|
||||
|
||||
async def async_read_file_content(file: FileContent) -> HttpxFileContent:
|
||||
if isinstance(file, os.PathLike):
|
||||
return await anyio.Path(file).read_bytes()
|
||||
|
||||
return file
|
||||
@@ -0,0 +1,888 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# mypy: ignore-errors
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import inspect
|
||||
import weakref
|
||||
from typing import (
|
||||
IO,
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Type,
|
||||
Union,
|
||||
Generic,
|
||||
TypeVar,
|
||||
Callable,
|
||||
Iterable,
|
||||
Optional,
|
||||
AsyncIterable,
|
||||
cast,
|
||||
)
|
||||
from datetime import date, datetime
|
||||
from typing_extensions import (
|
||||
List,
|
||||
Unpack,
|
||||
Literal,
|
||||
ClassVar,
|
||||
Protocol,
|
||||
Required,
|
||||
ParamSpec,
|
||||
TypedDict,
|
||||
TypeGuard,
|
||||
final,
|
||||
override,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
import pydantic
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from ._types import (
|
||||
Body,
|
||||
IncEx,
|
||||
Query,
|
||||
ModelT,
|
||||
Headers,
|
||||
Timeout,
|
||||
NotGiven,
|
||||
AnyMapping,
|
||||
HttpxRequestFiles,
|
||||
)
|
||||
from ._utils import (
|
||||
PropertyInfo,
|
||||
is_list,
|
||||
is_given,
|
||||
json_safe,
|
||||
lru_cache,
|
||||
is_mapping,
|
||||
parse_date,
|
||||
coerce_boolean,
|
||||
parse_datetime,
|
||||
strip_not_given,
|
||||
extract_type_arg,
|
||||
is_annotated_type,
|
||||
is_type_alias_type,
|
||||
strip_annotated_type,
|
||||
)
|
||||
from ._compat import (
|
||||
PYDANTIC_V1,
|
||||
ConfigDict,
|
||||
GenericModel as BaseGenericModel,
|
||||
get_args,
|
||||
is_union,
|
||||
parse_obj,
|
||||
get_origin,
|
||||
is_literal_type,
|
||||
get_model_config,
|
||||
get_model_fields,
|
||||
field_get_default,
|
||||
)
|
||||
from ._constants import RAW_RESPONSE_HEADER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic_core.core_schema import ModelField, ModelSchema, LiteralSchema, ModelFieldsSchema
|
||||
|
||||
__all__ = ["BaseModel", "GenericModel"]
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_BaseModelT = TypeVar("_BaseModelT", bound="BaseModel")
|
||||
|
||||
P = ParamSpec("P")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _ConfigProtocol(Protocol):
|
||||
allow_population_by_field_name: bool
|
||||
|
||||
|
||||
class BaseModel(pydantic.BaseModel):
|
||||
if PYDANTIC_V1:
|
||||
|
||||
@property
|
||||
@override
|
||||
def model_fields_set(self) -> set[str]:
|
||||
# a forwards-compat shim for pydantic v2
|
||||
return self.__fields_set__ # type: ignore
|
||||
|
||||
class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated]
|
||||
extra: Any = pydantic.Extra.allow # type: ignore
|
||||
else:
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(
|
||||
extra="allow", defer_build=coerce_boolean(os.environ.get("DEFER_PYDANTIC_BUILD", "true"))
|
||||
)
|
||||
|
||||
def to_dict(
|
||||
self,
|
||||
*,
|
||||
mode: Literal["json", "python"] = "python",
|
||||
use_api_names: bool = True,
|
||||
exclude_unset: bool = True,
|
||||
exclude_defaults: bool = False,
|
||||
exclude_none: bool = False,
|
||||
warnings: bool = True,
|
||||
) -> dict[str, object]:
|
||||
"""Recursively generate a dictionary representation of the model, optionally specifying which fields to include or exclude.
|
||||
|
||||
By default, fields that were not set by the API will not be included,
|
||||
and keys will match the API response, *not* the property names from the model.
|
||||
|
||||
For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property,
|
||||
the output will use the `"fooBar"` key (unless `use_api_names=False` is passed).
|
||||
|
||||
Args:
|
||||
mode:
|
||||
If mode is 'json', the dictionary will only contain JSON serializable types. e.g. `datetime` will be turned into a string, `"2024-3-22T18:11:19.117000Z"`.
|
||||
If mode is 'python', the dictionary may contain any Python objects. e.g. `datetime(2024, 3, 22)`
|
||||
|
||||
use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`.
|
||||
exclude_unset: Whether to exclude fields that have not been explicitly set.
|
||||
exclude_defaults: Whether to exclude fields that are set to their default value from the output.
|
||||
exclude_none: Whether to exclude fields that have a value of `None` from the output.
|
||||
warnings: Whether to log warnings when invalid fields are encountered. This is only supported in Pydantic v2.
|
||||
"""
|
||||
return self.model_dump(
|
||||
mode=mode,
|
||||
by_alias=use_api_names,
|
||||
exclude_unset=exclude_unset,
|
||||
exclude_defaults=exclude_defaults,
|
||||
exclude_none=exclude_none,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
def to_json(
|
||||
self,
|
||||
*,
|
||||
indent: int | None = 2,
|
||||
use_api_names: bool = True,
|
||||
exclude_unset: bool = True,
|
||||
exclude_defaults: bool = False,
|
||||
exclude_none: bool = False,
|
||||
warnings: bool = True,
|
||||
) -> str:
|
||||
"""Generates a JSON string representing this model as it would be received from or sent to the API (but with indentation).
|
||||
|
||||
By default, fields that were not set by the API will not be included,
|
||||
and keys will match the API response, *not* the property names from the model.
|
||||
|
||||
For example, if the API responds with `"fooBar": true` but we've defined a `foo_bar: bool` property,
|
||||
the output will use the `"fooBar"` key (unless `use_api_names=False` is passed).
|
||||
|
||||
Args:
|
||||
indent: Indentation to use in the JSON output. If `None` is passed, the output will be compact. Defaults to `2`
|
||||
use_api_names: Whether to use the key that the API responded with or the property name. Defaults to `True`.
|
||||
exclude_unset: Whether to exclude fields that have not been explicitly set.
|
||||
exclude_defaults: Whether to exclude fields that have the default value.
|
||||
exclude_none: Whether to exclude fields that have a value of `None`.
|
||||
warnings: Whether to show any warnings that occurred during serialization. This is only supported in Pydantic v2.
|
||||
"""
|
||||
return self.model_dump_json(
|
||||
indent=indent,
|
||||
by_alias=use_api_names,
|
||||
exclude_unset=exclude_unset,
|
||||
exclude_defaults=exclude_defaults,
|
||||
exclude_none=exclude_none,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
# mypy complains about an invalid self arg
|
||||
return f"{self.__repr_name__()}({self.__repr_str__(', ')})" # type: ignore[misc]
|
||||
|
||||
# Override the 'construct' method in a way that supports recursive parsing without validation.
|
||||
# Based on https://github.com/samuelcolvin/pydantic/issues/1168#issuecomment-817742836.
|
||||
@classmethod
|
||||
@override
|
||||
def construct( # pyright: ignore[reportIncompatibleMethodOverride]
|
||||
__cls: Type[ModelT],
|
||||
_fields_set: set[str] | None = None,
|
||||
**values: object,
|
||||
) -> ModelT:
|
||||
m = __cls.__new__(__cls)
|
||||
fields_values: dict[str, object] = {}
|
||||
|
||||
config = get_model_config(__cls)
|
||||
populate_by_name = (
|
||||
config.allow_population_by_field_name
|
||||
if isinstance(config, _ConfigProtocol)
|
||||
else config.get("populate_by_name")
|
||||
)
|
||||
|
||||
if _fields_set is None:
|
||||
_fields_set = set()
|
||||
|
||||
model_fields = get_model_fields(__cls)
|
||||
for name, field in model_fields.items():
|
||||
key = field.alias
|
||||
if key is None or (key not in values and populate_by_name):
|
||||
key = name
|
||||
|
||||
if key in values:
|
||||
fields_values[name] = _construct_field(value=values[key], field=field, key=key)
|
||||
_fields_set.add(name)
|
||||
else:
|
||||
fields_values[name] = field_get_default(field)
|
||||
|
||||
extra_field_type = _get_extra_fields_type(__cls)
|
||||
|
||||
_extra = {}
|
||||
for key, value in values.items():
|
||||
if key not in model_fields:
|
||||
parsed = construct_type(value=value, type_=extra_field_type) if extra_field_type is not None else value
|
||||
|
||||
if PYDANTIC_V1:
|
||||
_fields_set.add(key)
|
||||
fields_values[key] = parsed
|
||||
else:
|
||||
_extra[key] = parsed
|
||||
|
||||
object.__setattr__(m, "__dict__", fields_values)
|
||||
|
||||
if PYDANTIC_V1:
|
||||
# init_private_attributes() does not exist in v2
|
||||
m._init_private_attributes() # type: ignore
|
||||
|
||||
# copied from Pydantic v1's `construct()` method
|
||||
object.__setattr__(m, "__fields_set__", _fields_set)
|
||||
else:
|
||||
# these properties are copied from Pydantic's `model_construct()` method
|
||||
object.__setattr__(m, "__pydantic_private__", None)
|
||||
object.__setattr__(m, "__pydantic_extra__", _extra)
|
||||
object.__setattr__(m, "__pydantic_fields_set__", _fields_set)
|
||||
|
||||
return m
|
||||
|
||||
if not TYPE_CHECKING:
|
||||
# type checkers incorrectly complain about this assignment
|
||||
# because the type signatures are technically different
|
||||
# although not in practice
|
||||
model_construct = construct
|
||||
|
||||
if PYDANTIC_V1:
|
||||
# we define aliases for some of the new pydantic v2 methods so
|
||||
# that we can just document these methods without having to specify
|
||||
# a specific pydantic version as some users may not know which
|
||||
# pydantic version they are currently using
|
||||
|
||||
@override
|
||||
def model_dump(
|
||||
self,
|
||||
*,
|
||||
mode: Literal["json", "python"] | str = "python",
|
||||
include: IncEx | None = None,
|
||||
exclude: IncEx | None = None,
|
||||
context: Any | None = None,
|
||||
by_alias: bool | None = None,
|
||||
exclude_unset: bool = False,
|
||||
exclude_defaults: bool = False,
|
||||
exclude_none: bool = False,
|
||||
exclude_computed_fields: bool = False,
|
||||
round_trip: bool = False,
|
||||
warnings: bool | Literal["none", "warn", "error"] = True,
|
||||
fallback: Callable[[Any], Any] | None = None,
|
||||
serialize_as_any: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump
|
||||
|
||||
Generate a dictionary representation of the model, optionally specifying which fields to include or exclude.
|
||||
|
||||
Args:
|
||||
mode: The mode in which `to_python` should run.
|
||||
If mode is 'json', the output will only contain JSON serializable types.
|
||||
If mode is 'python', the output may contain non-JSON-serializable Python objects.
|
||||
include: A set of fields to include in the output.
|
||||
exclude: A set of fields to exclude from the output.
|
||||
context: Additional context to pass to the serializer.
|
||||
by_alias: Whether to use the field's alias in the dictionary key if defined.
|
||||
exclude_unset: Whether to exclude fields that have not been explicitly set.
|
||||
exclude_defaults: Whether to exclude fields that are set to their default value.
|
||||
exclude_none: Whether to exclude fields that have a value of `None`.
|
||||
exclude_computed_fields: Whether to exclude computed fields.
|
||||
While this can be useful for round-tripping, it is usually recommended to use the dedicated
|
||||
`round_trip` parameter instead.
|
||||
round_trip: If True, dumped values should be valid as input for non-idempotent types such as Json[T].
|
||||
warnings: How to handle serialization errors. False/"none" ignores them, True/"warn" logs errors,
|
||||
"error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError].
|
||||
fallback: A function to call when an unknown value is encountered. If not provided,
|
||||
a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised.
|
||||
serialize_as_any: Whether to serialize fields with duck-typing serialization behavior.
|
||||
|
||||
Returns:
|
||||
A dictionary representation of the model.
|
||||
"""
|
||||
if mode not in {"json", "python"}:
|
||||
raise ValueError("mode must be either 'json' or 'python'")
|
||||
if round_trip != False:
|
||||
raise ValueError("round_trip is only supported in Pydantic v2")
|
||||
if warnings != True:
|
||||
raise ValueError("warnings is only supported in Pydantic v2")
|
||||
if context is not None:
|
||||
raise ValueError("context is only supported in Pydantic v2")
|
||||
if serialize_as_any != False:
|
||||
raise ValueError("serialize_as_any is only supported in Pydantic v2")
|
||||
if fallback is not None:
|
||||
raise ValueError("fallback is only supported in Pydantic v2")
|
||||
if exclude_computed_fields != False:
|
||||
raise ValueError("exclude_computed_fields is only supported in Pydantic v2")
|
||||
dumped = super().dict( # pyright: ignore[reportDeprecated]
|
||||
include=include,
|
||||
exclude=exclude,
|
||||
by_alias=by_alias if by_alias is not None else False,
|
||||
exclude_unset=exclude_unset,
|
||||
exclude_defaults=exclude_defaults,
|
||||
exclude_none=exclude_none,
|
||||
)
|
||||
|
||||
return cast("dict[str, Any]", json_safe(dumped)) if mode == "json" else dumped
|
||||
|
||||
@override
|
||||
def model_dump_json(
|
||||
self,
|
||||
*,
|
||||
indent: int | None = None,
|
||||
ensure_ascii: bool = False,
|
||||
include: IncEx | None = None,
|
||||
exclude: IncEx | None = None,
|
||||
context: Any | None = None,
|
||||
by_alias: bool | None = None,
|
||||
exclude_unset: bool = False,
|
||||
exclude_defaults: bool = False,
|
||||
exclude_none: bool = False,
|
||||
exclude_computed_fields: bool = False,
|
||||
round_trip: bool = False,
|
||||
warnings: bool | Literal["none", "warn", "error"] = True,
|
||||
fallback: Callable[[Any], Any] | None = None,
|
||||
serialize_as_any: bool = False,
|
||||
) -> str:
|
||||
"""Usage docs: https://docs.pydantic.dev/2.4/concepts/serialization/#modelmodel_dump_json
|
||||
|
||||
Generates a JSON representation of the model using Pydantic's `to_json` method.
|
||||
|
||||
Args:
|
||||
indent: Indentation to use in the JSON output. If None is passed, the output will be compact.
|
||||
include: Field(s) to include in the JSON output. Can take either a string or set of strings.
|
||||
exclude: Field(s) to exclude from the JSON output. Can take either a string or set of strings.
|
||||
by_alias: Whether to serialize using field aliases.
|
||||
exclude_unset: Whether to exclude fields that have not been explicitly set.
|
||||
exclude_defaults: Whether to exclude fields that have the default value.
|
||||
exclude_none: Whether to exclude fields that have a value of `None`.
|
||||
round_trip: Whether to use serialization/deserialization between JSON and class instance.
|
||||
warnings: Whether to show any warnings that occurred during serialization.
|
||||
|
||||
Returns:
|
||||
A JSON string representation of the model.
|
||||
"""
|
||||
if round_trip != False:
|
||||
raise ValueError("round_trip is only supported in Pydantic v2")
|
||||
if warnings != True:
|
||||
raise ValueError("warnings is only supported in Pydantic v2")
|
||||
if context is not None:
|
||||
raise ValueError("context is only supported in Pydantic v2")
|
||||
if serialize_as_any != False:
|
||||
raise ValueError("serialize_as_any is only supported in Pydantic v2")
|
||||
if fallback is not None:
|
||||
raise ValueError("fallback is only supported in Pydantic v2")
|
||||
if ensure_ascii != False:
|
||||
raise ValueError("ensure_ascii is only supported in Pydantic v2")
|
||||
if exclude_computed_fields != False:
|
||||
raise ValueError("exclude_computed_fields is only supported in Pydantic v2")
|
||||
return super().json( # type: ignore[reportDeprecated]
|
||||
indent=indent,
|
||||
include=include,
|
||||
exclude=exclude,
|
||||
by_alias=by_alias if by_alias is not None else False,
|
||||
exclude_unset=exclude_unset,
|
||||
exclude_defaults=exclude_defaults,
|
||||
exclude_none=exclude_none,
|
||||
)
|
||||
|
||||
|
||||
def _construct_field(value: object, field: FieldInfo, key: str) -> object:
|
||||
if value is None:
|
||||
return field_get_default(field)
|
||||
|
||||
if PYDANTIC_V1:
|
||||
type_ = cast(type, field.outer_type_) # type: ignore
|
||||
else:
|
||||
type_ = field.annotation # type: ignore
|
||||
|
||||
if type_ is None:
|
||||
raise RuntimeError(f"Unexpected field type is None for {key}")
|
||||
|
||||
return construct_type(value=value, type_=type_, metadata=getattr(field, "metadata", None))
|
||||
|
||||
|
||||
def _get_extra_fields_type(cls: type[pydantic.BaseModel]) -> type | None:
|
||||
if PYDANTIC_V1:
|
||||
# TODO
|
||||
return None
|
||||
|
||||
schema = cls.__pydantic_core_schema__
|
||||
if schema["type"] == "model":
|
||||
fields = schema["schema"]
|
||||
if fields["type"] == "model-fields":
|
||||
extras = fields.get("extras_schema")
|
||||
if extras and "cls" in extras:
|
||||
# mypy can't narrow the type
|
||||
return extras["cls"] # type: ignore[no-any-return]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_basemodel(type_: type) -> bool:
|
||||
"""Returns whether or not the given type is either a `BaseModel` or a union of `BaseModel`"""
|
||||
if is_union(type_):
|
||||
for variant in get_args(type_):
|
||||
if is_basemodel(variant):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
return is_basemodel_type(type_)
|
||||
|
||||
|
||||
def is_basemodel_type(type_: type) -> TypeGuard[type[BaseModel] | type[GenericModel]]:
|
||||
origin = get_origin(type_) or type_
|
||||
if not inspect.isclass(origin):
|
||||
return False
|
||||
return issubclass(origin, BaseModel) or issubclass(origin, GenericModel)
|
||||
|
||||
|
||||
def build(
|
||||
base_model_cls: Callable[P, _BaseModelT],
|
||||
*args: P.args,
|
||||
**kwargs: P.kwargs,
|
||||
) -> _BaseModelT:
|
||||
"""Construct a BaseModel class without validation.
|
||||
|
||||
This is useful for cases where you need to instantiate a `BaseModel`
|
||||
from an API response as this provides type-safe params which isn't supported
|
||||
by helpers like `construct_type()`.
|
||||
|
||||
```py
|
||||
build(MyModel, my_field_a="foo", my_field_b=123)
|
||||
```
|
||||
"""
|
||||
if args:
|
||||
raise TypeError(
|
||||
"Received positional arguments which are not supported; Keyword arguments must be used instead",
|
||||
)
|
||||
|
||||
return cast(_BaseModelT, construct_type(type_=base_model_cls, value=kwargs))
|
||||
|
||||
|
||||
def construct_type_unchecked(*, value: object, type_: type[_T]) -> _T:
|
||||
"""Loose coercion to the expected type with construction of nested values.
|
||||
|
||||
Note: the returned value from this function is not guaranteed to match the
|
||||
given type.
|
||||
"""
|
||||
return cast(_T, construct_type(value=value, type_=type_))
|
||||
|
||||
|
||||
def construct_type(*, value: object, type_: object, metadata: Optional[List[Any]] = None) -> object:
|
||||
"""Loose coercion to the expected type with construction of nested values.
|
||||
|
||||
If the given value does not match the expected type then it is returned as-is.
|
||||
"""
|
||||
|
||||
# store a reference to the original type we were given before we extract any inner
|
||||
# types so that we can properly resolve forward references in `TypeAliasType` annotations
|
||||
original_type = None
|
||||
|
||||
# we allow `object` as the input type because otherwise, passing things like
|
||||
# `Literal['value']` will be reported as a type error by type checkers
|
||||
type_ = cast("type[object]", type_)
|
||||
if is_type_alias_type(type_):
|
||||
original_type = type_ # type: ignore[unreachable]
|
||||
type_ = type_.__value__ # type: ignore[unreachable]
|
||||
|
||||
# unwrap `Annotated[T, ...]` -> `T`
|
||||
if metadata is not None and len(metadata) > 0:
|
||||
meta: tuple[Any, ...] = tuple(metadata)
|
||||
elif is_annotated_type(type_):
|
||||
meta = get_args(type_)[1:]
|
||||
type_ = extract_type_arg(type_, 0)
|
||||
else:
|
||||
meta = tuple()
|
||||
|
||||
# we need to use the origin class for any types that are subscripted generics
|
||||
# e.g. Dict[str, object]
|
||||
origin = get_origin(type_) or type_
|
||||
args = get_args(type_)
|
||||
|
||||
if is_union(origin):
|
||||
try:
|
||||
return validate_type(type_=cast("type[object]", original_type or type_), value=value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# if the type is a discriminated union then we want to construct the right variant
|
||||
# in the union, even if the data doesn't match exactly, otherwise we'd break code
|
||||
# that relies on the constructed class types, e.g.
|
||||
#
|
||||
# class FooType:
|
||||
# kind: Literal['foo']
|
||||
# value: str
|
||||
#
|
||||
# class BarType:
|
||||
# kind: Literal['bar']
|
||||
# value: int
|
||||
#
|
||||
# without this block, if the data we get is something like `{'kind': 'bar', 'value': 'foo'}` then
|
||||
# we'd end up constructing `FooType` when it should be `BarType`.
|
||||
discriminator = _build_discriminated_union_meta(union=type_, meta_annotations=meta)
|
||||
if discriminator and is_mapping(value):
|
||||
variant_value = value.get(discriminator.field_alias_from or discriminator.field_name)
|
||||
if variant_value and isinstance(variant_value, str):
|
||||
variant_type = discriminator.mapping.get(variant_value)
|
||||
if variant_type:
|
||||
return construct_type(type_=variant_type, value=value)
|
||||
|
||||
# if the data is not valid, use the first variant that doesn't fail while deserializing
|
||||
for variant in args:
|
||||
try:
|
||||
return construct_type(value=value, type_=variant)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"Could not convert data into a valid instance of {type_}")
|
||||
|
||||
if origin == dict:
|
||||
if not is_mapping(value):
|
||||
return value
|
||||
|
||||
_, items_type = get_args(type_) # Dict[_, items_type]
|
||||
return {key: construct_type(value=item, type_=items_type) for key, item in value.items()}
|
||||
|
||||
if (
|
||||
not is_literal_type(type_)
|
||||
and inspect.isclass(origin)
|
||||
and (issubclass(origin, BaseModel) or issubclass(origin, GenericModel))
|
||||
):
|
||||
if is_list(value):
|
||||
return [cast(Any, type_).construct(**entry) if is_mapping(entry) else entry for entry in value]
|
||||
|
||||
if is_mapping(value):
|
||||
if issubclass(type_, BaseModel):
|
||||
return type_.construct(**value) # type: ignore[arg-type]
|
||||
|
||||
return cast(Any, type_).construct(**value)
|
||||
|
||||
if origin == list:
|
||||
if not is_list(value):
|
||||
return value
|
||||
|
||||
inner_type = args[0] # List[inner_type]
|
||||
return [construct_type(value=entry, type_=inner_type) for entry in value]
|
||||
|
||||
if origin == float:
|
||||
if isinstance(value, int):
|
||||
coerced = float(value)
|
||||
if coerced != value:
|
||||
return value
|
||||
return coerced
|
||||
|
||||
return value
|
||||
|
||||
if type_ == datetime:
|
||||
try:
|
||||
return parse_datetime(value) # type: ignore
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
if type_ == date:
|
||||
try:
|
||||
return parse_date(value) # type: ignore
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CachedDiscriminatorType(Protocol):
|
||||
__discriminator__: DiscriminatorDetails
|
||||
|
||||
|
||||
DISCRIMINATOR_CACHE: weakref.WeakKeyDictionary[type, DiscriminatorDetails] = weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
class DiscriminatorDetails:
|
||||
field_name: str
|
||||
"""The name of the discriminator field in the variant class, e.g.
|
||||
|
||||
```py
|
||||
class Foo(BaseModel):
|
||||
type: Literal['foo']
|
||||
```
|
||||
|
||||
Will result in field_name='type'
|
||||
"""
|
||||
|
||||
field_alias_from: str | None
|
||||
"""The name of the discriminator field in the API response, e.g.
|
||||
|
||||
```py
|
||||
class Foo(BaseModel):
|
||||
type: Literal['foo'] = Field(alias='type_from_api')
|
||||
```
|
||||
|
||||
Will result in field_alias_from='type_from_api'
|
||||
"""
|
||||
|
||||
mapping: dict[str, type]
|
||||
"""Mapping of discriminator value to variant type, e.g.
|
||||
|
||||
{'foo': FooVariant, 'bar': BarVariant}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
mapping: dict[str, type],
|
||||
discriminator_field: str,
|
||||
discriminator_alias: str | None,
|
||||
) -> None:
|
||||
self.mapping = mapping
|
||||
self.field_name = discriminator_field
|
||||
self.field_alias_from = discriminator_alias
|
||||
|
||||
|
||||
def _build_discriminated_union_meta(*, union: type, meta_annotations: tuple[Any, ...]) -> DiscriminatorDetails | None:
|
||||
cached = DISCRIMINATOR_CACHE.get(union)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
discriminator_field_name: str | None = None
|
||||
|
||||
for annotation in meta_annotations:
|
||||
if isinstance(annotation, PropertyInfo) and annotation.discriminator is not None:
|
||||
discriminator_field_name = annotation.discriminator
|
||||
break
|
||||
|
||||
if not discriminator_field_name:
|
||||
return None
|
||||
|
||||
mapping: dict[str, type] = {}
|
||||
discriminator_alias: str | None = None
|
||||
|
||||
for variant in get_args(union):
|
||||
variant = strip_annotated_type(variant)
|
||||
if is_basemodel_type(variant):
|
||||
if PYDANTIC_V1:
|
||||
field_info = cast("dict[str, FieldInfo]", variant.__fields__).get(discriminator_field_name) # pyright: ignore[reportDeprecated, reportUnnecessaryCast]
|
||||
if not field_info:
|
||||
continue
|
||||
|
||||
# Note: if one variant defines an alias then they all should
|
||||
discriminator_alias = field_info.alias
|
||||
|
||||
if (annotation := getattr(field_info, "annotation", None)) and is_literal_type(annotation):
|
||||
for entry in get_args(annotation):
|
||||
if isinstance(entry, str):
|
||||
mapping[entry] = variant
|
||||
else:
|
||||
field = _extract_field_schema_pv2(variant, discriminator_field_name)
|
||||
if not field:
|
||||
continue
|
||||
|
||||
# Note: if one variant defines an alias then they all should
|
||||
discriminator_alias = field.get("serialization_alias")
|
||||
|
||||
field_schema = field["schema"]
|
||||
|
||||
if field_schema["type"] == "literal":
|
||||
for entry in cast("LiteralSchema", field_schema)["expected"]:
|
||||
if isinstance(entry, str):
|
||||
mapping[entry] = variant
|
||||
|
||||
if not mapping:
|
||||
return None
|
||||
|
||||
details = DiscriminatorDetails(
|
||||
mapping=mapping,
|
||||
discriminator_field=discriminator_field_name,
|
||||
discriminator_alias=discriminator_alias,
|
||||
)
|
||||
DISCRIMINATOR_CACHE.setdefault(union, details)
|
||||
return details
|
||||
|
||||
|
||||
def _extract_field_schema_pv2(model: type[BaseModel], field_name: str) -> ModelField | None:
|
||||
schema = model.__pydantic_core_schema__
|
||||
if schema["type"] == "definitions":
|
||||
schema = schema["schema"]
|
||||
|
||||
if schema["type"] != "model":
|
||||
return None
|
||||
|
||||
schema = cast("ModelSchema", schema)
|
||||
fields_schema = schema["schema"]
|
||||
if fields_schema["type"] != "model-fields":
|
||||
return None
|
||||
|
||||
fields_schema = cast("ModelFieldsSchema", fields_schema)
|
||||
field = fields_schema["fields"].get(field_name)
|
||||
if not field:
|
||||
return None
|
||||
|
||||
return cast("ModelField", field) # pyright: ignore[reportUnnecessaryCast]
|
||||
|
||||
|
||||
def validate_type(*, type_: type[_T], value: object) -> _T:
|
||||
"""Strict validation that the given value matches the expected type"""
|
||||
if inspect.isclass(type_) and issubclass(type_, pydantic.BaseModel):
|
||||
return cast(_T, parse_obj(type_, value))
|
||||
|
||||
return cast(_T, _validate_non_model_type(type_=type_, value=value))
|
||||
|
||||
|
||||
def set_pydantic_config(typ: Any, config: pydantic.ConfigDict) -> None:
|
||||
"""Add a pydantic config for the given type.
|
||||
|
||||
Note: this is a no-op on Pydantic v1.
|
||||
"""
|
||||
setattr(typ, "__pydantic_config__", config) # noqa: B010
|
||||
|
||||
|
||||
# our use of subclassing here causes weirdness for type checkers,
|
||||
# so we just pretend that we don't subclass
|
||||
if TYPE_CHECKING:
|
||||
GenericModel = BaseModel
|
||||
else:
|
||||
|
||||
class GenericModel(BaseGenericModel, BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
if not PYDANTIC_V1:
|
||||
from pydantic import TypeAdapter as _TypeAdapter
|
||||
|
||||
_CachedTypeAdapter = cast("TypeAdapter[object]", lru_cache(maxsize=None)(_TypeAdapter))
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic import TypeAdapter
|
||||
else:
|
||||
TypeAdapter = _CachedTypeAdapter
|
||||
|
||||
def _validate_non_model_type(*, type_: type[_T], value: object) -> _T:
|
||||
return TypeAdapter(type_).validate_python(value)
|
||||
|
||||
elif not TYPE_CHECKING: # TODO: condition is weird
|
||||
|
||||
class RootModel(GenericModel, Generic[_T]):
|
||||
"""Used as a placeholder to easily convert runtime types to a Pydantic format
|
||||
to provide validation.
|
||||
|
||||
For example:
|
||||
```py
|
||||
validated = RootModel[int](__root__="5").__root__
|
||||
# validated: 5
|
||||
```
|
||||
"""
|
||||
|
||||
__root__: _T
|
||||
|
||||
def _validate_non_model_type(*, type_: type[_T], value: object) -> _T:
|
||||
model = _create_pydantic_model(type_).validate(value)
|
||||
return cast(_T, model.__root__)
|
||||
|
||||
def _create_pydantic_model(type_: _T) -> Type[RootModel[_T]]:
|
||||
return RootModel[type_] # type: ignore
|
||||
|
||||
|
||||
class FinalRequestOptionsInput(TypedDict, total=False):
|
||||
method: Required[str]
|
||||
url: Required[str]
|
||||
params: Query
|
||||
headers: Headers
|
||||
max_retries: int
|
||||
timeout: float | Timeout | None
|
||||
files: HttpxRequestFiles | None
|
||||
idempotency_key: str
|
||||
content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None]
|
||||
json_data: Body
|
||||
extra_json: AnyMapping
|
||||
follow_redirects: bool
|
||||
|
||||
|
||||
@final
|
||||
class FinalRequestOptions(pydantic.BaseModel):
|
||||
method: str
|
||||
url: str
|
||||
params: Query = {}
|
||||
headers: Union[Headers, NotGiven] = NotGiven()
|
||||
max_retries: Union[int, NotGiven] = NotGiven()
|
||||
timeout: Union[float, Timeout, None, NotGiven] = NotGiven()
|
||||
files: Union[HttpxRequestFiles, None] = None
|
||||
idempotency_key: Union[str, None] = None
|
||||
post_parser: Union[Callable[[Any], Any], NotGiven] = NotGiven()
|
||||
follow_redirects: Union[bool, None] = None
|
||||
|
||||
content: Union[bytes, bytearray, IO[bytes], Iterable[bytes], AsyncIterable[bytes], None] = None
|
||||
# It should be noted that we cannot use `json` here as that would override
|
||||
# a BaseModel method in an incompatible fashion.
|
||||
json_data: Union[Body, None] = None
|
||||
extra_json: Union[AnyMapping, None] = None
|
||||
|
||||
if PYDANTIC_V1:
|
||||
|
||||
class Config(pydantic.BaseConfig): # pyright: ignore[reportDeprecated]
|
||||
arbitrary_types_allowed: bool = True
|
||||
else:
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(arbitrary_types_allowed=True)
|
||||
|
||||
def get_max_retries(self, max_retries: int) -> int:
|
||||
if isinstance(self.max_retries, NotGiven):
|
||||
return max_retries
|
||||
return self.max_retries
|
||||
|
||||
def _strip_raw_response_header(self) -> None:
|
||||
if not is_given(self.headers):
|
||||
return
|
||||
|
||||
if self.headers.get(RAW_RESPONSE_HEADER):
|
||||
self.headers = {**self.headers}
|
||||
self.headers.pop(RAW_RESPONSE_HEADER)
|
||||
|
||||
# override the `construct` method so that we can run custom transformations.
|
||||
# this is necessary as we don't want to do any actual runtime type checking
|
||||
# (which means we can't use validators) but we do want to ensure that `NotGiven`
|
||||
# values are not present
|
||||
#
|
||||
# type ignore required because we're adding explicit types to `**values`
|
||||
@classmethod
|
||||
def construct( # type: ignore
|
||||
cls,
|
||||
_fields_set: set[str] | None = None,
|
||||
**values: Unpack[FinalRequestOptionsInput],
|
||||
) -> FinalRequestOptions:
|
||||
kwargs: dict[str, Any] = {
|
||||
# we unconditionally call `strip_not_given` on any value
|
||||
# as it will just ignore any non-mapping types
|
||||
key: strip_not_given(value)
|
||||
for key, value in values.items()
|
||||
}
|
||||
if PYDANTIC_V1:
|
||||
return cast(FinalRequestOptions, super().construct(_fields_set, **kwargs)) # pyright: ignore[reportDeprecated]
|
||||
return super().model_construct(_fields_set, **kwargs)
|
||||
|
||||
if not TYPE_CHECKING:
|
||||
# type checkers incorrectly complain about this assignment
|
||||
model_construct = construct
|
||||
@@ -0,0 +1,168 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, List, Tuple, Union, Mapping, TypeVar
|
||||
from urllib.parse import parse_qs, urlencode
|
||||
from typing_extensions import Literal, get_args
|
||||
|
||||
from ._types import NotGiven, not_given
|
||||
from ._utils import flatten
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
ArrayFormat = Literal["comma", "repeat", "indices", "brackets"]
|
||||
NestedFormat = Literal["dots", "brackets"]
|
||||
|
||||
PrimitiveData = Union[str, int, float, bool, None]
|
||||
# this should be Data = Union[PrimitiveData, "List[Data]", "Tuple[Data]", "Mapping[str, Data]"]
|
||||
# https://github.com/microsoft/pyright/issues/3555
|
||||
Data = Union[PrimitiveData, List[Any], Tuple[Any], "Mapping[str, Any]"]
|
||||
Params = Mapping[str, Data]
|
||||
|
||||
|
||||
class Querystring:
|
||||
array_format: ArrayFormat
|
||||
nested_format: NestedFormat
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
array_format: ArrayFormat = "repeat",
|
||||
nested_format: NestedFormat = "brackets",
|
||||
) -> None:
|
||||
self.array_format = array_format
|
||||
self.nested_format = nested_format
|
||||
|
||||
def parse(self, query: str) -> Mapping[str, object]:
|
||||
# Note: custom format syntax is not supported yet
|
||||
return parse_qs(query)
|
||||
|
||||
def stringify(
|
||||
self,
|
||||
params: Params,
|
||||
*,
|
||||
array_format: ArrayFormat | NotGiven = not_given,
|
||||
nested_format: NestedFormat | NotGiven = not_given,
|
||||
) -> str:
|
||||
return urlencode(
|
||||
self.stringify_items(
|
||||
params,
|
||||
array_format=array_format,
|
||||
nested_format=nested_format,
|
||||
)
|
||||
)
|
||||
|
||||
def stringify_items(
|
||||
self,
|
||||
params: Params,
|
||||
*,
|
||||
array_format: ArrayFormat | NotGiven = not_given,
|
||||
nested_format: NestedFormat | NotGiven = not_given,
|
||||
) -> list[tuple[str, str]]:
|
||||
opts = Options(
|
||||
qs=self,
|
||||
array_format=array_format,
|
||||
nested_format=nested_format,
|
||||
)
|
||||
return flatten([self._stringify_item(key, value, opts) for key, value in params.items()])
|
||||
|
||||
def _stringify_item(
|
||||
self,
|
||||
key: str,
|
||||
value: Data,
|
||||
opts: Options,
|
||||
) -> list[tuple[str, str]]:
|
||||
if isinstance(value, Mapping):
|
||||
items: list[tuple[str, str]] = []
|
||||
nested_format = opts.nested_format
|
||||
for subkey, subvalue in value.items():
|
||||
items.extend(
|
||||
self._stringify_item(
|
||||
# TODO: error if unknown format
|
||||
f"{key}.{subkey}" if nested_format == "dots" else f"{key}[{subkey}]",
|
||||
subvalue,
|
||||
opts,
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
if isinstance(value, (list, tuple)):
|
||||
array_format = opts.array_format
|
||||
if array_format == "comma":
|
||||
return [
|
||||
(
|
||||
key,
|
||||
",".join(self._primitive_value_to_str(item) for item in value if item is not None),
|
||||
),
|
||||
]
|
||||
elif array_format == "repeat":
|
||||
items = []
|
||||
for item in value:
|
||||
items.extend(self._stringify_item(key, item, opts))
|
||||
return items
|
||||
elif array_format == "indices":
|
||||
items = []
|
||||
for i, item in enumerate(value):
|
||||
items.extend(self._stringify_item(f"{key}[{i}]", item, opts))
|
||||
return items
|
||||
elif array_format == "brackets":
|
||||
items = []
|
||||
key = key + "[]"
|
||||
for item in value:
|
||||
items.extend(self._stringify_item(key, item, opts))
|
||||
return items
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"Unknown array_format value: {array_format}, choose from {', '.join(get_args(ArrayFormat))}"
|
||||
)
|
||||
|
||||
serialised = self._primitive_value_to_str(value)
|
||||
if not serialised:
|
||||
return []
|
||||
return [(key, serialised)]
|
||||
|
||||
def _primitive_value_to_str(self, value: PrimitiveData) -> str:
|
||||
# copied from httpx
|
||||
if value is True:
|
||||
return "true"
|
||||
elif value is False:
|
||||
return "false"
|
||||
elif value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
_qs = Querystring()
|
||||
parse = _qs.parse
|
||||
stringify = _qs.stringify
|
||||
stringify_items = _qs.stringify_items
|
||||
|
||||
|
||||
class Options:
|
||||
array_format: ArrayFormat
|
||||
nested_format: NestedFormat
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
qs: Querystring = _qs,
|
||||
*,
|
||||
array_format: ArrayFormat | NotGiven = not_given,
|
||||
nested_format: NestedFormat | NotGiven = not_given,
|
||||
) -> None:
|
||||
self.array_format = qs.array_format if isinstance(array_format, NotGiven) else array_format
|
||||
self.nested_format = qs.nested_format if isinstance(nested_format, NotGiven) else nested_format
|
||||
@@ -0,0 +1,58 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import anyio
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._client import GeminiNextGenAPIClient, AsyncGeminiNextGenAPIClient
|
||||
|
||||
|
||||
class SyncAPIResource:
|
||||
_client: GeminiNextGenAPIClient
|
||||
|
||||
def __init__(self, client: GeminiNextGenAPIClient) -> None:
|
||||
self._client = client
|
||||
self._get = client.get
|
||||
self._post = client.post
|
||||
self._patch = client.patch
|
||||
self._put = client.put
|
||||
self._delete = client.delete
|
||||
self._get_api_list = client.get_api_list
|
||||
|
||||
def _sleep(self, seconds: float) -> None:
|
||||
time.sleep(seconds)
|
||||
|
||||
|
||||
class AsyncAPIResource:
|
||||
_client: AsyncGeminiNextGenAPIClient
|
||||
|
||||
def __init__(self, client: AsyncGeminiNextGenAPIClient) -> None:
|
||||
self._client = client
|
||||
self._get = client.get
|
||||
self._post = client.post
|
||||
self._patch = client.patch
|
||||
self._put = client.put
|
||||
self._delete = client.delete
|
||||
self._get_api_list = client.get_api_list
|
||||
|
||||
async def _sleep(self, seconds: float) -> None:
|
||||
await anyio.sleep(seconds)
|
||||
@@ -0,0 +1,850 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import inspect
|
||||
import logging
|
||||
import datetime
|
||||
import functools
|
||||
from types import TracebackType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Union,
|
||||
Generic,
|
||||
TypeVar,
|
||||
Callable,
|
||||
Iterator,
|
||||
AsyncIterator,
|
||||
cast,
|
||||
overload,
|
||||
)
|
||||
from typing_extensions import Awaitable, ParamSpec, override, get_origin
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pydantic
|
||||
|
||||
from ._types import NoneType
|
||||
from ._utils import is_given, extract_type_arg, is_annotated_type, is_type_alias_type, extract_type_var_from_base
|
||||
from ._models import BaseModel, is_basemodel
|
||||
from ._constants import RAW_RESPONSE_HEADER, OVERRIDE_CAST_TO_HEADER
|
||||
from ._streaming import Stream, AsyncStream, is_stream_class_type, extract_stream_chunk_type
|
||||
from ._exceptions import APIResponseValidationError, GeminiNextGenAPIClientError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._models import FinalRequestOptions
|
||||
from ._base_client import BaseClient
|
||||
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
_T = TypeVar("_T")
|
||||
_APIResponseT = TypeVar("_APIResponseT", bound="APIResponse[Any]")
|
||||
_AsyncAPIResponseT = TypeVar("_AsyncAPIResponseT", bound="AsyncAPIResponse[Any]")
|
||||
|
||||
log: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseAPIResponse(Generic[R]):
|
||||
_cast_to: type[R]
|
||||
_client: BaseClient[Any, Any]
|
||||
_parsed_by_type: dict[type[Any], Any]
|
||||
_is_sse_stream: bool
|
||||
_stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None
|
||||
_options: FinalRequestOptions
|
||||
|
||||
http_response: httpx.Response
|
||||
|
||||
retries_taken: int
|
||||
"""The number of retries made. If no retries happened this will be `0`"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
raw: httpx.Response,
|
||||
cast_to: type[R],
|
||||
client: BaseClient[Any, Any],
|
||||
stream: bool,
|
||||
stream_cls: type[Stream[Any]] | type[AsyncStream[Any]] | None,
|
||||
options: FinalRequestOptions,
|
||||
retries_taken: int = 0,
|
||||
) -> None:
|
||||
self._cast_to = cast_to
|
||||
self._client = client
|
||||
self._parsed_by_type = {}
|
||||
self._is_sse_stream = stream
|
||||
self._stream_cls = stream_cls
|
||||
self._options = options
|
||||
self.http_response = raw
|
||||
self.retries_taken = retries_taken
|
||||
|
||||
@property
|
||||
def headers(self) -> httpx.Headers:
|
||||
return self.http_response.headers
|
||||
|
||||
@property
|
||||
def http_request(self) -> httpx.Request:
|
||||
"""Returns the httpx Request instance associated with the current response."""
|
||||
return self.http_response.request
|
||||
|
||||
@property
|
||||
def status_code(self) -> int:
|
||||
return self.http_response.status_code
|
||||
|
||||
@property
|
||||
def url(self) -> httpx.URL:
|
||||
"""Returns the URL for which the request was made."""
|
||||
return self.http_response.url
|
||||
|
||||
@property
|
||||
def method(self) -> str:
|
||||
return self.http_request.method
|
||||
|
||||
@property
|
||||
def http_version(self) -> str:
|
||||
return self.http_response.http_version
|
||||
|
||||
@property
|
||||
def elapsed(self) -> datetime.timedelta:
|
||||
"""The time taken for the complete request/response cycle to complete."""
|
||||
return self.http_response.elapsed
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
"""Whether or not the response body has been closed.
|
||||
|
||||
If this is False then there is response data that has not been read yet.
|
||||
You must either fully consume the response body or call `.close()`
|
||||
before discarding the response to prevent resource leaks.
|
||||
"""
|
||||
return self.http_response.is_closed
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<{self.__class__.__name__} [{self.status_code} {self.http_response.reason_phrase}] type={self._cast_to}>"
|
||||
)
|
||||
|
||||
def _parse(self, *, to: type[_T] | None = None) -> R | _T:
|
||||
cast_to = to if to is not None else self._cast_to
|
||||
|
||||
# unwrap `TypeAlias('Name', T)` -> `T`
|
||||
if is_type_alias_type(cast_to):
|
||||
cast_to = cast_to.__value__ # type: ignore[unreachable]
|
||||
|
||||
# unwrap `Annotated[T, ...]` -> `T`
|
||||
if cast_to and is_annotated_type(cast_to):
|
||||
cast_to = extract_type_arg(cast_to, 0)
|
||||
|
||||
origin = get_origin(cast_to) or cast_to
|
||||
|
||||
if self._is_sse_stream:
|
||||
if to:
|
||||
if not is_stream_class_type(to):
|
||||
raise TypeError(f"Expected custom parse type to be a subclass of {Stream} or {AsyncStream}")
|
||||
|
||||
return cast(
|
||||
_T,
|
||||
to(
|
||||
cast_to=extract_stream_chunk_type(
|
||||
to,
|
||||
failure_message="Expected custom stream type to be passed with a type argument, e.g. Stream[ChunkType]",
|
||||
),
|
||||
response=self.http_response,
|
||||
client=cast(Any, self._client),
|
||||
options=self._options,
|
||||
),
|
||||
)
|
||||
|
||||
if self._stream_cls:
|
||||
return cast(
|
||||
R,
|
||||
self._stream_cls(
|
||||
cast_to=extract_stream_chunk_type(self._stream_cls),
|
||||
response=self.http_response,
|
||||
client=cast(Any, self._client),
|
||||
options=self._options,
|
||||
),
|
||||
)
|
||||
|
||||
stream_cls = cast("type[Stream[Any]] | type[AsyncStream[Any]] | None", self._client._default_stream_cls)
|
||||
if stream_cls is None:
|
||||
raise MissingStreamClassError()
|
||||
|
||||
return cast(
|
||||
R,
|
||||
stream_cls(
|
||||
cast_to=cast_to,
|
||||
response=self.http_response,
|
||||
client=cast(Any, self._client),
|
||||
options=self._options,
|
||||
),
|
||||
)
|
||||
|
||||
if cast_to is NoneType:
|
||||
return cast(R, None)
|
||||
|
||||
response = self.http_response
|
||||
if cast_to == str:
|
||||
return cast(R, response.text)
|
||||
|
||||
if cast_to == bytes:
|
||||
return cast(R, response.content)
|
||||
|
||||
if cast_to == int:
|
||||
return cast(R, int(response.text))
|
||||
|
||||
if cast_to == float:
|
||||
return cast(R, float(response.text))
|
||||
|
||||
if cast_to == bool:
|
||||
return cast(R, response.text.lower() == "true")
|
||||
|
||||
if origin == APIResponse:
|
||||
raise RuntimeError("Unexpected state - cast_to is `APIResponse`")
|
||||
|
||||
if inspect.isclass(origin) and issubclass(origin, httpx.Response):
|
||||
# Because of the invariance of our ResponseT TypeVar, users can subclass httpx.Response
|
||||
# and pass that class to our request functions. We cannot change the variance to be either
|
||||
# covariant or contravariant as that makes our usage of ResponseT illegal. We could construct
|
||||
# the response class ourselves but that is something that should be supported directly in httpx
|
||||
# as it would be easy to incorrectly construct the Response object due to the multitude of arguments.
|
||||
if cast_to != httpx.Response:
|
||||
raise ValueError(f"Subclasses of httpx.Response cannot be passed to `cast_to`")
|
||||
return cast(R, response)
|
||||
|
||||
if (
|
||||
inspect.isclass(
|
||||
origin # pyright: ignore[reportUnknownArgumentType]
|
||||
)
|
||||
and not issubclass(origin, BaseModel)
|
||||
and issubclass(origin, pydantic.BaseModel)
|
||||
):
|
||||
raise TypeError(
|
||||
"Pydantic models must subclass our base model type, e.g. `from google.genai._interactions import BaseModel`"
|
||||
)
|
||||
|
||||
if (
|
||||
cast_to is not object
|
||||
and not origin is list
|
||||
and not origin is dict
|
||||
and not origin is Union
|
||||
and not issubclass(origin, BaseModel)
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Unsupported type, expected {cast_to} to be a subclass of {BaseModel}, {dict}, {list}, {Union}, {NoneType}, {str} or {httpx.Response}."
|
||||
)
|
||||
|
||||
# split is required to handle cases where additional information is included
|
||||
# in the response, e.g. application/json; charset=utf-8
|
||||
content_type, *_ = response.headers.get("content-type", "*").split(";")
|
||||
if not content_type.endswith("json"):
|
||||
if is_basemodel(cast_to):
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
log.debug("Could not read JSON from response data due to %s - %s", type(exc), exc)
|
||||
else:
|
||||
return self._client._process_response_data(
|
||||
data=data,
|
||||
cast_to=cast_to, # type: ignore
|
||||
response=response,
|
||||
)
|
||||
|
||||
if self._client._strict_response_validation:
|
||||
raise APIResponseValidationError(
|
||||
response=response,
|
||||
message=f"Expected Content-Type response header to be `application/json` but received `{content_type}` instead.",
|
||||
body=response.text,
|
||||
)
|
||||
|
||||
# If the API responds with content that isn't JSON then we just return
|
||||
# the (decoded) text without performing any parsing so that you can still
|
||||
# handle the response however you need to.
|
||||
return response.text # type: ignore
|
||||
|
||||
data = response.json()
|
||||
|
||||
return self._client._process_response_data(
|
||||
data=data,
|
||||
cast_to=cast_to, # type: ignore
|
||||
response=response,
|
||||
)
|
||||
|
||||
|
||||
class APIResponse(BaseAPIResponse[R]):
|
||||
@overload
|
||||
def parse(self, *, to: type[_T]) -> _T: ...
|
||||
|
||||
@overload
|
||||
def parse(self) -> R: ...
|
||||
|
||||
def parse(self, *, to: type[_T] | None = None) -> R | _T:
|
||||
"""Returns the rich python representation of this response's data.
|
||||
|
||||
For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`.
|
||||
|
||||
You can customise the type that the response is parsed into through
|
||||
the `to` argument, e.g.
|
||||
|
||||
```py
|
||||
from google.genai._interactions import BaseModel
|
||||
|
||||
|
||||
class MyModel(BaseModel):
|
||||
foo: str
|
||||
|
||||
|
||||
obj = response.parse(to=MyModel)
|
||||
print(obj.foo)
|
||||
```
|
||||
|
||||
We support parsing:
|
||||
- `BaseModel`
|
||||
- `dict`
|
||||
- `list`
|
||||
- `Union`
|
||||
- `str`
|
||||
- `int`
|
||||
- `float`
|
||||
- `httpx.Response`
|
||||
"""
|
||||
cache_key = to if to is not None else self._cast_to
|
||||
cached = self._parsed_by_type.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached # type: ignore[no-any-return]
|
||||
|
||||
if not self._is_sse_stream:
|
||||
self.read()
|
||||
|
||||
parsed = self._parse(to=to)
|
||||
if is_given(self._options.post_parser):
|
||||
parsed = self._options.post_parser(parsed)
|
||||
|
||||
self._parsed_by_type[cache_key] = parsed
|
||||
return parsed
|
||||
|
||||
def read(self) -> bytes:
|
||||
"""Read and return the binary response content."""
|
||||
try:
|
||||
return self.http_response.read()
|
||||
except httpx.StreamConsumed as exc:
|
||||
# The default error raised by httpx isn't very
|
||||
# helpful in our case so we re-raise it with
|
||||
# a different error message.
|
||||
raise StreamAlreadyConsumed() from exc
|
||||
|
||||
def text(self) -> str:
|
||||
"""Read and decode the response content into a string."""
|
||||
self.read()
|
||||
return self.http_response.text
|
||||
|
||||
def json(self) -> object:
|
||||
"""Read and decode the JSON response content."""
|
||||
self.read()
|
||||
return self.http_response.json()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the response and release the connection.
|
||||
|
||||
Automatically called if the response body is read to completion.
|
||||
"""
|
||||
self.http_response.close()
|
||||
|
||||
def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]:
|
||||
"""
|
||||
A byte-iterator over the decoded response content.
|
||||
|
||||
This automatically handles gzip, deflate and brotli encoded responses.
|
||||
"""
|
||||
for chunk in self.http_response.iter_bytes(chunk_size):
|
||||
yield chunk
|
||||
|
||||
def iter_text(self, chunk_size: int | None = None) -> Iterator[str]:
|
||||
"""A str-iterator over the decoded response content
|
||||
that handles both gzip, deflate, etc but also detects the content's
|
||||
string encoding.
|
||||
"""
|
||||
for chunk in self.http_response.iter_text(chunk_size):
|
||||
yield chunk
|
||||
|
||||
def iter_lines(self) -> Iterator[str]:
|
||||
"""Like `iter_text()` but will only yield chunks for each line"""
|
||||
for chunk in self.http_response.iter_lines():
|
||||
yield chunk
|
||||
|
||||
|
||||
class AsyncAPIResponse(BaseAPIResponse[R]):
|
||||
@overload
|
||||
async def parse(self, *, to: type[_T]) -> _T: ...
|
||||
|
||||
@overload
|
||||
async def parse(self) -> R: ...
|
||||
|
||||
async def parse(self, *, to: type[_T] | None = None) -> R | _T:
|
||||
"""Returns the rich python representation of this response's data.
|
||||
|
||||
For lower-level control, see `.read()`, `.json()`, `.iter_bytes()`.
|
||||
|
||||
You can customise the type that the response is parsed into through
|
||||
the `to` argument, e.g.
|
||||
|
||||
```py
|
||||
from google.genai._interactions import BaseModel
|
||||
|
||||
|
||||
class MyModel(BaseModel):
|
||||
foo: str
|
||||
|
||||
|
||||
obj = response.parse(to=MyModel)
|
||||
print(obj.foo)
|
||||
```
|
||||
|
||||
We support parsing:
|
||||
- `BaseModel`
|
||||
- `dict`
|
||||
- `list`
|
||||
- `Union`
|
||||
- `str`
|
||||
- `httpx.Response`
|
||||
"""
|
||||
cache_key = to if to is not None else self._cast_to
|
||||
cached = self._parsed_by_type.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached # type: ignore[no-any-return]
|
||||
|
||||
if not self._is_sse_stream:
|
||||
await self.read()
|
||||
|
||||
parsed = self._parse(to=to)
|
||||
if is_given(self._options.post_parser):
|
||||
parsed = self._options.post_parser(parsed)
|
||||
|
||||
self._parsed_by_type[cache_key] = parsed
|
||||
return parsed
|
||||
|
||||
async def read(self) -> bytes:
|
||||
"""Read and return the binary response content."""
|
||||
try:
|
||||
return await self.http_response.aread()
|
||||
except httpx.StreamConsumed as exc:
|
||||
# the default error raised by httpx isn't very
|
||||
# helpful in our case so we re-raise it with
|
||||
# a different error message
|
||||
raise StreamAlreadyConsumed() from exc
|
||||
|
||||
async def text(self) -> str:
|
||||
"""Read and decode the response content into a string."""
|
||||
await self.read()
|
||||
return self.http_response.text
|
||||
|
||||
async def json(self) -> object:
|
||||
"""Read and decode the JSON response content."""
|
||||
await self.read()
|
||||
return self.http_response.json()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the response and release the connection.
|
||||
|
||||
Automatically called if the response body is read to completion.
|
||||
"""
|
||||
await self.http_response.aclose()
|
||||
|
||||
async def iter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]:
|
||||
"""
|
||||
A byte-iterator over the decoded response content.
|
||||
|
||||
This automatically handles gzip, deflate and brotli encoded responses.
|
||||
"""
|
||||
async for chunk in self.http_response.aiter_bytes(chunk_size):
|
||||
yield chunk
|
||||
|
||||
async def iter_text(self, chunk_size: int | None = None) -> AsyncIterator[str]:
|
||||
"""A str-iterator over the decoded response content
|
||||
that handles both gzip, deflate, etc but also detects the content's
|
||||
string encoding.
|
||||
"""
|
||||
async for chunk in self.http_response.aiter_text(chunk_size):
|
||||
yield chunk
|
||||
|
||||
async def iter_lines(self) -> AsyncIterator[str]:
|
||||
"""Like `iter_text()` but will only yield chunks for each line"""
|
||||
async for chunk in self.http_response.aiter_lines():
|
||||
yield chunk
|
||||
|
||||
|
||||
class BinaryAPIResponse(APIResponse[bytes]):
|
||||
"""Subclass of APIResponse providing helpers for dealing with binary data.
|
||||
|
||||
Note: If you want to stream the response data instead of eagerly reading it
|
||||
all at once then you should use `.with_streaming_response` when making
|
||||
the API request, e.g. `.with_streaming_response.get_binary_response()`
|
||||
"""
|
||||
|
||||
def write_to_file(
|
||||
self,
|
||||
file: str | os.PathLike[str],
|
||||
) -> None:
|
||||
"""Write the output to the given file.
|
||||
|
||||
Accepts a filename or any path-like object, e.g. pathlib.Path
|
||||
|
||||
Note: if you want to stream the data to the file instead of writing
|
||||
all at once then you should use `.with_streaming_response` when making
|
||||
the API request, e.g. `.with_streaming_response.get_binary_response()`
|
||||
"""
|
||||
with open(file, mode="wb") as f:
|
||||
for data in self.iter_bytes():
|
||||
f.write(data)
|
||||
|
||||
|
||||
class AsyncBinaryAPIResponse(AsyncAPIResponse[bytes]):
|
||||
"""Subclass of APIResponse providing helpers for dealing with binary data.
|
||||
|
||||
Note: If you want to stream the response data instead of eagerly reading it
|
||||
all at once then you should use `.with_streaming_response` when making
|
||||
the API request, e.g. `.with_streaming_response.get_binary_response()`
|
||||
"""
|
||||
|
||||
async def write_to_file(
|
||||
self,
|
||||
file: str | os.PathLike[str],
|
||||
) -> None:
|
||||
"""Write the output to the given file.
|
||||
|
||||
Accepts a filename or any path-like object, e.g. pathlib.Path
|
||||
|
||||
Note: if you want to stream the data to the file instead of writing
|
||||
all at once then you should use `.with_streaming_response` when making
|
||||
the API request, e.g. `.with_streaming_response.get_binary_response()`
|
||||
"""
|
||||
path = anyio.Path(file)
|
||||
async with await path.open(mode="wb") as f:
|
||||
async for data in self.iter_bytes():
|
||||
await f.write(data)
|
||||
|
||||
|
||||
class StreamedBinaryAPIResponse(APIResponse[bytes]):
|
||||
def stream_to_file(
|
||||
self,
|
||||
file: str | os.PathLike[str],
|
||||
*,
|
||||
chunk_size: int | None = None,
|
||||
) -> None:
|
||||
"""Streams the output to the given file.
|
||||
|
||||
Accepts a filename or any path-like object, e.g. pathlib.Path
|
||||
"""
|
||||
with open(file, mode="wb") as f:
|
||||
for data in self.iter_bytes(chunk_size):
|
||||
f.write(data)
|
||||
|
||||
|
||||
class AsyncStreamedBinaryAPIResponse(AsyncAPIResponse[bytes]):
|
||||
async def stream_to_file(
|
||||
self,
|
||||
file: str | os.PathLike[str],
|
||||
*,
|
||||
chunk_size: int | None = None,
|
||||
) -> None:
|
||||
"""Streams the output to the given file.
|
||||
|
||||
Accepts a filename or any path-like object, e.g. pathlib.Path
|
||||
"""
|
||||
path = anyio.Path(file)
|
||||
async with await path.open(mode="wb") as f:
|
||||
async for data in self.iter_bytes(chunk_size):
|
||||
await f.write(data)
|
||||
|
||||
|
||||
class MissingStreamClassError(TypeError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"The `stream` argument was set to `True` but the `stream_cls` argument was not given. See `google.genai._interactions._streaming` for reference",
|
||||
)
|
||||
|
||||
|
||||
class StreamAlreadyConsumed(GeminiNextGenAPIClientError):
|
||||
"""
|
||||
Attempted to read or stream content, but the content has already
|
||||
been streamed.
|
||||
|
||||
This can happen if you use a method like `.iter_lines()` and then attempt
|
||||
to read th entire response body afterwards, e.g.
|
||||
|
||||
```py
|
||||
response = await client.post(...)
|
||||
async for line in response.iter_lines():
|
||||
... # do something with `line`
|
||||
|
||||
content = await response.read()
|
||||
# ^ error
|
||||
```
|
||||
|
||||
If you want this behaviour you'll need to either manually accumulate the response
|
||||
content or call `await response.read()` before iterating over the stream.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
message = (
|
||||
"Attempted to read or stream some content, but the content has "
|
||||
"already been streamed. "
|
||||
"This could be due to attempting to stream the response "
|
||||
"content more than once."
|
||||
"\n\n"
|
||||
"You can fix this by manually accumulating the response content while streaming "
|
||||
"or by calling `.read()` before starting to stream."
|
||||
)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class ResponseContextManager(Generic[_APIResponseT]):
|
||||
"""Context manager for ensuring that a request is not made
|
||||
until it is entered and that the response will always be closed
|
||||
when the context manager exits
|
||||
"""
|
||||
|
||||
def __init__(self, request_func: Callable[[], _APIResponseT]) -> None:
|
||||
self._request_func = request_func
|
||||
self.__response: _APIResponseT | None = None
|
||||
|
||||
def __enter__(self) -> _APIResponseT:
|
||||
self.__response = self._request_func()
|
||||
return self.__response
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
if self.__response is not None:
|
||||
self.__response.close()
|
||||
|
||||
|
||||
class AsyncResponseContextManager(Generic[_AsyncAPIResponseT]):
|
||||
"""Context manager for ensuring that a request is not made
|
||||
until it is entered and that the response will always be closed
|
||||
when the context manager exits
|
||||
"""
|
||||
|
||||
def __init__(self, api_request: Awaitable[_AsyncAPIResponseT]) -> None:
|
||||
self._api_request = api_request
|
||||
self.__response: _AsyncAPIResponseT | None = None
|
||||
|
||||
async def __aenter__(self) -> _AsyncAPIResponseT:
|
||||
self.__response = await self._api_request
|
||||
return self.__response
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
if self.__response is not None:
|
||||
await self.__response.close()
|
||||
|
||||
|
||||
def to_streamed_response_wrapper(func: Callable[P, R]) -> Callable[P, ResponseContextManager[APIResponse[R]]]:
|
||||
"""Higher order function that takes one of our bound API methods and wraps it
|
||||
to support streaming and returning the raw `APIResponse` object directly.
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[APIResponse[R]]:
|
||||
extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})}
|
||||
extra_headers[RAW_RESPONSE_HEADER] = "stream"
|
||||
|
||||
kwargs["extra_headers"] = extra_headers
|
||||
|
||||
make_request = functools.partial(func, *args, **kwargs)
|
||||
|
||||
return ResponseContextManager(cast(Callable[[], APIResponse[R]], make_request))
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def async_to_streamed_response_wrapper(
|
||||
func: Callable[P, Awaitable[R]],
|
||||
) -> Callable[P, AsyncResponseContextManager[AsyncAPIResponse[R]]]:
|
||||
"""Higher order function that takes one of our bound API methods and wraps it
|
||||
to support streaming and returning the raw `APIResponse` object directly.
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[AsyncAPIResponse[R]]:
|
||||
extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})}
|
||||
extra_headers[RAW_RESPONSE_HEADER] = "stream"
|
||||
|
||||
kwargs["extra_headers"] = extra_headers
|
||||
|
||||
make_request = func(*args, **kwargs)
|
||||
|
||||
return AsyncResponseContextManager(cast(Awaitable[AsyncAPIResponse[R]], make_request))
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def to_custom_streamed_response_wrapper(
|
||||
func: Callable[P, object],
|
||||
response_cls: type[_APIResponseT],
|
||||
) -> Callable[P, ResponseContextManager[_APIResponseT]]:
|
||||
"""Higher order function that takes one of our bound API methods and an `APIResponse` class
|
||||
and wraps the method to support streaming and returning the given response class directly.
|
||||
|
||||
Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])`
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapped(*args: P.args, **kwargs: P.kwargs) -> ResponseContextManager[_APIResponseT]:
|
||||
extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})}
|
||||
extra_headers[RAW_RESPONSE_HEADER] = "stream"
|
||||
extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls
|
||||
|
||||
kwargs["extra_headers"] = extra_headers
|
||||
|
||||
make_request = functools.partial(func, *args, **kwargs)
|
||||
|
||||
return ResponseContextManager(cast(Callable[[], _APIResponseT], make_request))
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def async_to_custom_streamed_response_wrapper(
|
||||
func: Callable[P, Awaitable[object]],
|
||||
response_cls: type[_AsyncAPIResponseT],
|
||||
) -> Callable[P, AsyncResponseContextManager[_AsyncAPIResponseT]]:
|
||||
"""Higher order function that takes one of our bound API methods and an `APIResponse` class
|
||||
and wraps the method to support streaming and returning the given response class directly.
|
||||
|
||||
Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])`
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncResponseContextManager[_AsyncAPIResponseT]:
|
||||
extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})}
|
||||
extra_headers[RAW_RESPONSE_HEADER] = "stream"
|
||||
extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls
|
||||
|
||||
kwargs["extra_headers"] = extra_headers
|
||||
|
||||
make_request = func(*args, **kwargs)
|
||||
|
||||
return AsyncResponseContextManager(cast(Awaitable[_AsyncAPIResponseT], make_request))
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def to_raw_response_wrapper(func: Callable[P, R]) -> Callable[P, APIResponse[R]]:
|
||||
"""Higher order function that takes one of our bound API methods and wraps it
|
||||
to support returning the raw `APIResponse` object directly.
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapped(*args: P.args, **kwargs: P.kwargs) -> APIResponse[R]:
|
||||
extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})}
|
||||
extra_headers[RAW_RESPONSE_HEADER] = "raw"
|
||||
|
||||
kwargs["extra_headers"] = extra_headers
|
||||
|
||||
return cast(APIResponse[R], func(*args, **kwargs))
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def async_to_raw_response_wrapper(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[AsyncAPIResponse[R]]]:
|
||||
"""Higher order function that takes one of our bound API methods and wraps it
|
||||
to support returning the raw `APIResponse` object directly.
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapped(*args: P.args, **kwargs: P.kwargs) -> AsyncAPIResponse[R]:
|
||||
extra_headers: dict[str, str] = {**(cast(Any, kwargs.get("extra_headers")) or {})}
|
||||
extra_headers[RAW_RESPONSE_HEADER] = "raw"
|
||||
|
||||
kwargs["extra_headers"] = extra_headers
|
||||
|
||||
return cast(AsyncAPIResponse[R], await func(*args, **kwargs))
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def to_custom_raw_response_wrapper(
|
||||
func: Callable[P, object],
|
||||
response_cls: type[_APIResponseT],
|
||||
) -> Callable[P, _APIResponseT]:
|
||||
"""Higher order function that takes one of our bound API methods and an `APIResponse` class
|
||||
and wraps the method to support returning the given response class directly.
|
||||
|
||||
Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])`
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapped(*args: P.args, **kwargs: P.kwargs) -> _APIResponseT:
|
||||
extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})}
|
||||
extra_headers[RAW_RESPONSE_HEADER] = "raw"
|
||||
extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls
|
||||
|
||||
kwargs["extra_headers"] = extra_headers
|
||||
|
||||
return cast(_APIResponseT, func(*args, **kwargs))
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def async_to_custom_raw_response_wrapper(
|
||||
func: Callable[P, Awaitable[object]],
|
||||
response_cls: type[_AsyncAPIResponseT],
|
||||
) -> Callable[P, Awaitable[_AsyncAPIResponseT]]:
|
||||
"""Higher order function that takes one of our bound API methods and an `APIResponse` class
|
||||
and wraps the method to support returning the given response class directly.
|
||||
|
||||
Note: the given `response_cls` *must* be concrete, e.g. `class BinaryAPIResponse(APIResponse[bytes])`
|
||||
"""
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapped(*args: P.args, **kwargs: P.kwargs) -> Awaitable[_AsyncAPIResponseT]:
|
||||
extra_headers: dict[str, Any] = {**(cast(Any, kwargs.get("extra_headers")) or {})}
|
||||
extra_headers[RAW_RESPONSE_HEADER] = "raw"
|
||||
extra_headers[OVERRIDE_CAST_TO_HEADER] = response_cls
|
||||
|
||||
kwargs["extra_headers"] = extra_headers
|
||||
|
||||
return cast(Awaitable[_AsyncAPIResponseT], func(*args, **kwargs))
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def extract_response_type(typ: type[BaseAPIResponse[Any]]) -> type:
|
||||
"""Given a type like `APIResponse[T]`, returns the generic type variable `T`.
|
||||
|
||||
This also handles the case where a concrete subclass is given, e.g.
|
||||
```py
|
||||
class MyResponse(APIResponse[bytes]):
|
||||
...
|
||||
|
||||
extract_response_type(MyResponse) -> bytes
|
||||
```
|
||||
"""
|
||||
return extract_type_var_from_base(
|
||||
typ,
|
||||
generic_bases=cast("tuple[type, ...]", (BaseAPIResponse, APIResponse, AsyncAPIResponse)),
|
||||
index=0,
|
||||
)
|
||||
@@ -0,0 +1,359 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# Note: initially copied from https://github.com/florimondmanca/httpx-sse/blob/master/src/httpx_sse/_decoders.py
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import inspect
|
||||
from types import TracebackType
|
||||
from typing import TYPE_CHECKING, Any, Generic, TypeVar, Iterator, Optional, AsyncIterator, cast
|
||||
from typing_extensions import Self, Protocol, TypeGuard, override, get_origin, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
from ._utils import extract_type_var_from_base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._client import GeminiNextGenAPIClient, AsyncGeminiNextGenAPIClient
|
||||
from ._models import FinalRequestOptions
|
||||
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
class Stream(Generic[_T]):
|
||||
"""Provides the core interface to iterate over a synchronous stream response."""
|
||||
|
||||
response: httpx.Response
|
||||
_options: Optional[FinalRequestOptions] = None
|
||||
_decoder: SSEBytesDecoder
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cast_to: type[_T],
|
||||
response: httpx.Response,
|
||||
client: GeminiNextGenAPIClient,
|
||||
options: Optional[FinalRequestOptions] = None,
|
||||
) -> None:
|
||||
self.response = response
|
||||
self._cast_to = cast_to
|
||||
self._client = client
|
||||
self._options = options
|
||||
self._decoder = client._make_sse_decoder()
|
||||
self._iterator = self.__stream__()
|
||||
|
||||
def __next__(self) -> _T:
|
||||
return self._iterator.__next__()
|
||||
|
||||
def __iter__(self) -> Iterator[_T]:
|
||||
for item in self._iterator:
|
||||
yield item
|
||||
|
||||
def _iter_events(self) -> Iterator[ServerSentEvent]:
|
||||
yield from self._decoder.iter_bytes(self.response.iter_bytes())
|
||||
|
||||
def __stream__(self) -> Iterator[_T]:
|
||||
cast_to = cast(Any, self._cast_to)
|
||||
response = self.response
|
||||
process_data = self._client._process_response_data
|
||||
iterator = self._iter_events()
|
||||
|
||||
try:
|
||||
for sse in iterator:
|
||||
if sse.data.startswith("[DONE]"):
|
||||
break
|
||||
|
||||
yield process_data(data=sse.json(), cast_to=cast_to, response=response)
|
||||
finally:
|
||||
# Ensure the response is closed even if the consumer doesn't read all data
|
||||
response.close()
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Close the response and release the connection.
|
||||
|
||||
Automatically called if the response body is read to completion.
|
||||
"""
|
||||
self.response.close()
|
||||
|
||||
|
||||
class AsyncStream(Generic[_T]):
|
||||
"""Provides the core interface to iterate over an asynchronous stream response."""
|
||||
|
||||
response: httpx.Response
|
||||
_options: Optional[FinalRequestOptions] = None
|
||||
_decoder: SSEDecoder | SSEBytesDecoder
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cast_to: type[_T],
|
||||
response: httpx.Response,
|
||||
client: AsyncGeminiNextGenAPIClient,
|
||||
options: Optional[FinalRequestOptions] = None,
|
||||
) -> None:
|
||||
self.response = response
|
||||
self._cast_to = cast_to
|
||||
self._client = client
|
||||
self._options = options
|
||||
self._decoder = client._make_sse_decoder()
|
||||
self._iterator = self.__stream__()
|
||||
|
||||
async def __anext__(self) -> _T:
|
||||
return await self._iterator.__anext__()
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[_T]:
|
||||
async for item in self._iterator:
|
||||
yield item
|
||||
|
||||
async def _iter_events(self) -> AsyncIterator[ServerSentEvent]:
|
||||
async for sse in self._decoder.aiter_bytes(self.response.aiter_bytes()):
|
||||
yield sse
|
||||
|
||||
async def __stream__(self) -> AsyncIterator[_T]:
|
||||
cast_to = cast(Any, self._cast_to)
|
||||
response = self.response
|
||||
process_data = self._client._process_response_data
|
||||
iterator = self._iter_events()
|
||||
|
||||
try:
|
||||
async for sse in iterator:
|
||||
if sse.data.startswith("[DONE]"):
|
||||
break
|
||||
|
||||
yield process_data(data=sse.json(), cast_to=cast_to, response=response)
|
||||
finally:
|
||||
# Ensure the response is closed even if the consumer doesn't read all data
|
||||
await response.aclose()
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
await self.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
"""
|
||||
Close the response and release the connection.
|
||||
|
||||
Automatically called if the response body is read to completion.
|
||||
"""
|
||||
await self.response.aclose()
|
||||
|
||||
|
||||
class ServerSentEvent:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
event: str | None = None,
|
||||
data: str | None = None,
|
||||
id: str | None = None,
|
||||
retry: int | None = None,
|
||||
) -> None:
|
||||
if data is None:
|
||||
data = ""
|
||||
|
||||
self._id = id
|
||||
self._data = data
|
||||
self._event = event or None
|
||||
self._retry = retry
|
||||
|
||||
@property
|
||||
def event(self) -> str | None:
|
||||
return self._event
|
||||
|
||||
@property
|
||||
def id(self) -> str | None:
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def retry(self) -> int | None:
|
||||
return self._retry
|
||||
|
||||
@property
|
||||
def data(self) -> str:
|
||||
return self._data
|
||||
|
||||
def json(self) -> Any:
|
||||
return json.loads(self.data)
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return f"ServerSentEvent(event={self.event}, data={self.data}, id={self.id}, retry={self.retry})"
|
||||
|
||||
|
||||
class SSEDecoder:
|
||||
_data: list[str]
|
||||
_event: str | None
|
||||
_retry: int | None
|
||||
_last_event_id: str | None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._event = None
|
||||
self._data = []
|
||||
self._last_event_id = None
|
||||
self._retry = None
|
||||
|
||||
def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]:
|
||||
"""Given an iterator that yields raw binary data, iterate over it & yield every event encountered"""
|
||||
for chunk in self._iter_chunks(iterator):
|
||||
# Split before decoding so splitlines() only uses \r and \n
|
||||
for raw_line in chunk.splitlines():
|
||||
line = raw_line.decode("utf-8")
|
||||
sse = self.decode(line)
|
||||
if sse:
|
||||
yield sse
|
||||
|
||||
def _iter_chunks(self, iterator: Iterator[bytes]) -> Iterator[bytes]:
|
||||
"""Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks"""
|
||||
data = b""
|
||||
for chunk in iterator:
|
||||
for line in chunk.splitlines(keepends=True):
|
||||
data += line
|
||||
if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")):
|
||||
yield data
|
||||
data = b""
|
||||
if data:
|
||||
yield data
|
||||
|
||||
async def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]:
|
||||
"""Given an iterator that yields raw binary data, iterate over it & yield every event encountered"""
|
||||
async for chunk in self._aiter_chunks(iterator):
|
||||
# Split before decoding so splitlines() only uses \r and \n
|
||||
for raw_line in chunk.splitlines():
|
||||
line = raw_line.decode("utf-8")
|
||||
sse = self.decode(line)
|
||||
if sse:
|
||||
yield sse
|
||||
|
||||
async def _aiter_chunks(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
|
||||
"""Given an iterator that yields raw binary data, iterate over it and yield individual SSE chunks"""
|
||||
data = b""
|
||||
async for chunk in iterator:
|
||||
for line in chunk.splitlines(keepends=True):
|
||||
data += line
|
||||
if data.endswith((b"\r\r", b"\n\n", b"\r\n\r\n")):
|
||||
yield data
|
||||
data = b""
|
||||
if data:
|
||||
yield data
|
||||
|
||||
def decode(self, line: str) -> ServerSentEvent | None:
|
||||
# See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501
|
||||
|
||||
if not line:
|
||||
if not self._event and not self._data and not self._last_event_id and self._retry is None:
|
||||
return None
|
||||
|
||||
sse = ServerSentEvent(
|
||||
event=self._event,
|
||||
data="\n".join(self._data),
|
||||
id=self._last_event_id,
|
||||
retry=self._retry,
|
||||
)
|
||||
|
||||
# NOTE: as per the SSE spec, do not reset last_event_id.
|
||||
self._event = None
|
||||
self._data = []
|
||||
self._retry = None
|
||||
|
||||
return sse
|
||||
|
||||
if line.startswith(":"):
|
||||
return None
|
||||
|
||||
fieldname, _, value = line.partition(":")
|
||||
|
||||
if value.startswith(" "):
|
||||
value = value[1:]
|
||||
|
||||
if fieldname == "event":
|
||||
self._event = value
|
||||
elif fieldname == "data":
|
||||
self._data.append(value)
|
||||
elif fieldname == "id":
|
||||
if "\0" in value:
|
||||
pass
|
||||
else:
|
||||
self._last_event_id = value
|
||||
elif fieldname == "retry":
|
||||
try:
|
||||
self._retry = int(value)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
else:
|
||||
pass # Field is ignored.
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SSEBytesDecoder(Protocol):
|
||||
def iter_bytes(self, iterator: Iterator[bytes]) -> Iterator[ServerSentEvent]:
|
||||
"""Given an iterator that yields raw binary data, iterate over it & yield every event encountered"""
|
||||
...
|
||||
|
||||
def aiter_bytes(self, iterator: AsyncIterator[bytes]) -> AsyncIterator[ServerSentEvent]:
|
||||
"""Given an async iterator that yields raw binary data, iterate over it & yield every event encountered"""
|
||||
...
|
||||
|
||||
|
||||
def is_stream_class_type(typ: type) -> TypeGuard[type[Stream[object]] | type[AsyncStream[object]]]:
|
||||
"""TypeGuard for determining whether or not the given type is a subclass of `Stream` / `AsyncStream`"""
|
||||
origin = get_origin(typ) or typ
|
||||
return inspect.isclass(origin) and issubclass(origin, (Stream, AsyncStream))
|
||||
|
||||
|
||||
def extract_stream_chunk_type(
|
||||
stream_cls: type,
|
||||
*,
|
||||
failure_message: str | None = None,
|
||||
) -> type:
|
||||
"""Given a type like `Stream[T]`, returns the generic type variable `T`.
|
||||
|
||||
This also handles the case where a concrete subclass is given, e.g.
|
||||
```py
|
||||
class MyStream(Stream[bytes]):
|
||||
...
|
||||
|
||||
extract_stream_chunk_type(MyStream) -> bytes
|
||||
```
|
||||
"""
|
||||
from ._base_client import Stream, AsyncStream
|
||||
|
||||
return extract_type_var_from_base(
|
||||
stream_cls,
|
||||
index=0,
|
||||
generic_bases=cast("tuple[type, ...]", (Stream, AsyncStream)),
|
||||
failure_message=failure_message,
|
||||
)
|
||||
@@ -0,0 +1,285 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from os import PathLike
|
||||
from typing import (
|
||||
IO,
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Type,
|
||||
Tuple,
|
||||
Union,
|
||||
Mapping,
|
||||
TypeVar,
|
||||
Callable,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Optional,
|
||||
Sequence,
|
||||
AsyncIterable,
|
||||
)
|
||||
from typing_extensions import (
|
||||
Set,
|
||||
Literal,
|
||||
Protocol,
|
||||
TypeAlias,
|
||||
TypedDict,
|
||||
SupportsIndex,
|
||||
overload,
|
||||
override,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
import httpx
|
||||
import pydantic
|
||||
from httpx import URL, Proxy, Timeout, Response, BaseTransport, AsyncBaseTransport
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._models import BaseModel
|
||||
from ._response import APIResponse, AsyncAPIResponse
|
||||
|
||||
Transport = BaseTransport
|
||||
AsyncTransport = AsyncBaseTransport
|
||||
Query = Mapping[str, object]
|
||||
Body = object
|
||||
AnyMapping = Mapping[str, object]
|
||||
ModelT = TypeVar("ModelT", bound=pydantic.BaseModel)
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
# Approximates httpx internal ProxiesTypes and RequestFiles types
|
||||
# while adding support for `PathLike` instances
|
||||
ProxiesDict = Dict["str | URL", Union[None, str, URL, Proxy]]
|
||||
ProxiesTypes = Union[str, Proxy, ProxiesDict]
|
||||
if TYPE_CHECKING:
|
||||
Base64FileInput = Union[IO[bytes], PathLike[str]]
|
||||
FileContent = Union[IO[bytes], bytes, PathLike[str]]
|
||||
else:
|
||||
Base64FileInput = Union[IO[bytes], PathLike]
|
||||
FileContent = Union[IO[bytes], bytes, PathLike] # PathLike is not subscriptable in Python 3.8.
|
||||
|
||||
|
||||
# Used for sending raw binary data / streaming data in request bodies
|
||||
# e.g. for file uploads without multipart encoding
|
||||
BinaryTypes = Union[bytes, bytearray, IO[bytes], Iterable[bytes]]
|
||||
AsyncBinaryTypes = Union[bytes, bytearray, IO[bytes], AsyncIterable[bytes]]
|
||||
|
||||
FileTypes = Union[
|
||||
# file (or bytes)
|
||||
FileContent,
|
||||
# (filename, file (or bytes))
|
||||
Tuple[Optional[str], FileContent],
|
||||
# (filename, file (or bytes), content_type)
|
||||
Tuple[Optional[str], FileContent, Optional[str]],
|
||||
# (filename, file (or bytes), content_type, headers)
|
||||
Tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]],
|
||||
]
|
||||
RequestFiles = Union[Mapping[str, FileTypes], Sequence[Tuple[str, FileTypes]]]
|
||||
|
||||
# duplicate of the above but without our custom file support
|
||||
HttpxFileContent = Union[IO[bytes], bytes]
|
||||
HttpxFileTypes = Union[
|
||||
# file (or bytes)
|
||||
HttpxFileContent,
|
||||
# (filename, file (or bytes))
|
||||
Tuple[Optional[str], HttpxFileContent],
|
||||
# (filename, file (or bytes), content_type)
|
||||
Tuple[Optional[str], HttpxFileContent, Optional[str]],
|
||||
# (filename, file (or bytes), content_type, headers)
|
||||
Tuple[Optional[str], HttpxFileContent, Optional[str], Mapping[str, str]],
|
||||
]
|
||||
HttpxRequestFiles = Union[Mapping[str, HttpxFileTypes], Sequence[Tuple[str, HttpxFileTypes]]]
|
||||
|
||||
# Workaround to support (cast_to: Type[ResponseT]) -> ResponseT
|
||||
# where ResponseT includes `None`. In order to support directly
|
||||
# passing `None`, overloads would have to be defined for every
|
||||
# method that uses `ResponseT` which would lead to an unacceptable
|
||||
# amount of code duplication and make it unreadable. See _base_client.py
|
||||
# for example usage.
|
||||
#
|
||||
# This unfortunately means that you will either have
|
||||
# to import this type and pass it explicitly:
|
||||
#
|
||||
# from google.genai._interactions import NoneType
|
||||
# client.get('/foo', cast_to=NoneType)
|
||||
#
|
||||
# or build it yourself:
|
||||
#
|
||||
# client.get('/foo', cast_to=type(None))
|
||||
if TYPE_CHECKING:
|
||||
NoneType: Type[None]
|
||||
else:
|
||||
NoneType = type(None)
|
||||
|
||||
|
||||
class RequestOptions(TypedDict, total=False):
|
||||
headers: Headers
|
||||
max_retries: int
|
||||
timeout: float | Timeout | None
|
||||
params: Query
|
||||
extra_json: AnyMapping
|
||||
idempotency_key: str
|
||||
follow_redirects: bool
|
||||
|
||||
|
||||
# Sentinel class used until PEP 0661 is accepted
|
||||
class NotGiven:
|
||||
"""
|
||||
For parameters with a meaningful None value, we need to distinguish between
|
||||
the user explicitly passing None, and the user not passing the parameter at
|
||||
all.
|
||||
|
||||
User code shouldn't need to use not_given directly.
|
||||
|
||||
For example:
|
||||
|
||||
```py
|
||||
def create(timeout: Timeout | None | NotGiven = not_given): ...
|
||||
|
||||
|
||||
create(timeout=1) # 1s timeout
|
||||
create(timeout=None) # No timeout
|
||||
create() # Default timeout behavior
|
||||
```
|
||||
"""
|
||||
|
||||
def __bool__(self) -> Literal[False]:
|
||||
return False
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return "NOT_GIVEN"
|
||||
|
||||
|
||||
not_given = NotGiven()
|
||||
# for backwards compatibility:
|
||||
NOT_GIVEN = NotGiven()
|
||||
|
||||
|
||||
class Omit:
|
||||
"""
|
||||
To explicitly omit something from being sent in a request, use `omit`.
|
||||
|
||||
```py
|
||||
# as the default `Content-Type` header is `application/json` that will be sent
|
||||
client.post("/upload/files", files={"file": b"my raw file content"})
|
||||
|
||||
# you can't explicitly override the header as it has to be dynamically generated
|
||||
# to look something like: 'multipart/form-data; boundary=0d8382fcf5f8c3be01ca2e11002d2983'
|
||||
client.post(..., headers={"Content-Type": "multipart/form-data"})
|
||||
|
||||
# instead you can remove the default `application/json` header by passing omit
|
||||
client.post(..., headers={"Content-Type": omit})
|
||||
```
|
||||
"""
|
||||
|
||||
def __bool__(self) -> Literal[False]:
|
||||
return False
|
||||
|
||||
|
||||
omit = Omit()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ModelBuilderProtocol(Protocol):
|
||||
@classmethod
|
||||
def build(
|
||||
cls: type[_T],
|
||||
*,
|
||||
response: Response,
|
||||
data: object,
|
||||
) -> _T: ...
|
||||
|
||||
|
||||
Headers = Mapping[str, Union[str, Omit]]
|
||||
|
||||
|
||||
class HeadersLikeProtocol(Protocol):
|
||||
def get(self, __key: str) -> str | None: ...
|
||||
|
||||
|
||||
HeadersLike = Union[Headers, HeadersLikeProtocol]
|
||||
|
||||
ResponseT = TypeVar(
|
||||
"ResponseT",
|
||||
bound=Union[
|
||||
object,
|
||||
str,
|
||||
None,
|
||||
"BaseModel",
|
||||
List[Any],
|
||||
Dict[str, Any],
|
||||
Response,
|
||||
ModelBuilderProtocol,
|
||||
"APIResponse[Any]",
|
||||
"AsyncAPIResponse[Any]",
|
||||
],
|
||||
)
|
||||
|
||||
StrBytesIntFloat = Union[str, bytes, int, float]
|
||||
|
||||
# Note: copied from Pydantic
|
||||
# https://github.com/pydantic/pydantic/blob/6f31f8f68ef011f84357330186f603ff295312fd/pydantic/main.py#L79
|
||||
IncEx: TypeAlias = Union[Set[int], Set[str], Mapping[int, Union["IncEx", bool]], Mapping[str, Union["IncEx", bool]]]
|
||||
|
||||
PostParser = Callable[[Any], Any]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class InheritsGeneric(Protocol):
|
||||
"""Represents a type that has inherited from `Generic`
|
||||
|
||||
The `__orig_bases__` property can be used to determine the resolved
|
||||
type variable for a given base class.
|
||||
"""
|
||||
|
||||
__orig_bases__: tuple[_GenericAlias]
|
||||
|
||||
|
||||
class _GenericAlias(Protocol):
|
||||
__origin__: type[object]
|
||||
|
||||
|
||||
class HttpxSendArgs(TypedDict, total=False):
|
||||
auth: httpx.Auth
|
||||
follow_redirects: bool
|
||||
|
||||
|
||||
_T_co = TypeVar("_T_co", covariant=True)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# This works because str.__contains__ does not accept object (either in typeshed or at runtime)
|
||||
# https://github.com/hauntsaninja/useful_types/blob/5e9710f3875107d068e7679fd7fec9cfab0eff3b/useful_types/__init__.py#L285
|
||||
#
|
||||
# Note: index() and count() methods are intentionally omitted to allow pyright to properly
|
||||
# infer TypedDict types when dict literals are used in lists assigned to SequenceNotStr.
|
||||
class SequenceNotStr(Protocol[_T_co]):
|
||||
@overload
|
||||
def __getitem__(self, index: SupportsIndex, /) -> _T_co: ...
|
||||
@overload
|
||||
def __getitem__(self, index: slice, /) -> Sequence[_T_co]: ...
|
||||
def __contains__(self, value: object, /) -> bool: ...
|
||||
def __len__(self) -> int: ...
|
||||
def __iter__(self) -> Iterator[_T_co]: ...
|
||||
def __reversed__(self) -> Iterator[_T_co]: ...
|
||||
else:
|
||||
# just point this to a normal `Sequence` at runtime to avoid having to special case
|
||||
# deserializing our custom sequence type
|
||||
SequenceNotStr = Sequence
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from ._path import path_template as path_template
|
||||
from ._sync import asyncify as asyncify
|
||||
from ._proxy import LazyProxy as LazyProxy
|
||||
from ._utils import (
|
||||
flatten as flatten,
|
||||
is_dict as is_dict,
|
||||
is_list as is_list,
|
||||
is_given as is_given,
|
||||
is_tuple as is_tuple,
|
||||
json_safe as json_safe,
|
||||
lru_cache as lru_cache,
|
||||
is_mapping as is_mapping,
|
||||
is_tuple_t as is_tuple_t,
|
||||
is_iterable as is_iterable,
|
||||
is_sequence as is_sequence,
|
||||
coerce_float as coerce_float,
|
||||
is_mapping_t as is_mapping_t,
|
||||
removeprefix as removeprefix,
|
||||
removesuffix as removesuffix,
|
||||
extract_files as extract_files,
|
||||
is_sequence_t as is_sequence_t,
|
||||
required_args as required_args,
|
||||
coerce_boolean as coerce_boolean,
|
||||
coerce_integer as coerce_integer,
|
||||
file_from_path as file_from_path,
|
||||
strip_not_given as strip_not_given,
|
||||
deepcopy_minimal as deepcopy_minimal,
|
||||
get_async_library as get_async_library,
|
||||
maybe_coerce_float as maybe_coerce_float,
|
||||
get_required_header as get_required_header,
|
||||
maybe_coerce_boolean as maybe_coerce_boolean,
|
||||
maybe_coerce_integer as maybe_coerce_integer,
|
||||
)
|
||||
from ._compat import (
|
||||
get_args as get_args,
|
||||
is_union as is_union,
|
||||
get_origin as get_origin,
|
||||
is_typeddict as is_typeddict,
|
||||
is_literal_type as is_literal_type,
|
||||
)
|
||||
from ._typing import (
|
||||
is_list_type as is_list_type,
|
||||
is_union_type as is_union_type,
|
||||
extract_type_arg as extract_type_arg,
|
||||
is_iterable_type as is_iterable_type,
|
||||
is_required_type as is_required_type,
|
||||
is_sequence_type as is_sequence_type,
|
||||
is_annotated_type as is_annotated_type,
|
||||
is_type_alias_type as is_type_alias_type,
|
||||
strip_annotated_type as strip_annotated_type,
|
||||
extract_type_var_from_base as extract_type_var_from_base,
|
||||
)
|
||||
from ._streams import consume_sync_iterator as consume_sync_iterator, consume_async_iterator as consume_async_iterator
|
||||
from ._transform import (
|
||||
PropertyInfo as PropertyInfo,
|
||||
transform as transform,
|
||||
async_transform as async_transform,
|
||||
maybe_transform as maybe_transform,
|
||||
async_maybe_transform as async_maybe_transform,
|
||||
)
|
||||
from ._reflection import (
|
||||
function_has_argument as function_has_argument,
|
||||
assert_signatures_in_sync as assert_signatures_in_sync,
|
||||
)
|
||||
from ._datetime_parse import parse_date as parse_date, parse_datetime as parse_datetime
|
||||
@@ -0,0 +1,61 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# mypy: ignore-errors
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import typing_extensions
|
||||
from typing import Any, Type, Union, Literal, Optional
|
||||
from datetime import date, datetime
|
||||
from typing_extensions import get_args as _get_args, get_origin as _get_origin
|
||||
|
||||
from .._types import StrBytesIntFloat
|
||||
from ._datetime_parse import parse_date as _parse_date, parse_datetime as _parse_datetime
|
||||
|
||||
_LITERAL_TYPES = {Literal, typing_extensions.Literal}
|
||||
|
||||
|
||||
def get_args(tp: type[Any]) -> tuple[Any, ...]:
|
||||
return _get_args(tp)
|
||||
|
||||
|
||||
def get_origin(tp: type[Any]) -> type[Any] | None:
|
||||
return _get_origin(tp)
|
||||
|
||||
|
||||
def is_union(tp: Optional[Type[Any]]) -> bool:
|
||||
if sys.version_info < (3, 10):
|
||||
return tp is Union # type: ignore[comparison-overlap]
|
||||
else:
|
||||
import types
|
||||
|
||||
return tp is Union or tp is types.UnionType # type: ignore[comparison-overlap]
|
||||
|
||||
|
||||
def is_typeddict(tp: Type[Any]) -> bool:
|
||||
return typing_extensions.is_typeddict(tp)
|
||||
|
||||
|
||||
def is_literal_type(tp: Type[Any]) -> bool:
|
||||
return get_origin(tp) in _LITERAL_TYPES
|
||||
|
||||
|
||||
def parse_date(value: Union[date, StrBytesIntFloat]) -> date:
|
||||
return _parse_date(value)
|
||||
|
||||
|
||||
def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime:
|
||||
return _parse_datetime(value)
|
||||
@@ -0,0 +1,151 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
"""
|
||||
This file contains code from https://github.com/pydantic/pydantic/blob/main/pydantic/v1/datetime_parse.py
|
||||
without the Pydantic v1 specific errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, Union, Optional
|
||||
from datetime import date, datetime, timezone, timedelta
|
||||
|
||||
from .._types import StrBytesIntFloat
|
||||
|
||||
date_expr = r"(?P<year>\d{4})-(?P<month>\d{1,2})-(?P<day>\d{1,2})"
|
||||
time_expr = (
|
||||
r"(?P<hour>\d{1,2}):(?P<minute>\d{1,2})"
|
||||
r"(?::(?P<second>\d{1,2})(?:\.(?P<microsecond>\d{1,6})\d{0,6})?)?"
|
||||
r"(?P<tzinfo>Z|[+-]\d{2}(?::?\d{2})?)?$"
|
||||
)
|
||||
|
||||
date_re = re.compile(f"{date_expr}$")
|
||||
datetime_re = re.compile(f"{date_expr}[T ]{time_expr}")
|
||||
|
||||
|
||||
EPOCH = datetime(1970, 1, 1)
|
||||
# if greater than this, the number is in ms, if less than or equal it's in seconds
|
||||
# (in seconds this is 11th October 2603, in ms it's 20th August 1970)
|
||||
MS_WATERSHED = int(2e10)
|
||||
# slightly more than datetime.max in ns - (datetime.max - EPOCH).total_seconds() * 1e9
|
||||
MAX_NUMBER = int(3e20)
|
||||
|
||||
|
||||
def _get_numeric(value: StrBytesIntFloat, native_expected_type: str) -> Union[None, int, float]:
|
||||
if isinstance(value, (int, float)):
|
||||
return value
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
except TypeError:
|
||||
raise TypeError(f"invalid type; expected {native_expected_type}, string, bytes, int or float") from None
|
||||
|
||||
|
||||
def _from_unix_seconds(seconds: Union[int, float]) -> datetime:
|
||||
if seconds > MAX_NUMBER:
|
||||
return datetime.max
|
||||
elif seconds < -MAX_NUMBER:
|
||||
return datetime.min
|
||||
|
||||
while abs(seconds) > MS_WATERSHED:
|
||||
seconds /= 1000
|
||||
dt = EPOCH + timedelta(seconds=seconds)
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _parse_timezone(value: Optional[str]) -> Union[None, int, timezone]:
|
||||
if value == "Z":
|
||||
return timezone.utc
|
||||
elif value is not None:
|
||||
offset_mins = int(value[-2:]) if len(value) > 3 else 0
|
||||
offset = 60 * int(value[1:3]) + offset_mins
|
||||
if value[0] == "-":
|
||||
offset = -offset
|
||||
return timezone(timedelta(minutes=offset))
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def parse_datetime(value: Union[datetime, StrBytesIntFloat]) -> datetime:
|
||||
"""
|
||||
Parse a datetime/int/float/string and return a datetime.datetime.
|
||||
|
||||
This function supports time zone offsets. When the input contains one,
|
||||
the output uses a timezone with a fixed offset from UTC.
|
||||
|
||||
Raise ValueError if the input is well formatted but not a valid datetime.
|
||||
Raise ValueError if the input isn't well formatted.
|
||||
"""
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
|
||||
number = _get_numeric(value, "datetime")
|
||||
if number is not None:
|
||||
return _from_unix_seconds(number)
|
||||
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode()
|
||||
|
||||
assert not isinstance(value, (float, int))
|
||||
|
||||
match = datetime_re.match(value)
|
||||
if match is None:
|
||||
raise ValueError("invalid datetime format")
|
||||
|
||||
kw = match.groupdict()
|
||||
if kw["microsecond"]:
|
||||
kw["microsecond"] = kw["microsecond"].ljust(6, "0")
|
||||
|
||||
tzinfo = _parse_timezone(kw.pop("tzinfo"))
|
||||
kw_: Dict[str, Union[None, int, timezone]] = {k: int(v) for k, v in kw.items() if v is not None}
|
||||
kw_["tzinfo"] = tzinfo
|
||||
|
||||
return datetime(**kw_) # type: ignore
|
||||
|
||||
|
||||
def parse_date(value: Union[date, StrBytesIntFloat]) -> date:
|
||||
"""
|
||||
Parse a date/int/float/string and return a datetime.date.
|
||||
|
||||
Raise ValueError if the input is well formatted but not a valid date.
|
||||
Raise ValueError if the input isn't well formatted.
|
||||
"""
|
||||
if isinstance(value, date):
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
else:
|
||||
return value
|
||||
|
||||
number = _get_numeric(value, "date")
|
||||
if number is not None:
|
||||
return _from_unix_seconds(number).date()
|
||||
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode()
|
||||
|
||||
assert not isinstance(value, (float, int))
|
||||
match = date_re.match(value)
|
||||
if match is None:
|
||||
raise ValueError("invalid date format")
|
||||
|
||||
kw = {k: int(v) for k, v in match.groupdict().items()}
|
||||
|
||||
try:
|
||||
return date(**kw)
|
||||
except ValueError:
|
||||
raise ValueError("invalid date format") from None
|
||||
@@ -0,0 +1,50 @@
|
||||
# Copyright 2025 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 json
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
from typing_extensions import override
|
||||
|
||||
import pydantic
|
||||
|
||||
from .._compat import model_dump
|
||||
|
||||
|
||||
def openapi_dumps(obj: Any) -> bytes:
|
||||
"""
|
||||
Serialize an object to UTF-8 encoded JSON bytes.
|
||||
|
||||
Extends the standard json.dumps with support for additional types
|
||||
commonly used in the SDK, such as `datetime`, `pydantic.BaseModel`, etc.
|
||||
"""
|
||||
return json.dumps(
|
||||
obj,
|
||||
cls=_CustomEncoder,
|
||||
# Uses the same defaults as httpx's JSON serialization
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
class _CustomEncoder(json.JSONEncoder):
|
||||
@override
|
||||
def default(self, o: Any) -> Any:
|
||||
if isinstance(o, datetime):
|
||||
return o.isoformat()
|
||||
if isinstance(o, pydantic.BaseModel):
|
||||
return model_dump(o, exclude_unset=True, mode="json", by_alias=True)
|
||||
return super().default(o)
|
||||
@@ -0,0 +1,40 @@
|
||||
# Copyright 2025 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 os
|
||||
import logging
|
||||
|
||||
logger: logging.Logger = logging.getLogger("google.genai._interactions")
|
||||
httpx_logger: logging.Logger = logging.getLogger("httpx")
|
||||
|
||||
|
||||
def _basic_config() -> None:
|
||||
# e.g. [2023-10-05 14:12:26 - google.genai._interactions._base_client:818 - DEBUG] HTTP Request: POST http://127.0.0.1:4010/foo/bar "200 OK"
|
||||
logging.basicConfig(
|
||||
format="[%(asctime)s - %(name)s:%(lineno)d - %(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
env = os.environ.get("GEMINI_NEXT_GEN_API_LOG")
|
||||
if env == "debug":
|
||||
_basic_config()
|
||||
logger.setLevel(logging.DEBUG)
|
||||
httpx_logger.setLevel(logging.DEBUG)
|
||||
elif env == "info":
|
||||
_basic_config()
|
||||
logger.setLevel(logging.INFO)
|
||||
httpx_logger.setLevel(logging.INFO)
|
||||
@@ -0,0 +1,142 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import (
|
||||
Any,
|
||||
Mapping,
|
||||
Callable,
|
||||
)
|
||||
from urllib.parse import quote
|
||||
|
||||
# Matches '.' or '..' where each dot is either literal or percent-encoded (%2e / %2E).
|
||||
_DOT_SEGMENT_RE = re.compile(r"^(?:\.|%2[eE]){1,2}$")
|
||||
|
||||
_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
|
||||
|
||||
|
||||
def _quote_path_segment_part(value: str) -> str:
|
||||
"""Percent-encode `value` for use in a URI path segment.
|
||||
|
||||
Considers characters not in `pchar` set from RFC 3986 §3.3 to be unsafe.
|
||||
https://datatracker.ietf.org/doc/html/rfc3986#section-3.3
|
||||
"""
|
||||
# quote() already treats unreserved characters (letters, digits, and -._~)
|
||||
# as safe, so we only need to add sub-delims, ':', and '@'.
|
||||
# Notably, unlike the default `safe` for quote(), / is unsafe and must be quoted.
|
||||
return quote(value, safe="!$&'()*+,;=:@")
|
||||
|
||||
|
||||
def _quote_query_part(value: str) -> str:
|
||||
"""Percent-encode `value` for use in a URI query string.
|
||||
|
||||
Considers &, = and characters not in `query` set from RFC 3986 §3.4 to be unsafe.
|
||||
https://datatracker.ietf.org/doc/html/rfc3986#section-3.4
|
||||
"""
|
||||
return quote(value, safe="!$'()*+,;:@/?")
|
||||
|
||||
|
||||
def _quote_fragment_part(value: str) -> str:
|
||||
"""Percent-encode `value` for use in a URI fragment.
|
||||
|
||||
Considers characters not in `fragment` set from RFC 3986 §3.5 to be unsafe.
|
||||
https://datatracker.ietf.org/doc/html/rfc3986#section-3.5
|
||||
"""
|
||||
return quote(value, safe="!$&'()*+,;=:@/?")
|
||||
|
||||
|
||||
def _interpolate(
|
||||
template: str,
|
||||
values: Mapping[str, Any],
|
||||
quoter: Callable[[str], str],
|
||||
) -> str:
|
||||
"""Replace {name} placeholders in `template`, quoting each value with `quoter`.
|
||||
|
||||
Placeholder names are looked up in `values`.
|
||||
|
||||
Raises:
|
||||
KeyError: If a placeholder is not found in `values`.
|
||||
"""
|
||||
# re.split with a capturing group returns alternating
|
||||
# [text, name, text, name, ..., text] elements.
|
||||
parts = _PLACEHOLDER_RE.split(template)
|
||||
|
||||
for i in range(1, len(parts), 2):
|
||||
name = parts[i]
|
||||
if name not in values:
|
||||
raise KeyError(f"a value for placeholder {{{name}}} was not provided")
|
||||
val = values[name]
|
||||
if val is None:
|
||||
parts[i] = "null"
|
||||
elif isinstance(val, bool):
|
||||
parts[i] = "true" if val else "false"
|
||||
else:
|
||||
parts[i] = quoter(str(values[name]))
|
||||
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def path_template(template: str, /, **kwargs: Any) -> str:
|
||||
"""Interpolate {name} placeholders in `template` from keyword arguments.
|
||||
|
||||
Args:
|
||||
template: The template string containing {name} placeholders.
|
||||
**kwargs: Keyword arguments to interpolate into the template.
|
||||
|
||||
Returns:
|
||||
The template with placeholders interpolated and percent-encoded.
|
||||
|
||||
Safe characters for percent-encoding are dependent on the URI component.
|
||||
Placeholders in path and fragment portions are percent-encoded where the `segment`
|
||||
and `fragment` sets from RFC 3986 respectively are considered safe.
|
||||
Placeholders in the query portion are percent-encoded where the `query` set from
|
||||
RFC 3986 §3.3 is considered safe except for = and & characters.
|
||||
|
||||
Raises:
|
||||
KeyError: If a placeholder is not found in `kwargs`.
|
||||
ValueError: If resulting path contains /./ or /../ segments (including percent-encoded dot-segments).
|
||||
"""
|
||||
# Split the template into path, query, and fragment portions.
|
||||
fragment_template: str | None = None
|
||||
query_template: str | None = None
|
||||
|
||||
rest = template
|
||||
if "#" in rest:
|
||||
rest, fragment_template = rest.split("#", 1)
|
||||
if "?" in rest:
|
||||
rest, query_template = rest.split("?", 1)
|
||||
path_template = rest
|
||||
|
||||
# Interpolate each portion with the appropriate quoting rules.
|
||||
path_result = _interpolate(path_template, kwargs, _quote_path_segment_part)
|
||||
|
||||
# Reject dot-segments (. and ..) in the final assembled path. The check
|
||||
# runs after interpolation so that adjacent placeholders or a mix of static
|
||||
# text and placeholders that together form a dot-segment are caught.
|
||||
# Also reject percent-encoded dot-segments to protect against incorrectly
|
||||
# implemented normalization in servers/proxies.
|
||||
for segment in path_result.split("/"):
|
||||
if _DOT_SEGMENT_RE.match(segment):
|
||||
raise ValueError(f"Constructed path {path_result!r} contains dot-segment {segment!r} which is not allowed")
|
||||
|
||||
result = path_result
|
||||
if query_template is not None:
|
||||
result += "?" + _interpolate(query_template, kwargs, _quote_query_part)
|
||||
if fragment_template is not None:
|
||||
result += "#" + _interpolate(fragment_template, kwargs, _quote_fragment_part)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,80 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Generic, TypeVar, Iterable, cast
|
||||
from typing_extensions import override
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class LazyProxy(Generic[T], ABC):
|
||||
"""Implements data methods to pretend that an instance is another instance.
|
||||
|
||||
This includes forwarding attribute access and other methods.
|
||||
"""
|
||||
|
||||
# Note: we have to special case proxies that themselves return proxies
|
||||
# to support using a proxy as a catch-all for any random access, e.g. `proxy.foo.bar.baz`
|
||||
|
||||
def __getattr__(self, attr: str) -> object:
|
||||
proxied = self.__get_proxied__()
|
||||
if isinstance(proxied, LazyProxy):
|
||||
return proxied # pyright: ignore
|
||||
return getattr(proxied, attr)
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
proxied = self.__get_proxied__()
|
||||
if isinstance(proxied, LazyProxy):
|
||||
return proxied.__class__.__name__
|
||||
return repr(self.__get_proxied__())
|
||||
|
||||
@override
|
||||
def __str__(self) -> str:
|
||||
proxied = self.__get_proxied__()
|
||||
if isinstance(proxied, LazyProxy):
|
||||
return proxied.__class__.__name__
|
||||
return str(proxied)
|
||||
|
||||
@override
|
||||
def __dir__(self) -> Iterable[str]:
|
||||
proxied = self.__get_proxied__()
|
||||
if isinstance(proxied, LazyProxy):
|
||||
return []
|
||||
return proxied.__dir__()
|
||||
|
||||
@property # type: ignore
|
||||
@override
|
||||
def __class__(self) -> type: # pyright: ignore
|
||||
try:
|
||||
proxied = self.__get_proxied__()
|
||||
except Exception:
|
||||
return type(self)
|
||||
if issubclass(type(proxied), LazyProxy):
|
||||
return type(proxied)
|
||||
return proxied.__class__
|
||||
|
||||
def __get_proxied__(self) -> T:
|
||||
return self.__load__()
|
||||
|
||||
def __as_proxied__(self) -> T:
|
||||
"""Helper method that returns the current proxy, typed as the loaded object"""
|
||||
return cast(T, self)
|
||||
|
||||
@abstractmethod
|
||||
def __load__(self) -> T: ...
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def function_has_argument(func: Callable[..., Any], arg_name: str) -> bool:
|
||||
"""Returns whether or not the given function has a specific parameter"""
|
||||
sig = inspect.signature(func)
|
||||
return arg_name in sig.parameters
|
||||
|
||||
|
||||
def assert_signatures_in_sync(
|
||||
source_func: Callable[..., Any],
|
||||
check_func: Callable[..., Any],
|
||||
*,
|
||||
exclude_params: set[str] = set(),
|
||||
) -> None:
|
||||
"""Ensure that the signature of the second function matches the first."""
|
||||
|
||||
check_sig = inspect.signature(check_func)
|
||||
source_sig = inspect.signature(source_func)
|
||||
|
||||
errors: list[str] = []
|
||||
|
||||
for name, source_param in source_sig.parameters.items():
|
||||
if name in exclude_params:
|
||||
continue
|
||||
|
||||
custom_param = check_sig.parameters.get(name)
|
||||
if not custom_param:
|
||||
errors.append(f"the `{name}` param is missing")
|
||||
continue
|
||||
|
||||
if custom_param.annotation != source_param.annotation:
|
||||
errors.append(
|
||||
f"types for the `{name}` param are do not match; source={repr(source_param.annotation)} checking={repr(custom_param.annotation)}"
|
||||
)
|
||||
continue
|
||||
|
||||
if errors:
|
||||
raise AssertionError(f"{len(errors)} errors encountered when comparing signatures:\n\n" + "\n\n".join(errors))
|
||||
@@ -0,0 +1,39 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing_extensions import override
|
||||
|
||||
from ._proxy import LazyProxy
|
||||
|
||||
|
||||
class ResourcesProxy(LazyProxy[Any]):
|
||||
"""A proxy for the `google.genai._interactions.resources` module.
|
||||
|
||||
This is used so that we can lazily import `google.genai._interactions.resources` only when
|
||||
needed *and* so that users can just import `google.genai._interactions` and reference `google.genai._interactions.resources`
|
||||
"""
|
||||
|
||||
@override
|
||||
def __load__(self) -> Any:
|
||||
import importlib
|
||||
|
||||
mod = importlib.import_module("google.genai._interactions.resources")
|
||||
return mod
|
||||
|
||||
|
||||
resources = ResourcesProxy().__as_proxied__()
|
||||
@@ -0,0 +1,27 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from typing import Any
|
||||
from typing_extensions import Iterator, AsyncIterator
|
||||
|
||||
|
||||
def consume_sync_iterator(iterator: Iterator[Any]) -> None:
|
||||
for _ in iterator:
|
||||
...
|
||||
|
||||
|
||||
async def consume_async_iterator(iterator: AsyncIterator[Any]) -> None:
|
||||
async for _ in iterator:
|
||||
...
|
||||
@@ -0,0 +1,73 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
from typing import TypeVar, Callable, Awaitable
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
import anyio
|
||||
import sniffio
|
||||
import anyio.to_thread
|
||||
|
||||
T_Retval = TypeVar("T_Retval")
|
||||
T_ParamSpec = ParamSpec("T_ParamSpec")
|
||||
|
||||
|
||||
async def to_thread(
|
||||
func: Callable[T_ParamSpec, T_Retval], /, *args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs
|
||||
) -> T_Retval:
|
||||
if sniffio.current_async_library() == "asyncio":
|
||||
return await asyncio.to_thread(func, *args, **kwargs)
|
||||
|
||||
return await anyio.to_thread.run_sync(
|
||||
functools.partial(func, *args, **kwargs),
|
||||
)
|
||||
|
||||
|
||||
# inspired by `asyncer`, https://github.com/tiangolo/asyncer
|
||||
def asyncify(function: Callable[T_ParamSpec, T_Retval]) -> Callable[T_ParamSpec, Awaitable[T_Retval]]:
|
||||
"""
|
||||
Take a blocking function and create an async one that receives the same
|
||||
positional and keyword arguments.
|
||||
|
||||
Usage:
|
||||
|
||||
```python
|
||||
def blocking_func(arg1, arg2, kwarg1=None):
|
||||
# blocking code
|
||||
return result
|
||||
|
||||
|
||||
result = asyncify(blocking_function)(arg1, arg2, kwarg1=value1)
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
`function`: a blocking regular callable (e.g. a function)
|
||||
|
||||
## Return
|
||||
|
||||
An async function that takes the same positional and keyword arguments as the
|
||||
original one, that when called runs the same original function in a thread worker
|
||||
and returns the result.
|
||||
"""
|
||||
|
||||
async def wrapper(*args: T_ParamSpec.args, **kwargs: T_ParamSpec.kwargs) -> T_Retval:
|
||||
return await to_thread(function, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,472 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import base64
|
||||
import pathlib
|
||||
from typing import Any, Mapping, TypeVar, cast
|
||||
from datetime import date, datetime
|
||||
from typing_extensions import Literal, get_args, override, get_type_hints as _get_type_hints
|
||||
|
||||
import anyio
|
||||
import pydantic
|
||||
|
||||
from ._utils import (
|
||||
is_list,
|
||||
is_given,
|
||||
lru_cache,
|
||||
is_mapping,
|
||||
is_iterable,
|
||||
is_sequence,
|
||||
)
|
||||
from .._files import is_base64_file_input
|
||||
from ._compat import get_origin, is_typeddict
|
||||
from ._typing import (
|
||||
is_list_type,
|
||||
is_union_type,
|
||||
extract_type_arg,
|
||||
is_iterable_type,
|
||||
is_required_type,
|
||||
is_sequence_type,
|
||||
is_annotated_type,
|
||||
strip_annotated_type,
|
||||
)
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
|
||||
# TODO: support for drilling globals() and locals()
|
||||
# TODO: ensure works correctly with forward references in all cases
|
||||
|
||||
|
||||
PropertyFormat = Literal["iso8601", "base64", "custom"]
|
||||
|
||||
|
||||
class PropertyInfo:
|
||||
"""Metadata class to be used in Annotated types to provide information about a given type.
|
||||
|
||||
For example:
|
||||
|
||||
class MyParams(TypedDict):
|
||||
account_holder_name: Annotated[str, PropertyInfo(alias='accountHolderName')]
|
||||
|
||||
This means that {'account_holder_name': 'Robert'} will be transformed to {'accountHolderName': 'Robert'} before being sent to the API.
|
||||
"""
|
||||
|
||||
alias: str | None
|
||||
format: PropertyFormat | None
|
||||
format_template: str | None
|
||||
discriminator: str | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
alias: str | None = None,
|
||||
format: PropertyFormat | None = None,
|
||||
format_template: str | None = None,
|
||||
discriminator: str | None = None,
|
||||
) -> None:
|
||||
self.alias = alias
|
||||
self.format = format
|
||||
self.format_template = format_template
|
||||
self.discriminator = discriminator
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}(alias='{self.alias}', format={self.format}, format_template='{self.format_template}', discriminator='{self.discriminator}')"
|
||||
|
||||
|
||||
def maybe_transform(
|
||||
data: object,
|
||||
expected_type: object,
|
||||
) -> Any | None:
|
||||
"""Wrapper over `transform()` that allows `None` to be passed.
|
||||
|
||||
See `transform()` for more details.
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
return transform(data, expected_type)
|
||||
|
||||
|
||||
# Wrapper over _transform_recursive providing fake types
|
||||
def transform(
|
||||
data: _T,
|
||||
expected_type: object,
|
||||
) -> _T:
|
||||
"""Transform dictionaries based off of type information from the given type, for example:
|
||||
|
||||
```py
|
||||
class Params(TypedDict, total=False):
|
||||
card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]]
|
||||
|
||||
|
||||
transformed = transform({"card_id": "<my card ID>"}, Params)
|
||||
# {'cardID': '<my card ID>'}
|
||||
```
|
||||
|
||||
Any keys / data that does not have type information given will be included as is.
|
||||
|
||||
It should be noted that the transformations that this function does are not represented in the type system.
|
||||
"""
|
||||
transformed = _transform_recursive(data, annotation=cast(type, expected_type))
|
||||
return cast(_T, transformed)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8096)
|
||||
def _get_annotated_type(type_: type) -> type | None:
|
||||
"""If the given type is an `Annotated` type then it is returned, if not `None` is returned.
|
||||
|
||||
This also unwraps the type when applicable, e.g. `Required[Annotated[T, ...]]`
|
||||
"""
|
||||
if is_required_type(type_):
|
||||
# Unwrap `Required[Annotated[T, ...]]` to `Annotated[T, ...]`
|
||||
type_ = get_args(type_)[0]
|
||||
|
||||
if is_annotated_type(type_):
|
||||
return type_
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _maybe_transform_key(key: str, type_: type) -> str:
|
||||
"""Transform the given `data` based on the annotations provided in `type_`.
|
||||
|
||||
Note: this function only looks at `Annotated` types that contain `PropertyInfo` metadata.
|
||||
"""
|
||||
annotated_type = _get_annotated_type(type_)
|
||||
if annotated_type is None:
|
||||
# no `Annotated` definition for this type, no transformation needed
|
||||
return key
|
||||
|
||||
# ignore the first argument as it is the actual type
|
||||
annotations = get_args(annotated_type)[1:]
|
||||
for annotation in annotations:
|
||||
if isinstance(annotation, PropertyInfo) and annotation.alias is not None:
|
||||
return annotation.alias
|
||||
|
||||
return key
|
||||
|
||||
|
||||
def _no_transform_needed(annotation: type) -> bool:
|
||||
return annotation == float or annotation == int
|
||||
|
||||
|
||||
def _transform_recursive(
|
||||
data: object,
|
||||
*,
|
||||
annotation: type,
|
||||
inner_type: type | None = None,
|
||||
) -> object:
|
||||
"""Transform the given data against the expected type.
|
||||
|
||||
Args:
|
||||
annotation: The direct type annotation given to the particular piece of data.
|
||||
This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc
|
||||
|
||||
inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type
|
||||
is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in
|
||||
the list can be transformed using the metadata from the container type.
|
||||
|
||||
Defaults to the same value as the `annotation` argument.
|
||||
"""
|
||||
from .._compat import model_dump
|
||||
|
||||
if inner_type is None:
|
||||
inner_type = annotation
|
||||
|
||||
stripped_type = strip_annotated_type(inner_type)
|
||||
origin = get_origin(stripped_type) or stripped_type
|
||||
if is_typeddict(stripped_type) and is_mapping(data):
|
||||
return _transform_typeddict(data, stripped_type)
|
||||
|
||||
if origin == dict and is_mapping(data):
|
||||
items_type = get_args(stripped_type)[1]
|
||||
return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()}
|
||||
|
||||
if (
|
||||
# List[T]
|
||||
(is_list_type(stripped_type) and is_list(data))
|
||||
# Iterable[T]
|
||||
or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str))
|
||||
# Sequence[T]
|
||||
or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str))
|
||||
):
|
||||
# dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually
|
||||
# intended as an iterable, so we don't transform it.
|
||||
if isinstance(data, dict):
|
||||
return cast(object, data)
|
||||
|
||||
inner_type = extract_type_arg(stripped_type, 0)
|
||||
if _no_transform_needed(inner_type):
|
||||
# for some types there is no need to transform anything, so we can get a small
|
||||
# perf boost from skipping that work.
|
||||
#
|
||||
# but we still need to convert to a list to ensure the data is json-serializable
|
||||
if is_list(data):
|
||||
return data
|
||||
return list(data)
|
||||
|
||||
return [_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data]
|
||||
|
||||
if is_union_type(stripped_type):
|
||||
# For union types we run the transformation against all subtypes to ensure that everything is transformed.
|
||||
#
|
||||
# TODO: there may be edge cases where the same normalized field name will transform to two different names
|
||||
# in different subtypes.
|
||||
for subtype in get_args(stripped_type):
|
||||
data = _transform_recursive(data, annotation=annotation, inner_type=subtype)
|
||||
return data
|
||||
|
||||
if isinstance(data, pydantic.BaseModel):
|
||||
return model_dump(data, exclude_unset=True, mode="json")
|
||||
|
||||
annotated_type = _get_annotated_type(annotation)
|
||||
if annotated_type is None:
|
||||
return data
|
||||
|
||||
# ignore the first argument as it is the actual type
|
||||
annotations = get_args(annotated_type)[1:]
|
||||
for annotation in annotations:
|
||||
if isinstance(annotation, PropertyInfo) and annotation.format is not None:
|
||||
return _format_data(data, annotation.format, annotation.format_template)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object:
|
||||
if isinstance(data, (date, datetime)):
|
||||
if format_ == "iso8601":
|
||||
return data.isoformat()
|
||||
|
||||
if format_ == "custom" and format_template is not None:
|
||||
return data.strftime(format_template)
|
||||
|
||||
if format_ == "base64" and is_base64_file_input(data):
|
||||
binary: str | bytes | None = None
|
||||
|
||||
if isinstance(data, pathlib.Path):
|
||||
binary = data.read_bytes()
|
||||
elif isinstance(data, io.IOBase):
|
||||
binary = data.read()
|
||||
|
||||
if isinstance(binary, str): # type: ignore[unreachable]
|
||||
binary = binary.encode()
|
||||
|
||||
if not isinstance(binary, bytes):
|
||||
raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}")
|
||||
|
||||
return base64.b64encode(binary).decode("ascii")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _transform_typeddict(
|
||||
data: Mapping[str, object],
|
||||
expected_type: type,
|
||||
) -> Mapping[str, object]:
|
||||
result: dict[str, object] = {}
|
||||
annotations = get_type_hints(expected_type, include_extras=True)
|
||||
for key, value in data.items():
|
||||
if not is_given(value):
|
||||
# we don't need to include omitted values here as they'll
|
||||
# be stripped out before the request is sent anyway
|
||||
continue
|
||||
|
||||
type_ = annotations.get(key)
|
||||
if type_ is None:
|
||||
# we do not have a type annotation for this field, leave it as is
|
||||
result[key] = value
|
||||
else:
|
||||
result[_maybe_transform_key(key, type_)] = _transform_recursive(value, annotation=type_)
|
||||
return result
|
||||
|
||||
|
||||
async def async_maybe_transform(
|
||||
data: object,
|
||||
expected_type: object,
|
||||
) -> Any | None:
|
||||
"""Wrapper over `async_transform()` that allows `None` to be passed.
|
||||
|
||||
See `async_transform()` for more details.
|
||||
"""
|
||||
if data is None:
|
||||
return None
|
||||
return await async_transform(data, expected_type)
|
||||
|
||||
|
||||
async def async_transform(
|
||||
data: _T,
|
||||
expected_type: object,
|
||||
) -> _T:
|
||||
"""Transform dictionaries based off of type information from the given type, for example:
|
||||
|
||||
```py
|
||||
class Params(TypedDict, total=False):
|
||||
card_id: Required[Annotated[str, PropertyInfo(alias="cardID")]]
|
||||
|
||||
|
||||
transformed = transform({"card_id": "<my card ID>"}, Params)
|
||||
# {'cardID': '<my card ID>'}
|
||||
```
|
||||
|
||||
Any keys / data that does not have type information given will be included as is.
|
||||
|
||||
It should be noted that the transformations that this function does are not represented in the type system.
|
||||
"""
|
||||
transformed = await _async_transform_recursive(data, annotation=cast(type, expected_type))
|
||||
return cast(_T, transformed)
|
||||
|
||||
|
||||
async def _async_transform_recursive(
|
||||
data: object,
|
||||
*,
|
||||
annotation: type,
|
||||
inner_type: type | None = None,
|
||||
) -> object:
|
||||
"""Transform the given data against the expected type.
|
||||
|
||||
Args:
|
||||
annotation: The direct type annotation given to the particular piece of data.
|
||||
This may or may not be wrapped in metadata types, e.g. `Required[T]`, `Annotated[T, ...]` etc
|
||||
|
||||
inner_type: If applicable, this is the "inside" type. This is useful in certain cases where the outside type
|
||||
is a container type such as `List[T]`. In that case `inner_type` should be set to `T` so that each entry in
|
||||
the list can be transformed using the metadata from the container type.
|
||||
|
||||
Defaults to the same value as the `annotation` argument.
|
||||
"""
|
||||
from .._compat import model_dump
|
||||
|
||||
if inner_type is None:
|
||||
inner_type = annotation
|
||||
|
||||
stripped_type = strip_annotated_type(inner_type)
|
||||
origin = get_origin(stripped_type) or stripped_type
|
||||
if is_typeddict(stripped_type) and is_mapping(data):
|
||||
return await _async_transform_typeddict(data, stripped_type)
|
||||
|
||||
if origin == dict and is_mapping(data):
|
||||
items_type = get_args(stripped_type)[1]
|
||||
return {key: _transform_recursive(value, annotation=items_type) for key, value in data.items()}
|
||||
|
||||
if (
|
||||
# List[T]
|
||||
(is_list_type(stripped_type) and is_list(data))
|
||||
# Iterable[T]
|
||||
or (is_iterable_type(stripped_type) and is_iterable(data) and not isinstance(data, str))
|
||||
# Sequence[T]
|
||||
or (is_sequence_type(stripped_type) and is_sequence(data) and not isinstance(data, str))
|
||||
):
|
||||
# dicts are technically iterable, but it is an iterable on the keys of the dict and is not usually
|
||||
# intended as an iterable, so we don't transform it.
|
||||
if isinstance(data, dict):
|
||||
return cast(object, data)
|
||||
|
||||
inner_type = extract_type_arg(stripped_type, 0)
|
||||
if _no_transform_needed(inner_type):
|
||||
# for some types there is no need to transform anything, so we can get a small
|
||||
# perf boost from skipping that work.
|
||||
#
|
||||
# but we still need to convert to a list to ensure the data is json-serializable
|
||||
if is_list(data):
|
||||
return data
|
||||
return list(data)
|
||||
|
||||
return [await _async_transform_recursive(d, annotation=annotation, inner_type=inner_type) for d in data]
|
||||
|
||||
if is_union_type(stripped_type):
|
||||
# For union types we run the transformation against all subtypes to ensure that everything is transformed.
|
||||
#
|
||||
# TODO: there may be edge cases where the same normalized field name will transform to two different names
|
||||
# in different subtypes.
|
||||
for subtype in get_args(stripped_type):
|
||||
data = await _async_transform_recursive(data, annotation=annotation, inner_type=subtype)
|
||||
return data
|
||||
|
||||
if isinstance(data, pydantic.BaseModel):
|
||||
return model_dump(data, exclude_unset=True, mode="json")
|
||||
|
||||
annotated_type = _get_annotated_type(annotation)
|
||||
if annotated_type is None:
|
||||
return data
|
||||
|
||||
# ignore the first argument as it is the actual type
|
||||
annotations = get_args(annotated_type)[1:]
|
||||
for annotation in annotations:
|
||||
if isinstance(annotation, PropertyInfo) and annotation.format is not None:
|
||||
return await _async_format_data(data, annotation.format, annotation.format_template)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def _async_format_data(data: object, format_: PropertyFormat, format_template: str | None) -> object:
|
||||
if isinstance(data, (date, datetime)):
|
||||
if format_ == "iso8601":
|
||||
return data.isoformat()
|
||||
|
||||
if format_ == "custom" and format_template is not None:
|
||||
return data.strftime(format_template)
|
||||
|
||||
if format_ == "base64" and is_base64_file_input(data):
|
||||
binary: str | bytes | None = None
|
||||
|
||||
if isinstance(data, pathlib.Path):
|
||||
binary = await anyio.Path(data).read_bytes()
|
||||
elif isinstance(data, io.IOBase):
|
||||
binary = data.read()
|
||||
|
||||
if isinstance(binary, str): # type: ignore[unreachable]
|
||||
binary = binary.encode()
|
||||
|
||||
if not isinstance(binary, bytes):
|
||||
raise RuntimeError(f"Could not read bytes from {data}; Received {type(binary)}")
|
||||
|
||||
return base64.b64encode(binary).decode("ascii")
|
||||
|
||||
return data
|
||||
|
||||
|
||||
async def _async_transform_typeddict(
|
||||
data: Mapping[str, object],
|
||||
expected_type: type,
|
||||
) -> Mapping[str, object]:
|
||||
result: dict[str, object] = {}
|
||||
annotations = get_type_hints(expected_type, include_extras=True)
|
||||
for key, value in data.items():
|
||||
if not is_given(value):
|
||||
# we don't need to include omitted values here as they'll
|
||||
# be stripped out before the request is sent anyway
|
||||
continue
|
||||
|
||||
type_ = annotations.get(key)
|
||||
if type_ is None:
|
||||
# we do not have a type annotation for this field, leave it as is
|
||||
result[key] = value
|
||||
else:
|
||||
result[_maybe_transform_key(key, type_)] = await _async_transform_recursive(value, annotation=type_)
|
||||
return result
|
||||
|
||||
|
||||
@lru_cache(maxsize=8096)
|
||||
def get_type_hints(
|
||||
obj: Any,
|
||||
globalns: dict[str, Any] | None = None,
|
||||
localns: Mapping[str, Any] | None = None,
|
||||
include_extras: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return _get_type_hints(obj, globalns=globalns, localns=localns, include_extras=include_extras)
|
||||
@@ -0,0 +1,172 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# mypy: ignore-errors
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import typing
|
||||
import typing_extensions
|
||||
from typing import Any, TypeVar, Iterable, cast
|
||||
from collections import abc as _c_abc
|
||||
from typing_extensions import (
|
||||
TypeIs,
|
||||
Required,
|
||||
Annotated,
|
||||
get_args,
|
||||
get_origin,
|
||||
)
|
||||
|
||||
from ._utils import lru_cache
|
||||
from .._types import InheritsGeneric
|
||||
from ._compat import is_union as _is_union
|
||||
|
||||
|
||||
def is_annotated_type(typ: type) -> bool:
|
||||
return get_origin(typ) == Annotated
|
||||
|
||||
|
||||
def is_list_type(typ: type) -> bool:
|
||||
return (get_origin(typ) or typ) == list
|
||||
|
||||
|
||||
def is_sequence_type(typ: type) -> bool:
|
||||
origin = get_origin(typ) or typ
|
||||
return origin == typing_extensions.Sequence or origin == typing.Sequence or origin == _c_abc.Sequence
|
||||
|
||||
|
||||
def is_iterable_type(typ: type) -> bool:
|
||||
"""If the given type is `typing.Iterable[T]`"""
|
||||
origin = get_origin(typ) or typ
|
||||
return origin == Iterable or origin == _c_abc.Iterable
|
||||
|
||||
|
||||
def is_union_type(typ: type) -> bool:
|
||||
return _is_union(get_origin(typ))
|
||||
|
||||
|
||||
def is_required_type(typ: type) -> bool:
|
||||
return get_origin(typ) == Required
|
||||
|
||||
|
||||
def is_typevar(typ: type) -> bool:
|
||||
# type ignore is required because type checkers
|
||||
# think this expression will always return False
|
||||
return type(typ) == TypeVar # type: ignore
|
||||
|
||||
|
||||
_TYPE_ALIAS_TYPES: tuple[type[typing_extensions.TypeAliasType], ...] = (typing_extensions.TypeAliasType,)
|
||||
if sys.version_info >= (3, 12):
|
||||
_TYPE_ALIAS_TYPES = (*_TYPE_ALIAS_TYPES, typing.TypeAliasType)
|
||||
|
||||
|
||||
def is_type_alias_type(tp: Any, /) -> TypeIs[typing_extensions.TypeAliasType]:
|
||||
"""Return whether the provided argument is an instance of `TypeAliasType`.
|
||||
|
||||
```python
|
||||
type Int = int
|
||||
is_type_alias_type(Int)
|
||||
# > True
|
||||
Str = TypeAliasType("Str", str)
|
||||
is_type_alias_type(Str)
|
||||
# > True
|
||||
```
|
||||
"""
|
||||
return isinstance(tp, _TYPE_ALIAS_TYPES)
|
||||
|
||||
|
||||
# Extracts T from Annotated[T, ...] or from Required[Annotated[T, ...]]
|
||||
@lru_cache(maxsize=8096)
|
||||
def strip_annotated_type(typ: type) -> type:
|
||||
if is_required_type(typ) or is_annotated_type(typ):
|
||||
return strip_annotated_type(cast(type, get_args(typ)[0]))
|
||||
|
||||
return typ
|
||||
|
||||
|
||||
def extract_type_arg(typ: type, index: int) -> type:
|
||||
args = get_args(typ)
|
||||
try:
|
||||
return cast(type, args[index])
|
||||
except IndexError as err:
|
||||
raise RuntimeError(f"Expected type {typ} to have a type argument at index {index} but it did not") from err
|
||||
|
||||
|
||||
def extract_type_var_from_base(
|
||||
typ: type,
|
||||
*,
|
||||
generic_bases: tuple[type, ...],
|
||||
index: int,
|
||||
failure_message: str | None = None,
|
||||
) -> type:
|
||||
"""Given a type like `Foo[T]`, returns the generic type variable `T`.
|
||||
|
||||
This also handles the case where a concrete subclass is given, e.g.
|
||||
```py
|
||||
class MyResponse(Foo[bytes]):
|
||||
...
|
||||
|
||||
extract_type_var(MyResponse, bases=(Foo,), index=0) -> bytes
|
||||
```
|
||||
|
||||
And where a generic subclass is given:
|
||||
```py
|
||||
_T = TypeVar('_T')
|
||||
class MyResponse(Foo[_T]):
|
||||
...
|
||||
|
||||
extract_type_var(MyResponse[bytes], bases=(Foo,), index=0) -> bytes
|
||||
```
|
||||
"""
|
||||
cls = cast(object, get_origin(typ) or typ)
|
||||
if cls in generic_bases: # pyright: ignore[reportUnnecessaryContains]
|
||||
# we're given the class directly
|
||||
return extract_type_arg(typ, index)
|
||||
|
||||
# if a subclass is given
|
||||
# ---
|
||||
# this is needed as __orig_bases__ is not present in the typeshed stubs
|
||||
# because it is intended to be for internal use only, however there does
|
||||
# not seem to be a way to resolve generic TypeVars for inherited subclasses
|
||||
# without using it.
|
||||
if isinstance(cls, InheritsGeneric):
|
||||
target_base_class: Any | None = None
|
||||
for base in cls.__orig_bases__:
|
||||
if base.__origin__ in generic_bases:
|
||||
target_base_class = base
|
||||
break
|
||||
|
||||
if target_base_class is None:
|
||||
raise RuntimeError(
|
||||
"Could not find the generic base class;\n"
|
||||
"This should never happen;\n"
|
||||
f"Does {cls} inherit from one of {generic_bases} ?"
|
||||
)
|
||||
|
||||
extracted = extract_type_arg(target_base_class, index)
|
||||
if is_typevar(extracted):
|
||||
# If the extracted type argument is itself a type variable
|
||||
# then that means the subclass itself is generic, so we have
|
||||
# to resolve the type argument from the class itself, not
|
||||
# the base class.
|
||||
#
|
||||
# Note: if there is more than 1 type argument, the subclass could
|
||||
# change the ordering of the type arguments, this is not currently
|
||||
# supported.
|
||||
return extract_type_arg(typ, index)
|
||||
|
||||
return extracted
|
||||
|
||||
raise RuntimeError(failure_message or f"Could not resolve inner type variable at index {index} for {typ}")
|
||||
@@ -0,0 +1,438 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# mypy: ignore-errors
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import inspect
|
||||
import functools
|
||||
from typing import (
|
||||
Any,
|
||||
Tuple,
|
||||
Mapping,
|
||||
TypeVar,
|
||||
Callable,
|
||||
Iterable,
|
||||
Sequence,
|
||||
cast,
|
||||
overload,
|
||||
)
|
||||
from pathlib import Path
|
||||
from datetime import date, datetime
|
||||
from typing_extensions import TypeGuard
|
||||
|
||||
import sniffio
|
||||
|
||||
from .._types import Omit, NotGiven, FileTypes, HeadersLike
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_TupleT = TypeVar("_TupleT", bound=Tuple[object, ...])
|
||||
_MappingT = TypeVar("_MappingT", bound=Mapping[str, object])
|
||||
_SequenceT = TypeVar("_SequenceT", bound=Sequence[object])
|
||||
CallableT = TypeVar("CallableT", bound=Callable[..., Any])
|
||||
|
||||
|
||||
def flatten(t: Iterable[Iterable[_T]]) -> list[_T]:
|
||||
return [item for sublist in t for item in sublist]
|
||||
|
||||
|
||||
def extract_files(
|
||||
# TODO: this needs to take Dict but variance issues.....
|
||||
# create protocol type ?
|
||||
query: Mapping[str, object],
|
||||
*,
|
||||
paths: Sequence[Sequence[str]],
|
||||
) -> list[tuple[str, FileTypes]]:
|
||||
"""Recursively extract files from the given dictionary based on specified paths.
|
||||
|
||||
A path may look like this ['foo', 'files', '<array>', 'data'].
|
||||
|
||||
Note: this mutates the given dictionary.
|
||||
"""
|
||||
files: list[tuple[str, FileTypes]] = []
|
||||
for path in paths:
|
||||
files.extend(_extract_items(query, path, index=0, flattened_key=None))
|
||||
return files
|
||||
|
||||
|
||||
def _extract_items(
|
||||
obj: object,
|
||||
path: Sequence[str],
|
||||
*,
|
||||
index: int,
|
||||
flattened_key: str | None,
|
||||
) -> list[tuple[str, FileTypes]]:
|
||||
try:
|
||||
key = path[index]
|
||||
except IndexError:
|
||||
if not is_given(obj):
|
||||
# no value was provided - we can safely ignore
|
||||
return []
|
||||
|
||||
# cyclical import
|
||||
from .._files import assert_is_file_content
|
||||
|
||||
# We have exhausted the path, return the entry we found.
|
||||
assert flattened_key is not None
|
||||
|
||||
if is_list(obj):
|
||||
files: list[tuple[str, FileTypes]] = []
|
||||
for entry in obj:
|
||||
assert_is_file_content(entry, key=flattened_key + "[]" if flattened_key else "")
|
||||
files.append((flattened_key + "[]", cast(FileTypes, entry)))
|
||||
return files
|
||||
|
||||
assert_is_file_content(obj, key=flattened_key)
|
||||
return [(flattened_key, cast(FileTypes, obj))]
|
||||
|
||||
index += 1
|
||||
if is_dict(obj):
|
||||
try:
|
||||
# Remove the field if there are no more dict keys in the path,
|
||||
# only "<array>" traversal markers or end.
|
||||
if all(p == "<array>" for p in path[index:]):
|
||||
item = obj.pop(key)
|
||||
else:
|
||||
item = obj[key]
|
||||
except KeyError:
|
||||
# Key was not present in the dictionary, this is not indicative of an error
|
||||
# as the given path may not point to a required field. We also do not want
|
||||
# to enforce required fields as the API may differ from the spec in some cases.
|
||||
return []
|
||||
if flattened_key is None:
|
||||
flattened_key = key
|
||||
else:
|
||||
flattened_key += f"[{key}]"
|
||||
return _extract_items(
|
||||
item,
|
||||
path,
|
||||
index=index,
|
||||
flattened_key=flattened_key,
|
||||
)
|
||||
elif is_list(obj):
|
||||
if key != "<array>":
|
||||
return []
|
||||
|
||||
return flatten(
|
||||
[
|
||||
_extract_items(
|
||||
item,
|
||||
path,
|
||||
index=index,
|
||||
flattened_key=flattened_key + "[]" if flattened_key is not None else "[]",
|
||||
)
|
||||
for item in obj
|
||||
]
|
||||
)
|
||||
|
||||
# Something unexpected was passed, just ignore it.
|
||||
return []
|
||||
|
||||
|
||||
def is_given(obj: _T | NotGiven | Omit) -> TypeGuard[_T]:
|
||||
return not isinstance(obj, NotGiven) and not isinstance(obj, Omit)
|
||||
|
||||
|
||||
# Type safe methods for narrowing types with TypeVars.
|
||||
# The default narrowing for isinstance(obj, dict) is dict[unknown, unknown],
|
||||
# however this cause Pyright to rightfully report errors. As we know we don't
|
||||
# care about the contained types we can safely use `object` in its place.
|
||||
#
|
||||
# There are two separate functions defined, `is_*` and `is_*_t` for different use cases.
|
||||
# `is_*` is for when you're dealing with an unknown input
|
||||
# `is_*_t` is for when you're narrowing a known union type to a specific subset
|
||||
|
||||
|
||||
def is_tuple(obj: object) -> TypeGuard[tuple[object, ...]]:
|
||||
return isinstance(obj, tuple)
|
||||
|
||||
|
||||
def is_tuple_t(obj: _TupleT | object) -> TypeGuard[_TupleT]:
|
||||
return isinstance(obj, tuple)
|
||||
|
||||
|
||||
def is_sequence(obj: object) -> TypeGuard[Sequence[object]]:
|
||||
return isinstance(obj, Sequence)
|
||||
|
||||
|
||||
def is_sequence_t(obj: _SequenceT | object) -> TypeGuard[_SequenceT]:
|
||||
return isinstance(obj, Sequence)
|
||||
|
||||
|
||||
def is_mapping(obj: object) -> TypeGuard[Mapping[str, object]]:
|
||||
return isinstance(obj, Mapping)
|
||||
|
||||
|
||||
def is_mapping_t(obj: _MappingT | object) -> TypeGuard[_MappingT]:
|
||||
return isinstance(obj, Mapping)
|
||||
|
||||
|
||||
def is_dict(obj: object) -> TypeGuard[dict[object, object]]:
|
||||
return isinstance(obj, dict)
|
||||
|
||||
|
||||
def is_list(obj: object) -> TypeGuard[list[object]]:
|
||||
return isinstance(obj, list)
|
||||
|
||||
|
||||
def is_iterable(obj: object) -> TypeGuard[Iterable[object]]:
|
||||
return isinstance(obj, Iterable)
|
||||
|
||||
|
||||
def deepcopy_minimal(item: _T) -> _T:
|
||||
"""Minimal reimplementation of copy.deepcopy() that will only copy certain object types:
|
||||
|
||||
- mappings, e.g. `dict`
|
||||
- list
|
||||
|
||||
This is done for performance reasons.
|
||||
"""
|
||||
if is_mapping(item):
|
||||
return cast(_T, {k: deepcopy_minimal(v) for k, v in item.items()})
|
||||
if is_list(item):
|
||||
return cast(_T, [deepcopy_minimal(entry) for entry in item])
|
||||
return item
|
||||
|
||||
|
||||
# copied from https://github.com/Rapptz/RoboDanny
|
||||
def human_join(seq: Sequence[str], *, delim: str = ", ", final: str = "or") -> str:
|
||||
size = len(seq)
|
||||
if size == 0:
|
||||
return ""
|
||||
|
||||
if size == 1:
|
||||
return seq[0]
|
||||
|
||||
if size == 2:
|
||||
return f"{seq[0]} {final} {seq[1]}"
|
||||
|
||||
return delim.join(seq[:-1]) + f" {final} {seq[-1]}"
|
||||
|
||||
|
||||
def quote(string: str) -> str:
|
||||
"""Add single quotation marks around the given string. Does *not* do any escaping."""
|
||||
return f"'{string}'"
|
||||
|
||||
|
||||
def required_args(*variants: Sequence[str]) -> Callable[[CallableT], CallableT]:
|
||||
"""Decorator to enforce a given set of arguments or variants of arguments are passed to the decorated function.
|
||||
|
||||
Useful for enforcing runtime validation of overloaded functions.
|
||||
|
||||
Example usage:
|
||||
```py
|
||||
@overload
|
||||
def foo(*, a: str) -> str: ...
|
||||
|
||||
|
||||
@overload
|
||||
def foo(*, b: bool) -> str: ...
|
||||
|
||||
|
||||
# This enforces the same constraints that a static type checker would
|
||||
# i.e. that either a or b must be passed to the function
|
||||
@required_args(["a"], ["b"])
|
||||
def foo(*, a: str | None = None, b: bool | None = None) -> str: ...
|
||||
```
|
||||
"""
|
||||
|
||||
def inner(func: CallableT) -> CallableT:
|
||||
params = inspect.signature(func).parameters
|
||||
positional = [
|
||||
name
|
||||
for name, param in params.items()
|
||||
if param.kind
|
||||
in {
|
||||
param.POSITIONAL_ONLY,
|
||||
param.POSITIONAL_OR_KEYWORD,
|
||||
}
|
||||
]
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: object, **kwargs: object) -> object:
|
||||
given_params: set[str] = set()
|
||||
for i, _ in enumerate(args):
|
||||
try:
|
||||
given_params.add(positional[i])
|
||||
except IndexError:
|
||||
raise TypeError(
|
||||
f"{func.__name__}() takes {len(positional)} argument(s) but {len(args)} were given"
|
||||
) from None
|
||||
|
||||
for key in kwargs.keys():
|
||||
given_params.add(key)
|
||||
|
||||
for variant in variants:
|
||||
matches = all((param in given_params for param in variant))
|
||||
if matches:
|
||||
break
|
||||
else: # no break
|
||||
if len(variants) > 1:
|
||||
variations = human_join(
|
||||
["(" + human_join([quote(arg) for arg in variant], final="and") + ")" for variant in variants]
|
||||
)
|
||||
msg = f"Missing required arguments; Expected either {variations} arguments to be given"
|
||||
else:
|
||||
assert len(variants) > 0
|
||||
|
||||
# TODO: this error message is not deterministic
|
||||
missing = list(set(variants[0]) - given_params)
|
||||
if len(missing) > 1:
|
||||
msg = f"Missing required arguments: {human_join([quote(arg) for arg in missing])}"
|
||||
else:
|
||||
msg = f"Missing required argument: {quote(missing[0])}"
|
||||
raise TypeError(msg)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper # type: ignore
|
||||
|
||||
return inner
|
||||
|
||||
|
||||
_K = TypeVar("_K")
|
||||
_V = TypeVar("_V")
|
||||
|
||||
|
||||
@overload
|
||||
def strip_not_given(obj: None) -> None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def strip_not_given(obj: Mapping[_K, _V | NotGiven]) -> dict[_K, _V]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def strip_not_given(obj: object) -> object: ...
|
||||
|
||||
|
||||
def strip_not_given(obj: object | None) -> object:
|
||||
"""Remove all top-level keys where their values are instances of `NotGiven`"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not is_mapping(obj):
|
||||
return obj
|
||||
|
||||
return {key: value for key, value in obj.items() if not isinstance(value, NotGiven)}
|
||||
|
||||
|
||||
def coerce_integer(val: str) -> int:
|
||||
return int(val, base=10)
|
||||
|
||||
|
||||
def coerce_float(val: str) -> float:
|
||||
return float(val)
|
||||
|
||||
|
||||
def coerce_boolean(val: str) -> bool:
|
||||
return val == "true" or val == "1" or val == "on"
|
||||
|
||||
|
||||
def maybe_coerce_integer(val: str | None) -> int | None:
|
||||
if val is None:
|
||||
return None
|
||||
return coerce_integer(val)
|
||||
|
||||
|
||||
def maybe_coerce_float(val: str | None) -> float | None:
|
||||
if val is None:
|
||||
return None
|
||||
return coerce_float(val)
|
||||
|
||||
|
||||
def maybe_coerce_boolean(val: str | None) -> bool | None:
|
||||
if val is None:
|
||||
return None
|
||||
return coerce_boolean(val)
|
||||
|
||||
|
||||
def removeprefix(string: str, prefix: str) -> str:
|
||||
"""Remove a prefix from a string.
|
||||
|
||||
Backport of `str.removeprefix` for Python < 3.9
|
||||
"""
|
||||
if string.startswith(prefix):
|
||||
return string[len(prefix) :]
|
||||
return string
|
||||
|
||||
|
||||
def removesuffix(string: str, suffix: str) -> str:
|
||||
"""Remove a suffix from a string.
|
||||
|
||||
Backport of `str.removesuffix` for Python < 3.9
|
||||
"""
|
||||
if string.endswith(suffix):
|
||||
return string[: -len(suffix)]
|
||||
return string
|
||||
|
||||
|
||||
def file_from_path(path: str) -> FileTypes:
|
||||
contents = Path(path).read_bytes()
|
||||
file_name = os.path.basename(path)
|
||||
return (file_name, contents)
|
||||
|
||||
|
||||
def get_required_header(headers: HeadersLike, header: str) -> str:
|
||||
lower_header = header.lower()
|
||||
if is_mapping_t(headers):
|
||||
# mypy doesn't understand the type narrowing here
|
||||
for k, v in headers.items(): # type: ignore
|
||||
if k.lower() == lower_header and isinstance(v, str):
|
||||
return v
|
||||
|
||||
# to deal with the case where the header looks like Stainless-Event-Id
|
||||
intercaps_header = re.sub(r"([^\w])(\w)", lambda pat: pat.group(1) + pat.group(2).upper(), header.capitalize())
|
||||
|
||||
for normalized_header in [header, lower_header, header.upper(), intercaps_header]:
|
||||
value = headers.get(normalized_header)
|
||||
if value:
|
||||
return value
|
||||
|
||||
raise ValueError(f"Could not find {header} header")
|
||||
|
||||
|
||||
def get_async_library() -> str:
|
||||
try:
|
||||
return sniffio.current_async_library()
|
||||
except Exception:
|
||||
return "false"
|
||||
|
||||
|
||||
def lru_cache(*, maxsize: int | None = 128) -> Callable[[CallableT], CallableT]:
|
||||
"""A version of functools.lru_cache that retains the type signature
|
||||
for the wrapped function arguments.
|
||||
"""
|
||||
wrapper = functools.lru_cache( # noqa: TID251
|
||||
maxsize=maxsize,
|
||||
)
|
||||
return cast(Any, wrapper) # type: ignore[no-any-return]
|
||||
|
||||
|
||||
def json_safe(data: object) -> object:
|
||||
"""Translates a mapping / sequence recursively in the same fashion
|
||||
as `pydantic` v2's `model_dump(mode="json")`.
|
||||
"""
|
||||
if is_mapping(data):
|
||||
return {json_safe(key): json_safe(value) for key, value in data.items()}
|
||||
|
||||
if is_iterable(data) and not isinstance(data, (str, bytes, bytearray)):
|
||||
return [json_safe(item) for item in data]
|
||||
|
||||
if isinstance(data, (datetime, date)):
|
||||
return data.isoformat()
|
||||
|
||||
return data
|
||||
@@ -0,0 +1,18 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
from ..version import __version__ as __version__
|
||||
|
||||
__title__ = "google.genai._interactions"
|
||||
@@ -0,0 +1,48 @@
|
||||
# Copyright 2025 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.
|
||||
#
|
||||
|
||||
# File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.
|
||||
|
||||
from .webhooks import (
|
||||
WebhooksResource,
|
||||
AsyncWebhooksResource,
|
||||
WebhooksResourceWithRawResponse,
|
||||
AsyncWebhooksResourceWithRawResponse,
|
||||
WebhooksResourceWithStreamingResponse,
|
||||
AsyncWebhooksResourceWithStreamingResponse,
|
||||
)
|
||||
from .interactions import (
|
||||
InteractionsResource,
|
||||
AsyncInteractionsResource,
|
||||
InteractionsResourceWithRawResponse,
|
||||
AsyncInteractionsResourceWithRawResponse,
|
||||
InteractionsResourceWithStreamingResponse,
|
||||
AsyncInteractionsResourceWithStreamingResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"InteractionsResource",
|
||||
"AsyncInteractionsResource",
|
||||
"InteractionsResourceWithRawResponse",
|
||||
"AsyncInteractionsResourceWithRawResponse",
|
||||
"InteractionsResourceWithStreamingResponse",
|
||||
"AsyncInteractionsResourceWithStreamingResponse",
|
||||
"WebhooksResource",
|
||||
"AsyncWebhooksResource",
|
||||
"WebhooksResourceWithRawResponse",
|
||||
"AsyncWebhooksResourceWithRawResponse",
|
||||
"WebhooksResourceWithStreamingResponse",
|
||||
"AsyncWebhooksResourceWithStreamingResponse",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user