diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3129151..e63215f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,10 @@ jobs: runs-on: ubuntu-latest env: - SPOTIFY_API_BASE_URL: ${{ vars.SPOTIFY_API_BASE_URL }} - SPOTIFY_CLIENT_ID: ${{ vars.SPOTIFY_CLIENT_ID }} - SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }} + ENV_PROFILE: production + SPOTIFY_API_BASE_URL: ${{ vars.SPOTIFY_API_BASE_URL }} + SPOTIFY_CLIENT_ID: ${{ vars.SPOTIFY_CLIENT_ID }} + SPOTIFY_CLIENT_SECRET: ${{ secrets.SPOTIFY_CLIENT_SECRET }} steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/poetry.lock b/poetry.lock index 51d84ca..c334a18 100644 --- a/poetry.lock +++ b/poetry.lock @@ -841,6 +841,22 @@ files = [ {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "tenacity" +version = "8.5.0" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687"}, + {file = "tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "typing-extensions" version = "4.13.2" @@ -889,4 +905,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.1" python-versions = ">=3.13" -content-hash = "4b244f11d491da99d93e61023da8b16f01ba8524f203862fc421a495257db667" +content-hash = "2bd04ec098f890539481e9fb5322931ecb127966461ab9de3a02e6e72c8e42fd" diff --git a/pyproject.toml b/pyproject.toml index ba8bc10..f2f6263 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,8 @@ dependencies = [ "httpx (>=0.28.1,<0.29.0)", "python-dotenv (>=1.1.0,<2.0.0)", "pydantic (>=2.11.5,<3.0.0)", - "pydantic-settings (>=2.9.1,<3.0.0)" + "pydantic-settings (>=2.9.1,<3.0.0)", + "tenacity (>=8.2.2,<9.0.0)", ] [tool.poetry] diff --git a/src/api_testing_framework/client.py b/src/api_testing_framework/client.py index 3336cd3..b166012 100644 --- a/src/api_testing_framework/client.py +++ b/src/api_testing_framework/client.py @@ -1,6 +1,12 @@ from typing import Any, Dict, Optional import httpx +from tenacity import ( + retry, + retry_if_exception_type, + stop_after_attempt, + wait_exponential, +) from api_testing_framework.exceptions import APIError @@ -52,12 +58,26 @@ def _handle_response(self, response: httpx.Response) -> dict: raise APIError(response.status_code, data.get("error", response.text), data) return data + @retry( + reraise=True, + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + retry=retry_if_exception_type(APIError), + ) def get(self, path: str, params: Dict[str, Any] = None) -> dict: + """GET with retries on APIError""" self._refresh_token_if_needed() resp = self._client.get(path, params=params) return self._handle_response(resp) + @retry( + reraise=True, + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=10), + retry=retry_if_exception_type(APIError), + ) def post(self, path: str, json: Dict[str, Any] = None) -> dict: + """POST with retries on APIError""" self._refresh_token_if_needed() resp = self._client.post(path, json=json) return self._handle_response(resp) diff --git a/src/api_testing_framework/config.py b/src/api_testing_framework/config.py index 575ae4f..4be6de7 100644 --- a/src/api_testing_framework/config.py +++ b/src/api_testing_framework/config.py @@ -1,15 +1,33 @@ +import os +from typing import List, Optional from pydantic_settings import BaseSettings, SettingsConfigDict + class Settings(BaseSettings): spotify_client_id: str spotify_client_secret: str spotify_api_base_url: str - model_config = SettingsConfigDict(env_file=".env") + # Pass env_file at instantiation so the class-level default is disabled + model_config = SettingsConfigDict(env_file=None) + -def get_settings() -> Settings: +def get_settings(env_profile: Optional[str] = None) -> Settings: """ - Read and return a fresh Settings object. + Load settings from + 1) .env.{env_profile} if it exists + 2) .env if it exists + The profile defaults to the ENV_PROFILE environment variable (or 'dev'). """ - return Settings() \ No newline at end of file + if env_profile is None: + env_profile = os.getenv("ENV_PROFILE", "dev") + + cwd = os.getcwd() + candidate_files: List[str] = [ + os.path.join(cwd, f".env.{env_profile}"), + os.path.join(cwd, ".env"), + ] + env_files = [f for f in candidate_files if os.path.isfile(f)] + + return Settings(_env_file=env_files or None)