Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9']
python: ['3.10', '3.11', '3.12', '3.13', 'pypy3.10']

steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand Down Expand Up @@ -60,10 +60,10 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Python 3.9
- name: Set up Python 3.10
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: 3.9
python-version: '3.10'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: 3.9
python-version: '3.10'

- name: Install dependencies
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: 3.9
python-version: '3.10'

- name: Install dependencies
run: |
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ information on using pull requests.

### Initial Setup

You need Python 3.9+ to build and test the code in this repo.
You need Python 3.10+ to build and test the code in this repo.

We recommend using [pip](https://pypi.python.org/pypi/pip) for installing the necessary tools and
project dependencies. Most recent versions of Python ship with pip. If your development environment
Expand Down
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,8 @@ requests, code review feedback, and also pull requests.

## Supported Python Versions

We currently support Python 3.9+. However, Python 3.9 support is deprecated,
and developers are strongly advised to use Python 3.10 or higher. Firebase
Admin Python SDK is also tested on PyPy and
[Google App Engine](https://cloud.google.com/appengine/) environments.
We currently support Python 3.10+. Firebase Admin Python SDK is also tested on
PyPy and [Google App Engine](https://cloud.google.com/appengine/) environments.


## Documentation
Expand Down
32 changes: 17 additions & 15 deletions firebase_admin/_http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Generator, Optional, Tuple, Union
import httpx
import httpx2
import requests.adapters
from requests.packages.urllib3.util import retry # pylint: disable=import-error
from google.auth import credentials
Expand Down Expand Up @@ -165,7 +165,7 @@ def __init__(self, **kwargs):
def parse_body(self, resp):
return resp.json()

class GoogleAuthCredentialFlow(httpx.Auth):
class GoogleAuthCredentialFlow(httpx2.Auth):
"""Google Auth Credential Auth Flow"""
def __init__(self, credential: credentials.Credentials):
self._credential = credential
Expand All @@ -174,7 +174,7 @@ def __init__(self, credential: credentials.Credentials):

def apply_auth_headers(
self,
request: httpx.Request,
request: httpx2.Request,
auth_request: google_auth_requests.Request
) -> None:
"""A helper function that refreshes credentials if needed and mutates the request headers
Expand All @@ -189,7 +189,9 @@ def apply_auth_headers(
)
logger.debug('Auth headers applied. Credential validity after: %s', self._credential.valid)

def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
def auth_flow(
self, request: httpx2.Request
) -> Generator[httpx2.Request, httpx2.Response, None]:
_original_headers = request.headers.copy()
_credential_refresh_attempt = 0

Expand All @@ -207,7 +209,7 @@ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Re
'Dispatching request, attempt %d of %d',
_credential_refresh_attempt, self._max_refresh_attempts
)
response: httpx.Response = yield request
response: httpx2.Response = yield request

if response.status_code in self._refresh_status_codes:
if _credential_refresh_attempt < self._max_refresh_attempts:
Expand Down Expand Up @@ -235,7 +237,7 @@ def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Re
response.status_code
)
break
# The last yielded response is automatically returned by httpx's auth flow.
# The last yielded response is automatically returned by httpx2's auth flow.

class HttpxAsyncClient():
"""Async HTTP client used to make HTTP/2 calls using HTTPX.
Expand All @@ -247,7 +249,7 @@ def __init__(
self,
credential: Optional[credentials.Credentials] = None,
base_url: str = '',
headers: Optional[Union[httpx.Headers, Dict[str, str]]] = None,
headers: Optional[Union[httpx2.Headers, Dict[str, str]]] = None,
retry_config: HttpxRetry = DEFAULT_HTTPX_RETRY_CONFIG,
timeout: int = DEFAULT_TIMEOUT_SECONDS,
http2: bool = True
Expand Down Expand Up @@ -279,15 +281,15 @@ def __init__(
}

if credential:
self._async_client = httpx.AsyncClient(
self._async_client = httpx2.AsyncClient(
http2=http2,
timeout=self._timeout,
headers=self._headers,
auth=GoogleAuthCredentialFlow(credential), # Add auth flow for credentials.
mounts=self._mounts
)
else:
self._async_client = httpx.AsyncClient(
self._async_client = httpx2.AsyncClient(
http2=http2,
timeout=self._timeout,
headers=self._headers,
Expand All @@ -306,12 +308,12 @@ def timeout(self):
def async_client(self):
return self._async_client

async def request(self, method: str, url: str, **kwargs: Any) -> httpx.Response:
async def request(self, method: str, url: str, **kwargs: Any) -> httpx2.Response:
"""Makes an HTTP call using the HTTPX library.

This is the sole entry point to the HTTPX library. All other helper methods in this
class call this method to send HTTP requests out. Refer to
https://www.python-httpx.org/api/ for more information on supported options
https://pydantic.dev/docs/httpx2/api/api/ for more information on supported options
and features.

Args:
Expand All @@ -332,12 +334,12 @@ class call this method to send HTTP requests out. Refer to
resp = await self._async_client.request(method, self.base_url + url, **kwargs)
return resp.raise_for_status()

async def headers(self, method: str, url: str, **kwargs: Any) -> httpx.Headers:
async def headers(self, method: str, url: str, **kwargs: Any) -> httpx2.Headers:
resp = await self.request(method, url, **kwargs)
return resp.headers

async def body_and_response(
self, method: str, url: str, **kwargs: Any) -> Tuple[Any, httpx.Response]:
self, method: str, url: str, **kwargs: Any) -> Tuple[Any, httpx2.Response]:
resp = await self.request(method, url, **kwargs)
return self.parse_body(resp), resp

Expand All @@ -346,11 +348,11 @@ async def body(self, method: str, url: str, **kwargs: Any) -> Any:
return self.parse_body(resp)

async def headers_and_body(
self, method: str, url: str, **kwargs: Any) -> Tuple[httpx.Headers, Any]:
self, method: str, url: str, **kwargs: Any) -> Tuple[httpx2.Headers, Any]:
resp = await self.request(method, url, **kwargs)
return resp.headers, self.parse_body(resp)

def parse_body(self, resp: httpx.Response) -> Any:
def parse_body(self, resp: httpx2.Response) -> Any:
return resp.json()

async def aclose(self) -> None:
Expand Down
33 changes: 17 additions & 16 deletions firebase_admin/_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from typing import Any, Callable, List, Optional, Tuple, Coroutine
import logging
import asyncio
import httpx
import httpx2

logger = logging.getLogger(__name__)

Expand All @@ -47,8 +47,8 @@ def __init__(
backoff_max: float = DEFAULT_BACKOFF_MAX,
backoff_jitter: float = 0,
history: Optional[List[Tuple[
httpx.Request,
Optional[httpx.Response],
httpx2.Request,
Optional[httpx2.Response],
Optional[Exception]
]]] = None,
respect_retry_after_header: bool = False,
Expand All @@ -68,7 +68,7 @@ def copy(self) -> HttpxRetry:
"""Creates a deep copy of this instance."""
return copy.deepcopy(self)

def is_retryable_response(self, response: httpx.Response) -> bool:
def is_retryable_response(self, response: httpx2.Response) -> bool:
"""Determine if a response implies that the request should be retried if possible."""
if self.status_forcelist and response.status_code in self.status_forcelist:
return True
Expand Down Expand Up @@ -98,7 +98,8 @@ def _parse_retry_after(self, retry_after_header: str) -> float | None:
else:
retry_date_tuple = email.utils.parsedate_tz(retry_after_header)
if retry_date_tuple is None:
raise httpx.RemoteProtocolError(f"Invalid Retry-After header: {retry_after_header}")
raise httpx2.RemoteProtocolError(
f"Invalid Retry-After header: {retry_after_header}")

retry_date = email.utils.mktime_tz(retry_date_tuple)
seconds = retry_date - time.time()
Expand All @@ -107,7 +108,7 @@ def _parse_retry_after(self, retry_after_header: str) -> float | None:

return seconds

def get_retry_after(self, response: httpx.Response) -> float | None:
def get_retry_after(self, response: httpx2.Response) -> float | None:
"""Determine the Retry-After time needed before sending the next request."""
retry_after_header = response.headers.get('Retry-After', None)
if retry_after_header:
Expand All @@ -133,7 +134,7 @@ async def sleep_for_backoff(self) -> None:
logger.debug('Sleeping for backoff of %f seconds following failed request', backoff)
await asyncio.sleep(backoff)

async def sleep(self, response: httpx.Response) -> None:
async def sleep(self, response: httpx2.Response) -> None:
"""Determine and wait the time needed before sending the next request."""
if self.respect_retry_after_header:
retry_after = self.get_retry_after(response)
Expand All @@ -148,16 +149,16 @@ async def sleep(self, response: httpx.Response) -> None:

def increment(
self,
request: httpx.Request,
response: Optional[httpx.Response] = None,
request: httpx2.Request,
response: Optional[httpx2.Response] = None,
error: Optional[Exception] = None
) -> None:
"""Update the retry state based on request attempt."""
self.retries_left -= 1
self.history.append((request, response, error))


class HttpxRetryTransport(httpx.AsyncBaseTransport):
class HttpxRetryTransport(httpx2.AsyncBaseTransport):
"""HTTPX transport with retry logic."""

DEFAULT_RETRY = HttpxRetry(max_retries=4, status_forcelist=[500, 503], backoff_factor=0.5)
Expand All @@ -170,17 +171,17 @@ def __init__(self, retry: HttpxRetry = DEFAULT_RETRY, **kwargs: Any) -> None:
# We use a full AsyncHTTPTransport under the hood that is already
# set up to handle requests. We also insure that that transport's internal
# retries are not allowed.
self._wrapped_transport = httpx.AsyncHTTPTransport(**transport_kwargs)
self._wrapped_transport = httpx2.AsyncHTTPTransport(**transport_kwargs)

async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
return await self._dispatch_with_retry(
request, self._wrapped_transport.handle_async_request)

async def _dispatch_with_retry(
self,
request: httpx.Request,
dispatch_method: Callable[[httpx.Request], Coroutine[Any, Any, httpx.Response]]
) -> httpx.Response:
request: httpx2.Request,
dispatch_method: Callable[[httpx2.Request], Coroutine[Any, Any, httpx2.Response]]
) -> httpx2.Response:
"""Sends a request with retry logic using a provided dispatch method."""
# This request config is used across all requests that use this transport and therefore
# needs to be copied to be used for just this request and it's retries.
Expand All @@ -201,7 +202,7 @@ async def _dispatch_with_retry(
logger.debug('Sending request in _dispatch_with_retry(): %r', request)
response = await dispatch_method(request)
logger.debug('Received response: %r', response)
except httpx.HTTPError as err:
except httpx2.HTTPError as err:
logger.debug('Received error: %r', err)
error = err

Expand Down
23 changes: 12 additions & 11 deletions firebase_admin/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

import google.auth
import requests
import httpx
import httpx2

import firebase_admin
from firebase_admin import exceptions
Expand Down Expand Up @@ -132,15 +132,15 @@ def handle_platform_error_from_requests(error, handle_func=None):
return exc if exc else _handle_func_requests(error, message, error_dict)

def handle_platform_error_from_httpx(
error: httpx.HTTPError,
error: httpx2.HTTPError,
handle_func: Optional[Callable[..., Optional[exceptions.FirebaseError]]] = None
) -> exceptions.FirebaseError:
"""Constructs a ``FirebaseError`` from the given httpx error.

This can be used to handle errors returned by Google Cloud Platform (GCP) APIs.

Args:
error: An error raised by the httpx module while making an HTTP call to a GCP API.
error: An error raised by the httpx2 module while making an HTTP call to a GCP API.
handle_func: A function that can be used to handle platform errors in a custom way. When
specified, this function will be called with three arguments. It has the same
signature as ```_handle_func_httpx``, but may return ``None``.
Expand All @@ -149,7 +149,7 @@ def handle_platform_error_from_httpx(
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
"""

if isinstance(error, httpx.HTTPStatusError):
if isinstance(error, httpx2.HTTPStatusError):
response = error.response
content = response.content.decode()
status_code = response.status_code
Expand Down Expand Up @@ -237,11 +237,11 @@ def handle_requests_error(error, message=None, code=None):
err_type = _error_code_to_exception_type(code)
return err_type(message=message, cause=error, http_response=error.response)

def _handle_func_httpx(error: httpx.HTTPError, message, error_dict) -> exceptions.FirebaseError:
def _handle_func_httpx(error: httpx2.HTTPError, message, error_dict) -> exceptions.FirebaseError:
"""Constructs a ``FirebaseError`` from the given GCP error.

Args:
error: An error raised by the httpx module while making an HTTP call.
error: An error raised by the httpx2 module while making an HTTP call.
message: A message to be included in the resulting ``FirebaseError``.
error_dict: Parsed GCP error response.

Expand All @@ -252,15 +252,16 @@ def _handle_func_httpx(error: httpx.HTTPError, message, error_dict) -> exception
return handle_httpx_error(error, message, code)


def handle_httpx_error(error: httpx.HTTPError, message=None, code=None) -> exceptions.FirebaseError:
def handle_httpx_error(
error: httpx2.HTTPError, message=None, code=None) -> exceptions.FirebaseError:
"""Constructs a ``FirebaseError`` from the given httpx error.

This method is agnostic of the remote service that produced the error, whether it is a GCP
service or otherwise. Therefore, this method does not attempt to parse the error response in
any way.

Args:
error: An error raised by the httpx module while making an HTTP call.
error: An error raised by the httpx2 module while making an HTTP call.
message: A message to be included in the resulting ``FirebaseError`` (optional). If not
specified the string representation of the ``error`` argument is used as the message.
code: A GCP error code that will be used to determine the resulting error type (optional).
Expand All @@ -270,15 +271,15 @@ def handle_httpx_error(error: httpx.HTTPError, message=None, code=None) -> excep
Returns:
FirebaseError: A ``FirebaseError`` that can be raised to the user code.
"""
if isinstance(error, httpx.TimeoutException):
if isinstance(error, httpx2.TimeoutException):
return exceptions.DeadlineExceededError(
message=f'Timed out while making an API call: {error}',
cause=error)
if isinstance(error, httpx.ConnectError):
if isinstance(error, httpx2.ConnectError):
return exceptions.UnavailableError(
message=f'Failed to establish a connection: {error}',
cause=error)
if isinstance(error, httpx.HTTPStatusError):
if isinstance(error, httpx2.HTTPStatusError):
if not code:
code = _http_status_to_error_code(error.response.status_code)
if not message:
Expand Down
Loading
Loading