Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c6e7a01
support for @hookable decorator
bklaas Aug 24, 2025
999c626
sort imports
bklaas Aug 28, 2025
2c6bc21
add VCR cassettes for hookable
bklaas Aug 28, 2025
8e3045d
simplify creds.json additional documentation
bklaas Aug 28, 2025
be7473d
Documentation example in advanced.rst
bklaas Aug 28, 2025
54b9171
another try at the full set of hookable VCR cassettes
bklaas Aug 28, 2025
2eeb914
another attempt at workflow fix
bklaas Aug 28, 2025
1bfcba8
remove skipped tests
bklaas Aug 28, 2025
2d8b8d0
remove unit test that was problematic because of VCR
bklaas Aug 31, 2025
8b2efd0
run black
bklaas Aug 31, 2025
2b9b6f9
rename Hookable and hookable to specific terms
bklaas Sep 3, 2025
c35c00c
bump the version for docker's sake
bklaas Sep 25, 2025
05958c5
update to allow retries if response is unparsable (e.g. html not json)
bklaas Feb 16, 2026
b121acc
add # nosec to get past bandit
bklaas Feb 16, 2026
dceb2ac
more nosec comments
bklaas Feb 16, 2026
0df6dd1
exclude B101 from bandit checks
bklaas Feb 16, 2026
25a6bab
unparseable->unparsable
bklaas Feb 16, 2026
db642a7
isort
bklaas Feb 16, 2026
5e7c0c3
keep fussing with github action crap
bklaas Feb 16, 2026
5b4b1a6
more CI stuff
bklaas Feb 16, 2026
ea55b05
only test 3.10 up
bklaas Feb 16, 2026
a587174
3.10 and up, because filelock needs to be newer
bklaas Feb 16, 2026
aa1707e
capture non-parseable responses better
bklaas Feb 26, 2026
53c0ea0
updated tests
bklaas Feb 26, 2026
7838046
rewrite comment, change lint versions
bklaas Feb 27, 2026
4fdf335
try limiting to modern python
bklaas Jul 24, 2026
dc9246f
fix try/catch logic
bklaas Jul 24, 2026
5ab47de
Merge pull request #1 from bklaas/second_try_unparseable_json_fix
bklaas Jul 24, 2026
e40a2f5
Merge remote-tracking branch 'upstream/master' into merge_down_from_u…
bklaas Jul 24, 2026
99fe375
fixes
bklaas Jul 24, 2026
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
20 changes: 19 additions & 1 deletion .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,25 @@

## Tests

To run tests, add your credentials to `tests/creds.json` and run
To run tests, add your credentials to `tests/creds.json`.

The creds.json file should be in a format like this:
```json
{
"type": "service_account",
"project_id": "your-project-id",
"private_key_id": "your-private-key-id",
"private_key": "-----BEGIN PRIVATE KEY-----\nYour private key content here\n-----END PRIVATE KEY-----\n",
"client_email": "your-service-account@your-project.iam.gserviceaccount.com",
"client_id": "your-client-id",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/your-service-account%40your-project.iam.gserviceaccount.com"
}
Comment thread
bklaas marked this conversation as resolved.
Outdated
```

then run:

```bash
GS_CREDS_FILENAME="tests/creds.json" GS_RECORD_MODE="all" tox -e py -- -k "<specific test to run>"
Expand Down
208 changes: 201 additions & 7 deletions gspread/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from google.auth.exceptions import RefreshError
from google.auth.transport.requests import AuthorizedSession
from requests import Response, Session
from requests import exceptions as requests_exceptions

from .exceptions import APIError, UnSupportedExportFormat
from .urls import (
Expand All @@ -43,6 +44,13 @@
)
from .utils import ExportFormat, convert_credentials, quote

# Constants for retryable HTTP error codes
RETRYABLE_HTTP_CODES = [
HTTPStatus.REQUEST_TIMEOUT, # 408
HTTPStatus.TOO_MANY_REQUESTS, # 429
]
SERVER_ERROR_THRESHOLD = HTTPStatus.INTERNAL_SERVER_ERROR # 500

ParamsType = MutableMapping[str, Optional[Union[str, int, bool, float, List[str]]]]

FileType = Optional[
Expand All @@ -55,7 +63,195 @@
]


class HTTPClient:
class Hookable:
"""A mixin class that provides hook functionality for method execution.

This class allows methods to be decorated with hooks that execute at different
points during method execution: before execution, after successful execution,
when exceptions occur, when retryable errors occur, when timeouts occur,
and after execution regardless of success/failure.

Hooks are stored as dictionaries mapping method names to lists of hook functions.
Each hook function receives the method name, arguments, keyword arguments,
and either the result (for success/after hooks) or the exception (for error hooks).
"""

def __init__(self):
# Dicts mapping method_name → list of hooks
self.before_hooks = {}
self.after_hooks = {}
self.exception_hooks = {}
self.retry_hooks = {}
self.success_hooks = {}
self.timeout_hooks = {}

def add_before_hook(self, method_name, func):
"""Add a hook that executes before a method is called.

Args:
method_name (str): The name of the method to hook into
func (callable): The hook function to execute
"""
self.before_hooks.setdefault(method_name, []).append(func)

def add_after_hook(self, method_name, func):
"""Add a hook that executes after a method completes (regardless of success/failure).

Args:
method_name (str): The name of the method to hook into
func (callable): The hook function to execute
"""
self.after_hooks.setdefault(method_name, []).append(func)

def add_exception_hook(self, method_name, func):
"""Add a hook that executes when an exception occurs during method execution.

Args:
method_name (str): The name of the method to hook into
func (callable): The hook function to execute
"""
self.exception_hooks.setdefault(method_name, []).append(func)

def add_retry_hook(self, method_name, func):
"""Add a hook that executes when a retryable error occurs.

Args:
method_name (str): The name of the method to hook into
func (callable): The hook function to execute
"""
self.retry_hooks.setdefault(method_name, []).append(func)

def add_success_hook(self, method_name, func):
"""Add a hook that executes when a method completes successfully.

Args:
method_name (str): The name of the method to hook into
func (callable): The hook function to execute
"""
self.success_hooks.setdefault(method_name, []).append(func)

def add_timeout_hook(self, method_name, func):
"""Add a hook that executes when a timeout occurs.

Args:
method_name (str): The name of the method to hook into
func (callable): The hook function to execute
"""
self.timeout_hooks.setdefault(method_name, []).append(func)

def _run_hooks(self, hooks, method_name, args, kwargs, result=None, exception=None):
"""Execute all hooks for a given method name.

Args:
hooks (dict): Dictionary mapping method names to lists of hook functions
method_name (str): Name of the method being executed
args (tuple): Positional arguments passed to the method
kwargs (dict): Keyword arguments passed to the method
result: The result returned by the method (if successful)
exception: The exception that occurred (if any)

Note:
Exceptions in hooks are silently caught to prevent breaking the main
gspread execution flow.
"""
for hook in hooks.get(method_name, []):
try:
if exception is not None:
hook(method_name, args, kwargs, exception)
else:
hook(method_name, args, kwargs, result)
except Exception:
# an exception here should not break the main gspread execution!
pass


def hookable(method):
"""Decorator that adds hook functionality to a method.

This decorator wraps a method to execute hooks at different points:
- Before the method executes
- After successful execution
- When exceptions occur
- When timeouts occur
- When retryable errors occur (http codes that signal retryable errors)
- After execution regardless of success/failure

The decorated method must be part of a class that inherits from Hookable.

Args:
method (callable): The method to be decorated

Returns:
callable: The wrapped method with hook functionality

Example:
class MyClass(Hookable):
@hookable
def my_method(self, arg1, arg2):
# Method implementation
return result

def before_hook(method_name, args, kwargs):
print(f"Before {method_name}")

def success_hook(method_name, args, kwargs, result):
print(f"Success: {result}")

# Add hooks
obj = MyClass()
obj.add_before_hook('my_method', before_hook)
obj.add_success_hook('my_method', success_hook)
"""

def wrapper(self, *args, **kwargs):
try:
# Run before hooks
self._run_hooks(self.before_hooks, method.__name__, args, kwargs)

# Execute the method
result = method(self, *args, **kwargs)

# Run success hooks
self._run_hooks(self.success_hooks, method.__name__, args, kwargs, result)

# Run after hooks
self._run_hooks(self.after_hooks, method.__name__, args, kwargs, result)

return result

except Exception as e:
# Run exception hooks
self._run_hooks(
self.exception_hooks, method.__name__, args, kwargs, exception=e
)

# Check if it's a retryable error and run retry hooks
if isinstance(
e, (requests_exceptions.Timeout, requests_exceptions.ConnectionError)
):
self._run_hooks(
self.timeout_hooks, method.__name__, args, kwargs, exception=e
)

# Check for other retryable errors and run retry hooks
elif isinstance(e, APIError) and (
e.code in RETRYABLE_HTTP_CODES or e.code >= SERVER_ERROR_THRESHOLD
):
self._run_hooks(
self.retry_hooks, method.__name__, args, kwargs, exception=e
)
elif isinstance(e, RefreshError):
self._run_hooks(
self.retry_hooks, method.__name__, args, kwargs, exception=e
)

# Re-raise the exception
raise

return wrapper


class HTTPClient(Hookable):
"""An instance of this class communicates with Google API.

:param Credentials auth: An instance of google.auth.Credentials used to authenticate requests
Expand All @@ -76,6 +272,7 @@ class HTTPClient:
"""

def __init__(self, auth: Credentials, session: Optional[Session] = None) -> None:
super().__init__()
if session is not None:
self.session = session
else:
Expand All @@ -102,6 +299,7 @@ def set_timeout(self, timeout: Optional[Union[float, Tuple[float, float]]]) -> N
"""
self.timeout = timeout

@hookable
def request(
self,
method: str,
Expand Down Expand Up @@ -541,10 +739,7 @@ class BackOffHTTPClient(HTTPClient):
403 (Forbidden) errors for forbidden access and
for api rate limit exceeded."""

_HTTP_ERROR_CODES: List[HTTPStatus] = [
HTTPStatus.REQUEST_TIMEOUT, # in case of a timeout
HTTPStatus.TOO_MANY_REQUESTS, # sheet API usage rate limit exceeded
]
_HTTP_ERROR_CODES: List[HTTPStatus] = RETRYABLE_HTTP_CODES
_NR_BACKOFF: int = 0
_MAX_BACKOFF: int = 128 # arbitrary maximum backoff

Expand All @@ -571,8 +766,7 @@ def _should_retry(
# - >= 500: some server error
# - AND we did not reach the max retry limit
return (
code in self._HTTP_ERROR_CODES
or code >= HTTPStatus.INTERNAL_SERVER_ERROR
code in self._HTTP_ERROR_CODES or code >= SERVER_ERROR_THRESHOLD
) and wait <= self._MAX_BACKOFF

try:
Expand Down
Loading
Loading