diff --git a/.gitignore b/.gitignore index eb89b24..e427f13 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ site/ .pdm-python .token .cred +.env .cache_ggshield # common name for local Sony Ci config file diff --git a/pdm.lock b/pdm.lock index 1d929a3..d56e74a 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,10 +5,10 @@ groups = ["default", "cli", "cli-ci", "dev", "docs", "test", "tui"] strategy = ["cross_platform"] lock_version = "4.5.0" -content_hash = "sha256:0e6538dc11c48bc381845ef2710b4ce5ec132879f6445d3d29bf952aee03f99a" +content_hash = "sha256:1f19671130379d5a19f528a390bda87baa9133ceef451bcc22795df2a2d5b69c" [[metadata.targets]] -requires_python = ">=3.8" +requires_python = ">=3.9" [[package]] name = "annotated-types" @@ -1674,16 +1674,6 @@ files = [ {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, ] -[[package]] -name = "pkgutil-resolve-name" -version = "1.3.10" -requires_python = ">=3.6" -summary = "Resolve a name to an object." -files = [ - {file = "pkgutil_resolve_name-1.3.10-py3-none-any.whl", hash = "sha256:ca27cc078d25c5ad71a9de0a7a330146c4e014c2462d9af19c6b828280649c5e"}, - {file = "pkgutil_resolve_name-1.3.10.tar.gz", hash = "sha256:357d6c9e6a755653cfd78893817c0853af365dd51ec97f3d358a819373bbd174"}, -] - [[package]] name = "platformdirs" version = "4.3.6" diff --git a/pyproject.toml b/pyproject.toml index c8c169c..6d02853 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "requests-oauth2client~=1.6", "loguru~=0.7", ] -requires-python = '>=3.8' +requires-python = '>=3.9' readme = 'README.md' license = { text = 'MIT' } dynamic = ['version'] diff --git a/sonyci/_version.py b/sonyci/_version.py index 0404d81..abeeedb 100644 --- a/sonyci/_version.py +++ b/sonyci/_version.py @@ -1 +1 @@ -__version__ = '0.3.0' +__version__ = '0.4.0' diff --git a/sonyci/cli.py b/sonyci/cli.py index 6d3af77..b8edeb6 100644 --- a/sonyci/cli.py +++ b/sonyci/cli.py @@ -1,12 +1,11 @@ from json import dumps, loads from pathlib import Path -from typing import Optional +from typing import Annotated, Optional from urllib.request import urlretrieve from requests_oauth2client.tokens import BearerToken, BearerTokenSerializer from typer import Argument, Context, Exit, Option, Typer from typer.main import get_group -from typing_extensions import Annotated from sonyci import SonyCi from sonyci.log import log diff --git a/sonyci/exceptions.py b/sonyci/exceptions.py new file mode 100644 index 0000000..60fba06 --- /dev/null +++ b/sonyci/exceptions.py @@ -0,0 +1,2 @@ +class RetryError(Exception): + """Raised when a function fails after retrying a set number of times.""" diff --git a/sonyci/sonyci.py b/sonyci/sonyci.py index e97f243..d202d04 100644 --- a/sonyci/sonyci.py +++ b/sonyci/sonyci.py @@ -6,7 +6,7 @@ from sonyci.config import Config from sonyci.log import log -from sonyci.utils import get_token, json +from sonyci.utils import get_token, json, retry class SonyCi(Config): @@ -95,12 +95,15 @@ def asset_download(self, asset_id: str, **kwargs) -> dict: return self.get(f'/assets/{asset_id}/download', params=kwargs) @json + @retry def get(self, *args, **kwargs): log.debug(f'GET {args} {kwargs}') return self.client.get(*args, **kwargs) @json + @retry def post(self, *args, **kwargs): + log.debug(f'POST {args} {kwargs}') return self.client.post(*args, **kwargs) def __call__(self, path: str, **kwds: Any) -> Any: diff --git a/sonyci/utils.py b/sonyci/utils.py index 4bad4f3..7cef3db 100644 --- a/sonyci/utils.py +++ b/sonyci/utils.py @@ -2,6 +2,7 @@ from requests_oauth2client.tokens import BearerToken, BearerTokenSerializer from sonyci.config import TOKEN_URL +from sonyci.exceptions import RetryError from sonyci.log import log @@ -48,3 +49,33 @@ def inner(*args, **kwargs): return func(*args, **kwargs).json() return inner + + +def retry(func): + """Decorator for retrying a function call after a rate limit error.""" + from requests import HTTPError + + def inner(*args, **kwargs): + tries: int = 0 + max_tries: int = 5 + while tries < max_tries: + try: + return func(*args, **kwargs) + except HTTPError as e: + if e.response.status_code != 429: + log.error(f'HTTPError {e.response.status_code}: {e}') + raise e + from time import sleep + + # Get the retry-after header, if it exists + retry_after = e.response.headers.get('Retry-After') + if not retry_after: + log.error('No Retry-After header found') + raise e + log.warning(f'Rate limited. Retrying after {retry_after} seconds...') + sleep(int(retry_after + 1)) + tries += 1 + log.error(f'Failed after {max_tries} tries') + raise RetryError(f'Failed after {max_tries} tries') + + return inner diff --git a/tests/cli/test_cli_login.py b/tests/cli/test_cli_login.py index 4cc3d02..d7f28db 100644 --- a/tests/cli/test_cli_login.py +++ b/tests/cli/test_cli_login.py @@ -45,8 +45,8 @@ def test_bad_login(error_runner): assert 'invalid_client' in str(result.exception) -def test_missing_username(runner): - result = runner.invoke( +def test_missing_username(error_runner): + result = error_runner.invoke( app, [ '--client-id', @@ -59,4 +59,5 @@ def test_missing_username(runner): ], ) assert result.exit_code == 2 - assert 'username' in result.stdout + assert 'Missing option' in result.stderr + assert 'username' in result.stderr