diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4211685..4706e57 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,10 +20,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set uv & Python ${{ matrix.python-version }} + - name: Set uv & Python 3.12 uses: astral-sh/setup-uv@v6 with: - python-version: "3.10" + python-version: "3.12" - name: Install dependencies run: uv sync --locked --all-extras --dev diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1259ac7..37f7c57 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: continue-on-error: true strategy: matrix: - python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12" ] + python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14" ] steps: - name: Checkout code diff --git a/README.md b/README.md index 8acc7dd..9910f88 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ TODO: * Python 3+ (Tested using 3.8+) * Pandas >= 0.24.0 -* Requests >= 2.0.0 +* Httpx >= 0.28.1 ## From PyPI @@ -38,9 +38,9 @@ df = get_sheet_as_df(token='smartsheet_auth_token', sheet_id=sheet_id_int) # Using 'generic' function (without smartsheet-python-sdk) -df = get_as_df(type_='sheet', +df = get_as_df(object_type='sheet', token='smartsheet_auth_token', - id_=sheet_id_int) + object_id=sheet_id_int) ``` Alternatively, sheet objects can be used from the ``smartsheet-python-sdk`` package: @@ -56,7 +56,7 @@ sheet = smartsheet_client.Sheets.get_sheet(sheet_id_int) df = get_sheet_as_df(sheet_obj=sheet) # And using the 'generic' function -df = get_as_df(type_='sheet', +df = get_as_df(object_type='sheet', obj=sheet) ``` @@ -72,9 +72,9 @@ df = get_report_as_df(token='smartsheet_auth_token', report_id=report_id_int) # Using 'generic' function (without smartsheet-python-sdk) -df = get_as_df(type_='report', +df = get_as_df(object_type='report', token='smartsheet_auth_token', - id_=report_id_int) + object_id=report_id_int) ``` And using a report object from the ``smartsheet-python-sdk`` package: @@ -90,6 +90,6 @@ report = smartsheet_client.Reports.get_report(report_id_int) df = get_report_as_df(report_obj=report) # And using the 'generic' function -df = get_as_df(type_='report', +df = get_as_df(object_type='report', obj=report) ``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 5b2a17b..532f36a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "smartsheet-dataframe" -version = "0.3.7" +version = "1.0.0" authors = [ { name = "Ridge Coffman", email = "coffman.ridge@gmail.com" }, ] @@ -26,8 +26,8 @@ classifiers = [ "Topic :: Office/Business :: Financial :: Spreadsheet", ] dependencies = [ + "httpx>=0.22.0", "pandas>=0.24.0", - "requests>=2.0.0" ] [project.urls] @@ -106,6 +106,7 @@ dev = [ "isort>=5.10.1", "pyright>=0.0.13.post0", "pytest>=7.0.1", + "pytest-asyncio>=0.16.0", "pytest-cov>=4.0.0", "python-dotenv>=0.20.0", "ruff>=0.0.17", diff --git a/src/smartsheet_dataframe/__init__.py b/src/smartsheet_dataframe/__init__.py index 22790f6..93b6c1c 100644 --- a/src/smartsheet_dataframe/__init__.py +++ b/src/smartsheet_dataframe/__init__.py @@ -1,5 +1,7 @@ """Init file for smartsheet_dataframe module.""" +from .aio.client import AsyncClient +from .client import Client from .smartsheet_dataframe import ( get_as_df, get_report_as_df, @@ -7,6 +9,8 @@ ) __all__ = [ + "AsyncClient", + "Client", "get_as_df", "get_report_as_df", "get_sheet_as_df", diff --git a/src/smartsheet_dataframe/aio/__init__.py b/src/smartsheet_dataframe/aio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/smartsheet_dataframe/aio/client.py b/src/smartsheet_dataframe/aio/client.py new file mode 100644 index 0000000..074bbae --- /dev/null +++ b/src/smartsheet_dataframe/aio/client.py @@ -0,0 +1,79 @@ +# Standard Imports +import logging +from typing import Optional + +# 3rd-Party Imports +import pandas as pd + +logger = logging.getLogger(__name__) + + +class AsyncClient: + __slots__ = ("token", "include_row_id", "include_parent_id") + + def __init__(self, token: str, include_row_id: bool = True, include_parent_id: bool = True): + self.token = token + self.include_row_id = include_row_id + self.include_parent_id = include_parent_id + + def get_sheet_as_df(self, + sheet_id: int, + include_row_id: Optional[bool] = None, + include_parent_id: Optional[bool] = None) -> pd.DataFrame: + """Get a Smartsheet sheet as a Pandas DataFrame. + + :param sheet_id: Smartsheet source sheet id to get + :type sheet_id: int + + :param include_row_id: If True, will append a 'row_id' column to the dataframe + and populate with row id for each row in sheet + :type include_row_id: bool + + :param include_parent_id: If True, will append a 'parent_id' column to the + dataframe and populate with parent ID for each nested row + :type include_parent_id: bool + + :return: Pandas DataFrame with sheet data + :rtype: pd.DataFrame + """ + + if include_row_id is None: + include_row_id = self.include_row_id + if include_parent_id is None: + include_parent_id = self.include_parent_id + + return get_sheet_as_df(token=self.token, + sheet_id=sheet_id, + include_row_id=include_row_id, + include_parent_id=include_parent_id) + + def get_report_as_df(self, + report_id: int, + include_row_id: Optional[bool] = None, + include_parent_id: Optional[bool] = None) -> pd.DataFrame: + """Get a Smartsheet report as a Pandas DataFrame. + + :param report_id: Smartsheet source report id to get + :type report_id: int + + :param include_row_id: If True, will append a 'row_id' column to the dataframe + and populate with row id for each row in sheet + :type include_row_id: bool + + :param include_parent_id: If True, will append a 'parent_id' column to the + dataframe and populate with parent ID for each nested row + :type include_parent_id: bool + + :return: Pandas DataFrame with report data + :rtype: pd.DataFrame + """ + + if include_row_id is None: + include_row_id = self.include_row_id + if include_parent_id is None: + include_parent_id = self.include_parent_id + + return get_report_as_df(token=self.token, + report_id=report_id, + include_row_id=include_row_id, + include_parent_id=include_parent_id) diff --git a/src/smartsheet_dataframe/client.py b/src/smartsheet_dataframe/client.py new file mode 100644 index 0000000..4d1591f --- /dev/null +++ b/src/smartsheet_dataframe/client.py @@ -0,0 +1,87 @@ +# Standard Imports +import logging +from typing import Optional + +# 3rd-Party Imports +import pandas as pd + +# Local Imports +from .smartsheet_dataframe import ( + get_sheet_as_df, + get_report_as_df, +) + +logger = logging.getLogger(__name__) + + +class BaseClient: + __slots__ = ("token", "include_row_id", "include_parent_id") + + def __init__(self, token: str, include_row_id: bool = True, include_parent_id: bool = True): + self.token = token + self.include_row_id = include_row_id + self.include_parent_id = include_parent_id + + +class Client(BaseClient): + def get_sheet_as_df(self, + sheet_id: int, + include_row_id: Optional[bool] = None, + include_parent_id: Optional[bool] = None) -> pd.DataFrame: + """Get a Smartsheet sheet as a Pandas DataFrame. + + :param sheet_id: Smartsheet source sheet id to get + :type sheet_id: int + + :param include_row_id: If True, will append a 'row_id' column to the dataframe + and populate with row id for each row in sheet + :type include_row_id: bool + + :param include_parent_id: If True, will append a 'parent_id' column to the + dataframe and populate with parent ID for each nested row + :type include_parent_id: bool + + :return: Pandas DataFrame with sheet data + :rtype: pd.DataFrame + """ + + if include_row_id is None: + include_row_id = self.include_row_id + if include_parent_id is None: + include_parent_id = self.include_parent_id + + return get_sheet_as_df(token=self.token, + sheet_id=sheet_id, + include_row_id=include_row_id, + include_parent_id=include_parent_id) + + def get_report_as_df(self, + report_id: int, + include_row_id: Optional[bool] = None, + include_parent_id: Optional[bool] = None) -> pd.DataFrame: + """Get a Smartsheet report as a Pandas DataFrame. + + :param report_id: Smartsheet source report id to get + :type report_id: int + + :param include_row_id: If True, will append a 'row_id' column to the dataframe + and populate with row id for each row in sheet + :type include_row_id: bool + + :param include_parent_id: If True, will append a 'parent_id' column to the + dataframe and populate with parent ID for each nested row + :type include_parent_id: bool + + :return: Pandas DataFrame with report data + :rtype: pd.DataFrame + """ + + if include_row_id is None: + include_row_id = self.include_row_id + if include_parent_id is None: + include_parent_id = self.include_parent_id + + return get_report_as_df(token=self.token, + report_id=report_id, + include_row_id=include_row_id, + include_parent_id=include_parent_id) diff --git a/src/smartsheet_dataframe/exceptions.py b/src/smartsheet_dataframe/exceptions.py deleted file mode 100644 index 4dc2fd1..0000000 --- a/src/smartsheet_dataframe/exceptions.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Custom exceptions for the smartsheet_dataframe package.""" - - -class AuthenticationError(BaseException): - """Raised when the user is not authenticated.""" diff --git a/src/smartsheet_dataframe/smartsheet_dataframe.py b/src/smartsheet_dataframe/smartsheet_dataframe.py index a63da5f..c520bf4 100644 --- a/src/smartsheet_dataframe/smartsheet_dataframe.py +++ b/src/smartsheet_dataframe/smartsheet_dataframe.py @@ -6,38 +6,34 @@ # Standard Imports import logging -import time -import warnings -from typing import ( - Any, - Optional, -) +from typing import Any # 3rd-Party Imports import pandas as pd -import requests # Local Imports -from .exceptions import AuthenticationError from .utils.constants import ( REPORT, SHEET, ) +from .utils.exceptions import ( + AuthenticationError, +) +from .utils._http import _do_request logger = logging.getLogger(__name__) -def get_report_as_df(token: Optional[str] = None, - report_id: Optional[int] = None, +def get_report_as_df(token: str, + report_id: int, include_row_id: bool = True, - include_parent_id: bool = True, - report_obj: Optional[Any] = None) -> pd.DataFrame: + include_parent_id: bool = True) -> pd.DataFrame: """Get a Smartsheet report as a Pandas DataFrame. :param token: Smartsheet Personal Access Token :type token: str - :param report_id: ID of report to retrieve + :param report_id: Id of report to retrieve :type report_id: int :param include_row_id: If True, will append a 'row_id' column to the dataframe @@ -45,51 +41,28 @@ def get_report_as_df(token: Optional[str] = None, :type include_row_id: bool :param include_parent_id: If True, will append a 'parent_id' column to the - dataframe and populate with parent ID for each nested row + dataframe and populate with parent id for each nested row :type include_parent_id: bool - :param report_obj: Smartsheet Python SDK Report object - Should not be included if token and id_ are provided. - If both token and id_, and obj are provided, obj will be ignored - :type report_obj: Any - :return: Pandas DataFrame with report data :rtype: pd.DataFrame """ - if token and not report_id: - try: - import smartsheet.models # noqa: PLC0415 - if isinstance(token, smartsheet.models.sheet.Sheet): - raise ValueError("Function must be called with the 'report_obj=' keyword argument") - except ModuleNotFoundError: - pass - - raise ValueError("A report_id must be included in the parameters if a token is provided") + return to_dataframe(_get_from_request(token=token, object_id=report_id, object_type=REPORT), + include_row_id, + include_parent_id) - if report_obj and report_id: - warnings.warn("A 'report_id' has been provided along with a 'report_obj' \n" + - "The 'report_id' parameter will be ignored") - if token and report_id: - return _to_dataframe(_get_from_request(token, report_id, type_="REPORT"), include_row_id, include_parent_id) - elif report_obj: - return _to_dataframe(report_obj.to_dict(), include_row_id, include_parent_id) - else: - raise ValueError("One of 'token' or 'report_obj' must be included in parameters") - - -def get_sheet_as_df(token: Optional[str] = None, - sheet_id: Optional[int] = None, +def get_sheet_as_df(token: str, + sheet_id: int, include_row_id: bool = True, - include_parent_id: bool = True, - sheet_obj: Optional[Any] = None) -> pd.DataFrame: + include_parent_id: bool = True) -> pd.DataFrame: """Get a Smartsheet sheet as a Pandas DataFrame. :param token: Smartsheet personal authentication token :type token: str - :param sheet_id: Smartsheet source sheet ID to get + :param sheet_id: Smartsheet source sheet id to get :type sheet_id: int :param include_row_id: If True, will append a 'row_id' column to the dataframe @@ -97,131 +70,83 @@ def get_sheet_as_df(token: Optional[str] = None, :type include_row_id: bool :param include_parent_id: If True, will append a 'parent_id' column to the - dataframe and populat with parent ID for each nested row + dataframe and populate with parent id for each nested row :type include_parent_id: bool - :param sheet_obj: Smartsheet Python SDK sheet object - Should not be included if token and id_ are provided. - If both token and id_, and obj are provided, obj will be ignored - :type sheet_obj: Any - :return: Pandas DataFrame with sheet data :rtype: pd.DataFrame """ - if token and not sheet_id: - try: - import smartsheet.models # noqa: PLC0415 - if isinstance(token, smartsheet.models.sheet.Sheet): - raise ValueError("Function must be called with the 'sheet_obj=' keyword argument") - except ModuleNotFoundError: - pass + return to_dataframe(_get_from_request(token=token, object_id=sheet_id, object_type=SHEET), + include_row_id, + include_parent_id) - raise ValueError("A sheet_id must be included in the parameters if a token is provided") - if sheet_obj and sheet_id: - warnings.warn("A 'sheet_id' has been provided along with a 'sheet_obj' \n" + - "The 'sheet_id' parameter will be ignored") - - if token and sheet_id: - return _to_dataframe(_get_from_request(token, sheet_id, type_="SHEET"), include_row_id, include_parent_id) - elif sheet_obj: - return _to_dataframe(sheet_obj.to_dict(), include_row_id, include_parent_id) - else: - raise ValueError("One of 'token' or 'sheet_obj' must be included in parameters") - - -def get_as_df(type_: str, - token: Optional[str] = None, - id_: Optional[int] = None, - obj: Optional[Any] = None, +def get_as_df(token: str, + object_type: str, + object_id: int, include_row_id: bool = True, include_parent_id: bool = True) -> pd.DataFrame: """Get a Smartsheet report or sheet as a Pandas DataFrame. - :param type_: type of object to get. Must be one of 'report' or 'sheet' - :type type_: str - :param token: Smartsheet personal authentication token :type token: str - :param id_: Smartsheet object (report or sheet) ID - :type id_: int + :param object_type: type of object to get. Must be one of 'report' or 'sheet' + :type object_type: str - :param obj: Smartsheet Python SDK report or sheet object - Should not be included if token and id_ are provided. - If both token and id_, and obj are provided, obj will be ignored - :type obj: Any + :param object_id: Smartsheet object (report or sheet) id + :type object_id: int :param include_row_id: If True, will append a 'row_id' column to the dataframe and populate with row id for each row in sheet :type include_row_id: bool :param include_parent_id: If True, will append a 'parent_id' column to the - dataframe and populate with parent ID for each nested row + dataframe and populate with parent id for each nested row :type include_parent_id: bool :return: Pandas DataFrame with object data :rtype: pd.DataFrame """ - if not (token or obj): - raise ValueError("One of 'token' or 'obj' must be included in parameters") - - if token and not id_: - try: - import smartsheet.models # noqa: PLC0415 - if isinstance(token, smartsheet.models.sheet.Sheet): - raise ValueError("Function must be called with the 'sheet_obj=' keyword argument") - except ModuleNotFoundError: - pass - - raise ValueError("A sheet_id must be included in the parameters if a token is provided") + return to_dataframe(_get_from_request(token=token, object_id=object_id, object_type=object_type), + include_row_id, + include_parent_id) - if obj and id_: - warnings.warn("An 'id' has been provided along with a 'obj' \n" + - "The 'id' parameter will be ignored") - if token and id_: - return _to_dataframe(_get_from_request(token, id_, type_), include_row_id, include_parent_id) - elif obj: - return _to_dataframe(obj.to_dict(), include_row_id, include_parent_id) - else: - raise ValueError("One of 'token' or 'obj' must be included in parameters") - - -def _get_from_request(token: str, id_: int, type_: str) -> dict: +def _get_from_request(token: str, + object_type: str, + object_id: int) -> dict: """Get a Smartsheet object from the API via HTTP request. :param token: Smartsheet personal authentication token :type token: str - :param id_: Smartsheet object (report or sheet) ID - :type id_: int + :param object_id: Smartsheet object (report or sheet) id + :type object_id: int - :param type_: type of object to get. Must be one of 'REPORT' or 'SHEET' - :type type_: str + :param object_type: type of object to get. Must be one of 'REPORT' or 'SHEET' + :type object_type: str :return: Smartsheet sheet or report object dictionary :rtype: dict """ - if str(type_).upper() not in (SHEET, REPORT): - raise ValueError(f"'type_' parameter must be one of SHEET or REPORT. The current value is '{type_.upper()}'") - - if type_.upper() == "SHEET": - url = f"https://api.smartsheet.com/2.0/sheets/{id_}?include=objectValue&level=1" - logger.debug("Getting sheet request", extra={"id": id_, - "url": url, - "object_type": "sheet"}) - elif type_.upper() == "REPORT": - url = f"https://api.smartsheet.com/2.0/reports/{id_}?pageSize=50000" - logger.debug("Getting report request", extra={"id": id_, - "url": url, - "object_Type": "report"}) + if str(object_type).upper() not in (SHEET, REPORT): + raise ValueError( + f"'object_type' parameter must be one of SHEET or REPORT. The current value is '{object_type.upper()}'") + + if object_type.upper() == SHEET: + url = f"https://api.smartsheet.com/2.0/sheets/{object_id}?include=objectValue&level=1" + logger.debug("Getting sheet request", extra={"id": object_id, "url": url, "object_type": SHEET}) + elif object_type.upper() == REPORT: + url = f"https://api.smartsheet.com/2.0/reports/{object_id}?pageSize=50000" + logger.debug("Getting report request", extra={"id": object_id, "url": url, "object_Type": REPORT}) else: # Leaving for type checking purposes - raise ValueError(f"'type_' parameter must be one of SHEET or REPORT. The current value is '{type_.upper()}'") + raise ValueError( + f"'object_type' parameter must be one of SHEET or REPORT. The current value is '{object_type.upper()}'") credentials: dict = {"Authorization": f"Bearer {token}"} response = _do_request(url, options=credentials) @@ -229,9 +154,9 @@ def _get_from_request(token: str, id_: int, type_: str) -> dict: return response.json() -def _to_dataframe(object_dict: dict, - include_row_id: bool = True, - include_parent_id: bool = True) -> pd.DataFrame: +def to_dataframe(object_dict: dict, + include_row_id: bool = True, + include_parent_id: bool = True) -> pd.DataFrame: """Convert a Smartsheet object dictionary to a Pandas DataFrame. :param object_dict: Smartsheet object dictionary @@ -242,7 +167,7 @@ def _to_dataframe(object_dict: dict, :type include_row_id: bool :param include_parent_id: If True, will append a 'parent_id' column to the - dataframe and populate with parent ID for each nested row + dataframe and populate with parent id for each nested row :type include_parent_id: bool :return: Pandas DataFrame with object data @@ -282,56 +207,6 @@ def _to_dataframe(object_dict: dict, return pd.DataFrame(rows_list, columns=columns_list) # pyright: ignore -def _do_request(url: str, options: dict, retries: int = 3) -> requests.Response: - """Do the HTTP request, handling rate limit retrying. - - :param url: Smartsheet API URL - :type url: str - - :param options: API request headers - :type options: dict - - :param retries: Number of retries - :type retries: int - - :return: Requests response object - :rtype: requests.Response - """ - - i = 0 - for i in range(retries): - try: - response = requests.get(url, headers=options) - response_json = response.json() - - if response.status_code != 200: - if response_json["errorCode"] in (1002, 1003, 1004): - raise AuthenticationError("Could not connect using the supplied auth token \n" + - response.text) - elif response_json["errorCode"] == 4004: - logger.debug(f"Rate limit exceeded. Waiting and trying again... {i}") - time.sleep(5 + (i * 5)) - continue - else: - warnings.warn("An unhandled status_code was returned by the Smartsheet API: \n" + - response.text) - return # TODO: Fix reportReturnType - except AuthenticationError: - logger.exception("Smartsheet returned an error status code") - # TODO: For 1.0 release, ensure that this is re-raised - break - except Exception: - logger.exception(f"Not able to retrieve get response. Retrying... {i}") - time.sleep(5 + (i * 5)) - continue - break - else: - # TODO: For 1.0 release, re-raise exception - raise Exception(f"Could not retrieve request after retrying {i} times") - - return response # TODO: Fix reportPossiblyUnboundVariable - - def _handle_object_value(object_value: dict) -> str: """Handle Smartsheet objectValue cell types. diff --git a/src/smartsheet_dataframe/utils/_http.py b/src/smartsheet_dataframe/utils/_http.py new file mode 100644 index 0000000..153b869 --- /dev/null +++ b/src/smartsheet_dataframe/utils/_http.py @@ -0,0 +1,112 @@ +# Standard Imports +import logging +import time +import warnings +import asyncio + +# 3rd-Party Imports +import httpx + +# Local Imports +from .exceptions import AuthenticationError + +logger = logging.getLogger(__name__) + + +def _do_request(url: str, options: dict, retries: int = 3) -> httpx.Response: + """Do the HTTP request, handling rate limit retrying. + + :param url: Smartsheet API URL + :type url: str + + :param options: API request headers + :type options: dict + + :param retries: Number of retries + :type retries: int + + :return: httpx Response object + :rtype: httpx.Response + """ + + i = 0 + response: httpx.Response | None = None + + for i in range(retries): + try: + # Use httpx to perform a simple GET. Keep timeout modest to avoid hanging. + response = httpx.get(url, headers=options, timeout=30.0) + + # Attempt to parse JSON (tests use mocked .json()) + response_json = response.json() + + if response.status_code != 200: + if response_json["errorCode"] in (1002, 1003, 1004): + raise AuthenticationError("Could not connect using the supplied auth token \n" + + response.text) + elif response_json["errorCode"] == 4004: + logger.debug(f"Rate limit exceeded. Waiting and trying again... {i}") + time.sleep(5 + (i * 5)) + continue + else: + warnings.warn("An unhandled status_code was returned by the Smartsheet API: \n" + response.text) + return response + except AuthenticationError: + logger.exception("Smartsheet returned an error status code") + # TODO: For 1.0 release, ensure that this is re-raised + break + except Exception: + logger.exception(f"Not able to retrieve get response. Retrying... {i}") + time.sleep(5 + (i * 5)) + continue + break + else: + # TODO: For 1.0 release, re-raise exception + raise Exception(f"Could not retrieve request after retrying {i} times") + + return response # TODO: Fix reportPossiblyUnboundVariable and reportReturnType + + +# New async counterpart to support asynchronous callers. +async def _async_do_request(url: str, options: dict, retries: int = 3) -> httpx.Response: + """Asynchronous version of _do_request using httpx.AsyncClient and asyncio.sleep. + + Behavior mirrors the synchronous function: retries on errors, handles auth error codes + and rate-limit errorCode 4004 with backoff. + """ + + i = 0 + response: httpx.Response | None = None + + for i in range(retries): + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, headers=options) + + # Parse JSON (tests may rely on mocked .json()) + response_json = response.json() + + if response.status_code != 200: + if isinstance(response_json, dict) and response_json.get("errorCode") in (1002, 1003, 1004): + raise AuthenticationError("Could not connect using the supplied auth token \n" + response.text) + elif isinstance(response_json, dict) and response_json.get("errorCode") == 4004: + logger.debug(f"Rate limit exceeded. Waiting and trying again... {i}") + await asyncio.sleep(5 + (i * 5)) + continue + else: + warnings.warn("An unhandled status_code was returned by the Smartsheet API: \n" + response.text) + return response + except AuthenticationError: + logger.exception("Smartsheet returned an error status code") + # TODO: For 1.0 release, ensure that this is re-raised + break + except Exception: + logger.exception(f"Not able to retrieve get response. Retrying... {i}") + await asyncio.sleep(5 + (i * 5)) + continue + break + else: + # TODO: For 1.0 release, re-raise exception + raise Exception(f"Could not retrieve request after retrying {i} times") + + return response diff --git a/src/smartsheet_dataframe/utils/exceptions.py b/src/smartsheet_dataframe/utils/exceptions.py new file mode 100644 index 0000000..a019416 --- /dev/null +++ b/src/smartsheet_dataframe/utils/exceptions.py @@ -0,0 +1,80 @@ +"""Custom exceptions for the smartsheet_dataframe package.""" + +# Standard Imports +from typing import Optional + + +class SmartsheetAPIError(BaseException): + """Raised when there is an error with the Smartsheet API.""" + + def __init__(self, status_code: int, error_code: int, message: str) -> None: + """ + + :param status_code: HTTP status code returned by the API + :type status_code: int + + :param error_code: Smartsheet-specific error code + :type error_code: int + + :param message: Error message returned by the API + :type message: str + """ + + self.status_code = status_code + self.error_code = error_code + self.message = message + + super().__init__(f"Smartsheet API Error [{status_code}] ({error_code}) {message}") + + +class AuthenticationError(SmartsheetAPIError): + """Raised when the user is not authenticated.""" + + +class AuthorizationError(SmartsheetAPIError): + """Raised when the user is authenticated, but not authorized to perform an action.""" + + +class InvalidAccessTokenError(AuthenticationError): + """Raised when the provided access token is invalid.""" + + def __init__(self, message: Optional[str] = None) -> None: + """Initialize the InvalidAccessTokenError class. + + `status_code` and `error_code` are hardcoded to 401 and 1002 respectively + per https://developers.smartsheet.com/api/smartsheet/error-codes. + + :param message: Optional custom error message. + Defaults to "Invalid Access Token" if not provided. + :type message: Optional[str] + """ + + if message is None: + message = "Invalid Access Token" + + super().__init__(status_code=401, error_code=1002, message=message) + + +class RateLimitExceededError(SmartsheetAPIError): + """Raised when the Smartsheet API rate limit has been exceeded.""" + + def __init__(self, status_code: int, error_code: int, message: str, retries: int) -> None: + """Initialize the RateLimitExceededError class. + + :param status_code: HTTP status code returned by the API + :type status_code: int + + :param error_code: Smartsheet-specific error code + :type error_code: int + + :param message: Error message returned by the API + :type message: str + + :param retries: Number of retries attempted + :type retries: int + """ + + self.retries = retries + self.message = f"{message} after {retries} retries." + + super().__init__(status_code=status_code, error_code=error_code, message=self.message) diff --git a/tests/test_aio/__init__.py b/tests/test_aio/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_aio/client.py b/tests/test_aio/client.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..3d9758a --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,78 @@ +# Standard Imports +from unittest.mock import patch + +# 3rd-Party Imports +import pytest + +# Local Imports +from smartsheet_dataframe import Client + + +@pytest.fixture +def client() -> Client: + return Client(token="test_token", include_row_id=True, include_parent_id=True) + + +class TestClient: + @pytest.mark.parametrize("include_row_id", [True, False]) + @pytest.mark.parametrize("include_parent_id", [True, False]) + def test_create_client(self, include_row_id: bool, include_parent_id: bool): + client = Client(token="fake_token", + include_row_id=include_row_id, + include_parent_id=include_parent_id) + + assert client.token == "fake_token" + assert client.include_row_id is include_row_id + assert client.include_parent_id is include_parent_id + + @patch("smartsheet_dataframe.client.get_sheet_as_df") + @pytest.mark.parametrize("include_parent_id", [None, True, False]) + @pytest.mark.parametrize("include_row_id", [None, True, False]) + def test_get_sheet_as_df(self, mock_get_sheet_as_df, client: Client, include_row_id, include_parent_id): + mock_get_sheet_as_df.return_value = "mocked_dataframe" + + expected_include_row_id = include_row_id + if expected_include_row_id is None: + expected_include_row_id = client.include_row_id + + expected_include_parent_id = include_parent_id + if expected_include_parent_id is None: + expected_include_parent_id = client.include_parent_id + + kwargs = {} + if include_row_id is not None: + kwargs["include_row_id"] = include_row_id + if include_parent_id is not None: + kwargs["include_parent_id"] = include_parent_id + + client.get_sheet_as_df(sheet_id=1234, **kwargs) + + assert mock_get_sheet_as_df.call_count == 1 + assert mock_get_sheet_as_df.call_args.kwargs["include_row_id"] == expected_include_row_id + assert mock_get_sheet_as_df.call_args.kwargs["include_parent_id"] == expected_include_parent_id + + @patch("smartsheet_dataframe.client.get_report_as_df") + @pytest.mark.parametrize("include_parent_id", [None, True, False]) + @pytest.mark.parametrize("include_row_id", [None, True, False]) + def test_get_report_as_df(self, mock_get_report_as_df, client: Client, include_row_id, include_parent_id): + mock_get_report_as_df.return_value = "mocked_dataframe" + + expected_include_row_id = include_row_id + if expected_include_row_id is None: + expected_include_row_id = client.include_row_id + + expected_include_parent_id = include_parent_id + if expected_include_parent_id is None: + expected_include_parent_id = client.include_parent_id + + kwargs = {} + if include_row_id is not None: + kwargs["include_row_id"] = include_row_id + if include_parent_id is not None: + kwargs["include_parent_id"] = include_parent_id + + client.get_report_as_df(report_id=5678, **kwargs) + + assert mock_get_report_as_df.call_count == 1 + assert mock_get_report_as_df.call_args.kwargs["include_row_id"] == expected_include_row_id + assert mock_get_report_as_df.call_args.kwargs["include_parent_id"] == expected_include_parent_id diff --git a/tests/test_smartsheet_dataframe.py b/tests/test_smartsheet_dataframe.py index a52dc19..1d20e63 100644 --- a/tests/test_smartsheet_dataframe.py +++ b/tests/test_smartsheet_dataframe.py @@ -1,8 +1,5 @@ # Standard Imports -import builtins import os -import sys -from json import load from unittest.mock import ( patch, Mock @@ -11,7 +8,6 @@ # 3rd-Party Imports import pandas as pd import pytest -import smartsheet from dotenv import load_dotenv # Local Imports @@ -20,19 +16,18 @@ get_sheet_as_df, get_as_df, ) -from smartsheet_dataframe.exceptions import ( - AuthenticationError, -) from smartsheet_dataframe.smartsheet_dataframe import ( - _do_request, _get_from_request, _handle_object_value, - _to_dataframe, + to_dataframe, ) from smartsheet_dataframe.utils.constants import ( REPORT, SHEET, ) +from smartsheet_dataframe.utils.exceptions import ( + AuthenticationError, +) load_dotenv() @@ -40,26 +35,20 @@ @pytest.mark.skipif(str(os.environ.get("SKIP_LIVE_TESTS", "1")) == "1", reason="Not testing live API calls at this time") class TestSheet: - def test_df_has_all_rows__api(self, smartsheet_access_token: str, sheet_id: int): + def test_df_has_all_rows(self, smartsheet_access_token: str, sheet_id: int): df = get_sheet_as_df(token=smartsheet_access_token, sheet_id=sheet_id) assert len(df.index) > 100 def test_object_and_request_are_equal(self, smartsheet_access_token: str, sheet_id: int, sheet): df1 = get_sheet_as_df(token=smartsheet_access_token, sheet_id=sheet_id) - df2 = get_sheet_as_df(sheet_obj=sheet) + df2 = to_dataframe(object_dict=sheet.to_dict()) assert df1.to_dict() == df2.to_dict() def test_generic_vs_specific_requests(self, smartsheet_access_token: str, sheet_id: int): df1 = get_sheet_as_df(token=smartsheet_access_token, sheet_id=sheet_id) - df2 = get_as_df(type_='sheet', token=smartsheet_access_token, id_=sheet_id) - - assert df1.to_dict() == df2.to_dict() - - def test_generic_vs_specific_object(self, sheet): - df1 = get_sheet_as_df(sheet_obj=sheet) - df2 = get_as_df(type_='sheet', obj=sheet) + df2 = get_as_df(object_type='sheet', token=smartsheet_access_token, object_id=sheet_id) assert df1.to_dict() == df2.to_dict() @@ -67,21 +56,20 @@ def test_generic_vs_specific_object(self, sheet): @pytest.mark.skipif(str(os.environ.get("SKIP_LIVE_TESTS", "1")) == "1", reason="Not testing live API calls at this time") class TestReport: + def test_df_has_all_rows(self, smartsheet_access_token: str, report_id: int): + df = get_report_as_df(token=smartsheet_access_token, report_id=report_id) + + assert len(df.index) > 100 + def test_report_object_and_request_are_equal(self, smartsheet_access_token: str, report_id: int, report): df1 = get_report_as_df(token=smartsheet_access_token, report_id=report_id) - df2 = get_report_as_df(report_obj=report) + df2 = to_dataframe(object_dict=report.to_dict()) assert df1.to_dict() == df2.to_dict() def test_generic_vs_specific_requests(self, smartsheet_access_token: str, report_id: int): df1 = get_report_as_df(token=smartsheet_access_token, report_id=report_id) - df2 = get_as_df(type_='report', token=smartsheet_access_token, id_=report_id) - - assert df1.to_dict() == df2.to_dict() - - def test_generic_vs_specific_object(self, report): - df1 = get_report_as_df(report_obj=report) - df2 = get_as_df(type_='report', obj=report) + df2 = get_as_df(object_type='report', token=smartsheet_access_token, object_id=report_id) assert df1.to_dict() == df2.to_dict() @@ -102,35 +90,6 @@ def test_get_report_as_df_with_token_and_report_id(self, mock_get_from_request): assert "Column2" in df.columns assert df.loc[0, "Column1"] == "Value1" - @patch('warnings.warn') - @patch('smartsheet_dataframe.smartsheet_dataframe._get_from_request') - def test_get_report_as_df_with_both_token_and_report_obj(self, mock_get_from_request, mock_warn): - mock_response = { - "columns": [{"title": "Column1"}, {"title": "Column2"}], - "rows": [{"id": 1, "cells": [{"value": "Value1"}, {"value": "Value2"}]}] - } - mock_get_from_request.return_value = mock_response - - mock_report_obj = Mock() - mock_report_obj.to_dict.return_value = mock_response - - df = get_report_as_df(token="fake_token", report_id=12345, report_obj=mock_report_obj) - - mock_warn.assert_called_with("A 'report_id' has been provided along with a 'report_obj' \n" + - "The 'report_id' parameter will be ignored") - - def test_get_report_as_df_without_token_or_report_obj(self): - with pytest.raises(ValueError): - get_report_as_df() - - def test_get_report_as_df_token_without_report_id_but_token_is_report_obj(self): - with pytest.raises(ValueError): - get_report_as_df(token=smartsheet.models.Report()) # type: ignore - - def test_get_report_as_df_token_without_report_id(self): - with pytest.raises(ValueError): - get_report_as_df(token="test") - class TestGetSheetAsDf: @patch('smartsheet_dataframe.smartsheet_dataframe._get_from_request') @@ -148,187 +107,9 @@ def test_sheet_id__with_token(self, mock_get_from_request): assert "Column2" in df.columns assert df.loc[0, "Column1"] in "Value1" - @patch('smartsheet_dataframe.smartsheet_dataframe._get_from_request') - def test_sheet_obj(self, mock_get_from_request): - mock_response = { - "columns": [{"title": "Column1"}, {"title": "Column2"}], - "rows": [{"id": 1, "cells": [{"value": "Value1"}, {"value": "Value2"}]}] - } - mock_get_from_request.return_value = mock_response - - mock_sheet_obj = Mock() - mock_sheet_obj.to_dict.return_value = mock_response - - df = get_sheet_as_df(sheet_obj=mock_sheet_obj) - - assert isinstance(df, pd.DataFrame) - assert "Column1" in df.columns - assert "Column2" in df.columns - assert df.loc[0, "Column1"] in "Value1" - - @patch('warnings.warn') - @patch('smartsheet_dataframe.smartsheet_dataframe._get_from_request') - def test_sheet_id_and_obj__with_token(self, mock_get_from_request, mock_warn): - """Ensure that a warning is raised if both sheet_id and sheet_obj are provided - and that the sheet_id is ignored.""" - - mock_response = { - "columns": [{"title": "Column1"}, {"title": "Column2"}], - "rows": [{"id": 1, "cells": [{"value": "Value1"}, {"value": "Value2"}]}] - } - mock_get_from_request.return_value = mock_response - - mock_sheet_obj = Mock() - mock_sheet_obj.to_dict.return_value = mock_response - - df = get_sheet_as_df(token="fake_token", sheet_id=12345, sheet_obj=mock_sheet_obj) - - mock_warn.assert_called_with("A 'sheet_id' has been provided along with a 'sheet_obj' \n" + - "The 'sheet_id' parameter will be ignored") - - assert isinstance(df, pd.DataFrame) - assert "Column1" in df.columns - assert "Column2" in df.columns - assert df.loc[0, "Column1"] in "Value1" - - @patch.dict(sys.modules, {'smartsheet': None}) - def test_sheet_obj__with_token__missing_smartsheet_import(self): - with pytest.raises(ValueError) as e: - get_sheet_as_df(token="fake_token", sheet_obj=smartsheet.models.Sheet()) - - assert "A sheet_id must be included in the parameters if a token is provided" in str(e.value) - - def test_sheet_obj_with_token(self): - with pytest.raises(ValueError) as e: - get_sheet_as_df(token="fake_token", sheet_obj=smartsheet.models.Sheet()) - - assert "A sheet_id must be included in the parameters if a token is provided" in str(e.value) - - def test_without_token_or_sheet_obj(self): - with pytest.raises(ValueError) as e: - get_sheet_as_df() - - assert "One of 'token' or 'sheet_obj' must be included in parameters" in str(e.value) - - def test_token__without_sheet_id_but_token_is_sheet_obj(self): - with pytest.raises(ValueError) as e: - get_sheet_as_df(token=smartsheet.models.Sheet()) # type: ignore - - assert "Function must be called with the 'sheet_obj=' keyword argument" in str(e.value) - - def test_token__without_sheet_id(self): - with pytest.raises(ValueError) as e: - get_sheet_as_df(token="test") - - assert "A sheet_id must be included in the parameters if a token is provided" in str(e.value) - class TestGetAsDf: - - def test_get_as_df_with_report_obj(self): - mock_report_obj = Mock() - mock_report_obj.to_dict.return_value = { - "columns": [{"title": "Column1"}, {"title": "Column2"}], - "rows": [{"id": 1, "cells": [{"value": "Value1"}, {"value": "Value2"}]}] - } - - df = get_as_df(type_="report", obj=mock_report_obj) - - assert isinstance(df, pd.DataFrame) - assert "Column1" in df.columns - assert "Column2" in df.columns - assert df.loc[0, "Column1"] == "Value1" - - def test_get_as_df_with_sheet_obj(self): - mock_sheet_obj = Mock() - mock_sheet_obj.to_dict.return_value = { - "columns": [{"title": "Column1"}, {"title": "Column2"}], - "rows": [{"id": 1, "cells": [{"value": "Value1"}, {"value": "Value2"}]}] - } - - df = get_as_df(type_="sheet", obj=mock_sheet_obj) - - assert isinstance(df, pd.DataFrame) - assert "Column1" in df.columns - assert "Column2" in df.columns - assert df.loc[0, "Column1"] == "Value1" - - def test_get_as_df_without_token_or_obj(self): - with pytest.raises(ValueError): - get_as_df(type_="test") - - def test_get_as_df_token_without_id(self): - with pytest.raises(ValueError): - get_as_df(type_="test", token="test") - - -class TestDoRequest: - - @patch('smartsheet_dataframe.smartsheet_dataframe.requests.get') - def test_do_request_success(self, mock_get): - mock_response = Mock() - mock_response.status_code = 200 - mock_response.json.return_value = {"data": "some_data"} - mock_get.return_value = mock_response - - response = _do_request(url="https://fakeurl.com", options={}) - - assert response.json() == {"data": "some_data"} - - @patch("smartsheet_dataframe.smartsheet_dataframe.time") - @patch('smartsheet_dataframe.smartsheet_dataframe.requests.get') - def test_do_request_rate_limit(self, mock_get, mock_time): - mock_time.sleep.return_value = None - - mock_response_rate_limit = Mock() - mock_response_rate_limit.status_code = 429 - mock_response_rate_limit.json.return_value = {"errorCode": 4004} - - mock_response_success = Mock() - mock_response_success.status_code = 200 - mock_response_success.json.return_value = {"data": "some_data"} - - mock_get.side_effect = [mock_response_rate_limit] * 3 + [mock_response_success] - - response = _do_request(url="https://fakeurl.com", options={}, retries=4) - - assert response.json() == {"data": "some_data"} - - @patch("smartsheet_dataframe.smartsheet_dataframe.time") - @patch('smartsheet_dataframe.smartsheet_dataframe.requests.get') - def test_do_request_rate_limit_failure(self, mock_get, mock_time): - mock_time.sleep.return_value = None - - mock_response_rate_limit = Mock() - mock_response_rate_limit.status_code = 429 - mock_response_rate_limit.json.return_value = {"errorCode": 4004} - - mock_get.return_value = mock_response_rate_limit - - with pytest.raises(Exception) as e: - _do_request(url="https://fakeurl.com", options={}, retries=3) - - assert 'Could not retrieve request after retrying' in str(e.value) - - @patch('smartsheet_dataframe.smartsheet_dataframe.requests.get') - @pytest.mark.parametrize("error_code", [1002, 1003, 1004]) - def test_do_request_auth_failure(self, mock_get, error_code, caplog): - mock_response = Mock() - mock_response.status_code = 401 - mock_response.json.return_value = {"errorCode": error_code} - mock_response.text = "Test authentication failure message" - mock_get.return_value = mock_response - - # Uncomment for 1.0 release - # with pytest.raises(AuthenticationError, match="auth") as e: - # _do_request(url="https://fakeurl.com", options={}) - # assert "Could not connect using the supplied auth token" in str(e.value) - - _do_request(url="https://fakeurl.com", options={}) - - assert mock_get.call_count == 1 - assert caplog.records[-1].levelname == "ERROR" - assert "Smartsheet returned an error status code" in caplog.text + """ """ class TestToDataFrame: @@ -338,7 +119,7 @@ def test_to_dataframe_empty_sheet(self): "rows": [] } - df = _to_dataframe(mock_object_dict) + df = to_dataframe(mock_object_dict) assert isinstance(df, pd.DataFrame) assert df.empty is True @@ -351,7 +132,7 @@ def test_to_dataframe_with_data(self): "rows": [{"id": 1, "parentId": 0, "cells": [{"value": "Value1"}, {"value": "Value2"}]}] } - df = _to_dataframe(mock_object_dict) + df = to_dataframe(mock_object_dict) assert isinstance(df, pd.DataFrame) assert "Column1" in df.columns @@ -367,17 +148,17 @@ def test_report_live(self, smartsheet_access_token: str, report_id: int): """ Ensure that a report can be retrieved. """ response_json = _get_from_request(token=smartsheet_access_token, - id_=report_id, - type_="REPORT") + object_id=report_id, + object_type="REPORT") assert response_json is not None assert isinstance(response_json, dict) def test_unknown_type_raises(self): - """ Ensure that an unknown "type_" argument raises an exception. """ + """ Ensure that an unknown "object_type" argument raises an exception. """ with pytest.raises(ValueError) as e: - _get_from_request(token="fake_token", id_=1234, type_="UNKNOWN") + _get_from_request(token="fake_token", object_id=1234, object_type="UNKNOWN") assert "parameter must be one of SHEET or REPORT" in str(e.value) @@ -391,7 +172,7 @@ def test_known_types_do_not_raise(self, mock_do_request, object_type): mock_do_request.return_value = MockResponse - response_json = _get_from_request(token="fake_token", id_=1234, type_=object_type) + response_json = _get_from_request(token="fake_token", object_id=1234, object_type=object_type) assert response_json is not None assert isinstance(response_json, dict) diff --git a/tests/test_utils/test_exceptions.py b/tests/test_utils/test_exceptions.py new file mode 100644 index 0000000..70efb63 --- /dev/null +++ b/tests/test_utils/test_exceptions.py @@ -0,0 +1,80 @@ +# Local Imports +from typing import Optional + +# 3rd-Party Imports +import pytest + +# Local Imports +from smartsheet_dataframe.utils.exceptions import ( + AuthenticationError, + AuthorizationError, + InvalidAccessTokenError, + RateLimitExceededError, + SmartsheetAPIError, +) + + +class TestSmartsheetAPIError: + def test_exception_message(self): + exc = SmartsheetAPIError(status_code=400, error_code=1001, message="Bad Request") + + assert str(exc) == "Smartsheet API Error [400] (1001) Bad Request" + assert exc.status_code == 400 + assert exc.error_code == 1001 + assert exc.message == "Bad Request" + + +class TestAuthenticationError: + def test_inherits_from_smartsheet_api_error(self): + exc = AuthenticationError(status_code=401, error_code=2001, message="Unauthorized") + + assert isinstance(exc, SmartsheetAPIError) + assert issubclass(AuthenticationError, SmartsheetAPIError) + assert str(exc) == "Smartsheet API Error [401] (2001) Unauthorized" + assert exc.status_code == 401 + assert exc.error_code == 2001 + assert exc.message == "Unauthorized" + + +class TestAuthorizationError: + def test_inherits_from_smartsheet_api_error(self): + exc = AuthorizationError(status_code=403, error_code=3001, message="Forbidden") + + assert isinstance(exc, SmartsheetAPIError) + assert issubclass(AuthorizationError, SmartsheetAPIError) + assert str(exc) == "Smartsheet API Error [403] (3001) Forbidden" + assert exc.status_code == 403 + assert exc.error_code == 3001 + assert exc.message == "Forbidden" + + +class TestInvalidAccessTokenError: + @pytest.mark.parametrize("custom_message", [None, "Custom invalid token message"]) + def test_inherits_from_authentication_error(self, custom_message: Optional[str]): + exc = InvalidAccessTokenError(message=custom_message) + + assert isinstance(exc, AuthenticationError) + assert isinstance(exc, SmartsheetAPIError) + assert issubclass(InvalidAccessTokenError, AuthenticationError) + assert issubclass(InvalidAccessTokenError, SmartsheetAPIError) + assert exc.status_code == 401 + assert exc.error_code == 1002 + + if custom_message is not None: + assert exc.message == custom_message + assert str(exc) == f"Smartsheet API Error [401] (1002) {custom_message}" + else: + assert exc.message == "Invalid Access Token" + assert str(exc) == "Smartsheet API Error [401] (1002) Invalid Access Token" + + +class TestRateLimitExceededError: + def test_inherits_from_smartsheet_api_error(self): + exc = RateLimitExceededError(status_code=429, error_code=4004, message="Rate limit exceeded", retries=3) + + assert isinstance(exc, SmartsheetAPIError) + assert issubclass(RateLimitExceededError, SmartsheetAPIError) + assert str(exc) == "Smartsheet API Error [429] (4004) Rate limit exceeded after 3 retries." + assert exc.status_code == 429 + assert exc.error_code == 4004 + assert exc.message == "Rate limit exceeded after 3 retries." diff --git a/tests/test_utils/test_http.py b/tests/test_utils/test_http.py new file mode 100644 index 0000000..efc7c82 --- /dev/null +++ b/tests/test_utils/test_http.py @@ -0,0 +1,82 @@ +# Standard Imports +from unittest.mock import ( + patch, + Mock, +) + +# 3rd-Party Imports +import pytest + +# Local Imports +from smartsheet_dataframe.utils._http import ( + _async_do_request, + _do_request, +) + + +class TestDoRequest: + @patch('smartsheet_dataframe.utils.http.httpx.get') + def test_do_request_success(self, mock_get): + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"data": "some_data"} + mock_get.return_value = mock_response + + response = _do_request(url="https://fakeurl.com", options={}) + + assert response.json() == {"data": "some_data"} + + @patch("smartsheet_dataframe.utils.http.time") + @patch('smartsheet_dataframe.utils.http.httpx.get') + def test_do_request_rate_limit(self, mock_get, mock_time): + mock_time.sleep.return_value = None + + mock_response_rate_limit = Mock() + mock_response_rate_limit.status_code = 429 + mock_response_rate_limit.json.return_value = {"errorCode": 4004} + + mock_response_success = Mock() + mock_response_success.status_code = 200 + mock_response_success.json.return_value = {"data": "some_data"} + + mock_get.side_effect = [mock_response_rate_limit] * 3 + [mock_response_success] + + response = _do_request(url="https://fakeurl.com", options={}, retries=4) + + assert response.json() == {"data": "some_data"} + + @patch("smartsheet_dataframe.utils.http.time") + @patch('smartsheet_dataframe.utils.http.httpx.get') + def test_do_request_rate_limit_failure(self, mock_get, mock_time): + mock_time.sleep.return_value = None + + mock_response_rate_limit = Mock() + mock_response_rate_limit.status_code = 429 + mock_response_rate_limit.json.return_value = {"errorCode": 4004} + + mock_get.return_value = mock_response_rate_limit + + with pytest.raises(Exception) as e: + _do_request(url="https://fakeurl.com", options={}, retries=3) + + assert 'Could not retrieve request after retrying' in str(e.value) + + @patch('smartsheet_dataframe.utils.http.httpx.get') + @pytest.mark.parametrize("error_code", [1002, 1003, 1004]) + def test_do_request_auth_failure(self, mock_get, error_code, caplog): + mock_response = Mock() + mock_response.status_code = 401 + mock_response.json.return_value = {"errorCode": error_code} + mock_response.text = "Test authentication failure message" + mock_get.return_value = mock_response + + # Uncomment for 1.0 release + # with pytest.raises(AuthenticationError, match="auth") as e: + # _do_request(url="https://fakeurl.com", options={}) + # assert "Could not connect using the supplied auth token" in str(e.value) + + _do_request(url="https://fakeurl.com", options={}) + + assert mock_get.call_count == 1 + assert caplog.records[-1].levelname == "ERROR" + assert "Smartsheet returned an error status code" in caplog.text diff --git a/uv.lock b/uv.lock index 51a79e2..f12177d 100644 --- a/uv.lock +++ b/uv.lock @@ -15,6 +15,94 @@ resolution-markers = [ "python_full_version < '3.6.8'", ] +[[package]] +name = "anyio" +version = "3.6.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.6.8' and python_full_version < '3.7'", + "python_full_version < '3.6.8'", +] +dependencies = [ + { name = "contextvars", marker = "python_full_version < '3.7'" }, + { name = "dataclasses", marker = "python_full_version < '3.7'" }, + { name = "idna", marker = "python_full_version < '3.7'" }, + { name = "sniffio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, + { name = "typing-extensions", version = "4.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/94/6928d4345f2bc1beecbff03325cad43d320717f51ab74ab5a571324f4f5a/anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421", size = 140378, upload-time = "2022-10-19T10:08:34.564Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/2b/b4c0b7a3f3d61adb1a1e0b78f90a94e2b6162a043880704b7437ef297cad/anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3", size = 80622, upload-time = "2022-10-19T10:08:32.354Z" }, +] + +[[package]] +name = "anyio" +version = "3.7.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine != 'aarch64' and platform_machine != 'arm64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'aarch64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'arm64'", + "python_full_version >= '3.7' and python_full_version < '3.7.1'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version == '3.7.*'" }, + { name = "idna", marker = "python_full_version == '3.7.*'" }, + { name = "sniffio", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/99/2dfd53fd55ce9838e6ff2d4dac20ce58263798bd1a0dbe18b3a9af3fcfce/anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780", size = 142927, upload-time = "2023-07-05T16:45:02.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/24/44299477fe7dcc9cb58d0a57d5a7588d6af2ff403fdd2d47a246c91a3246/anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5", size = 80896, upload-time = "2023-07-05T16:44:59.805Z" }, +] + +[[package]] +name = "anyio" +version = "4.5.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version == '3.8.*'" }, + { name = "idna", marker = "python_full_version == '3.8.*'" }, + { name = "sniffio", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/f9/9a7ce600ebe7804daf90d4d48b1c0510a4561ddce43a596be46676f82343/anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b", size = 171293, upload-time = "2024-10-13T22:18:03.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/b4/f7e396030e3b11394436358ca258a81d6010106582422f23443c16ca1873/anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f", size = 89766, upload-time = "2024-10-13T22:18:01.524Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, + { name = "idna", marker = "python_full_version >= '3.9'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "async-generator" +version = "1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/b6/6fa6b3b598a03cba5e80f829e0dadbb49d7645f523d209b2fb7ea0bbb02a/async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144", size = 29870, upload-time = "2018-08-01T03:36:21.69Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/52/39d20e03abd0ac9159c162ec24b93fbcaa111e8400308f2465432495ca2b/async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b", size = 18857, upload-time = "2018-08-01T03:36:20.029Z" }, +] + [[package]] name = "atomicwrites" version = "1.4.1" @@ -30,6 +118,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/6e/6f83bf616d2becdf333a1640f1d463fef3150e2e926b7010cb0f81c95e88/attrs-22.2.0-py3-none-any.whl", hash = "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836", size = 60018, upload-time = "2022-12-21T09:48:49.401Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "certifi" version = "2025.4.26" @@ -182,6 +279,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "contextvars" +version = "2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "immutables", marker = "python_full_version < '3.7'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/96/55b82d9f13763be9d672622e1b8106c85acb83edd7cc2fa5bc67cd9877e9/contextvars-2.4.tar.gz", hash = "sha256:f38c908aaa59c14335eeea12abea5f443646216c4e29380d7bf34d2018e2c39e", size = 9570, upload-time = "2019-04-01T14:42:11.953Z" } + [[package]] name = "coverage" version = "6.2" @@ -516,6 +622,15 @@ toml = [ { name = "tomli", version = "2.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version <= '3.11'" }, ] +[[package]] +name = "dataclasses" +version = "0.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/12/7919c5d8b9c497f9180db15ea8ead6499812ea8264a6ae18766d93c59fe5/dataclasses-0.8.tar.gz", hash = "sha256:8479067f342acf957dc82ec415d355ab5edb7e7646b90dc6e2fd1d96ad084c97", size = 36581, upload-time = "2020-11-13T14:40:30.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ca/75fac5856ab5cfa51bbbcefa250182e50441074fdc3f803f6e76451fab43/dataclasses-0.8-py3-none-any.whl", hash = "sha256:0201d89fa866f68c8ebd9d08ee6ff50c0b255f8ec63a71c16fda7af82bb887bf", size = 19041, upload-time = "2020-11-13T14:40:29.194Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.0" @@ -530,6 +645,178 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" }, ] +[[package]] +name = "h11" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.6.8' and python_full_version < '3.7'", + "python_full_version < '3.6.8'", +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/e9/72c3dc8f7dd7874812be6a6ec788ba1300bfe31570963a7e788c86280cb9/h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042", size = 98121, upload-time = "2021-01-01T11:34:46.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/0f/7a0eeea938eaf61074f29fed9717f2010e8d0e0905d36b38d3275a1e4622/h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6", size = 54857, upload-time = "2021-01-01T11:34:45.391Z" }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine != 'aarch64' and platform_machine != 'arm64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'aarch64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'arm64'", + "python_full_version >= '3.7' and python_full_version < '3.7.1'", +] +dependencies = [ + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418, upload-time = "2022-09-25T15:40:01.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259, upload-time = "2022-09-25T15:39:59.68Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "0.14.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.6.8' and python_full_version < '3.7'", + "python_full_version < '3.6.8'", +] +dependencies = [ + { name = "anyio", version = "3.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, + { name = "certifi", version = "2025.4.26", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, + { name = "h11", version = "0.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, + { name = "sniffio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/46/2c1e32574749d38404c9380d5c0de3f6fba44ceea119cf1536f138e72784/httpcore-0.14.7.tar.gz", hash = "sha256:7503ec1c0f559066e7e39bc4003fd2ce023d01cf51793e3c173b864eb456ead1", size = 53400, upload-time = "2022-02-04T12:17:59.923Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/38/7b76d3d71c462dc936e333b358a3106e7af913e6c8c9dd5a45684fec08cc/httpcore-0.14.7-py3-none-any.whl", hash = "sha256:47d772f754359e56dd9d892d9593b6f9870a37aeb8ba51e9a88b09b3d68cfade", size = 68325, upload-time = "2022-02-04T12:17:58.427Z" }, +] + +[[package]] +name = "httpcore" +version = "0.17.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine != 'aarch64' and platform_machine != 'arm64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'aarch64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'arm64'", + "python_full_version >= '3.7' and python_full_version < '3.7.1'", +] +dependencies = [ + { name = "anyio", version = "3.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, + { name = "certifi", version = "2025.8.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, + { name = "h11", version = "0.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, + { name = "sniffio", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/ad/c98ecdbfe04417e71e143bf2f2fb29128e4787d78d1cedba21bd250c7e7a/httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888", size = 62676, upload-time = "2023-07-05T12:09:31.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/2c/2bde7ff8dd2064395555220cbf7cba79991172bf5315a07eb3ac7688d9f1/httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87", size = 74513, upload-time = "2023-07-05T12:09:29.425Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "certifi", version = "2025.8.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "h11", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.6.8' and python_full_version < '3.7'", + "python_full_version < '3.6.8'", +] +dependencies = [ + { name = "async-generator", marker = "python_full_version < '3.7'" }, + { name = "certifi", version = "2025.4.26", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, + { name = "charset-normalizer", version = "2.0.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, + { name = "httpcore", version = "0.14.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, + { name = "rfc3986", extra = ["idna2008"], marker = "python_full_version < '3.7'" }, + { name = "sniffio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/07/de30dd4bb26131bf34fe82bf721a392ff21e35bb2707ef8cbec954054a23/httpx-0.22.0.tar.gz", hash = "sha256:d8e778f76d9bbd46af49e7f062467e3157a5a3d2ae4876a4bbfd8a51ed9c9cb4", size = 107324, upload-time = "2022-01-26T14:50:24.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/d3/6a990516a43a522a72da356c4a91c03e09c0cddce8106e7e1215c120011f/httpx-0.22.0-py3-none-any.whl", hash = "sha256:e35e83d1d2b9b2a609ef367cc4c1e66fd80b750348b20cc9e19d1952fc2ca3f6", size = 84179, upload-time = "2022-01-26T14:50:22.535Z" }, +] + +[[package]] +name = "httpx" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine != 'aarch64' and platform_machine != 'arm64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'aarch64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'arm64'", + "python_full_version >= '3.7' and python_full_version < '3.7.1'", +] +dependencies = [ + { name = "certifi", version = "2025.8.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, + { name = "httpcore", version = "0.17.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, + { name = "idna", marker = "python_full_version == '3.7.*'" }, + { name = "sniffio", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/2a/114d454cb77657dbf6a293e69390b96318930ace9cd96b51b99682493276/httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd", size = 81858, upload-time = "2023-05-19T00:50:56.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/91/e41f64f03d2a13aee7e8c819d82ee3aa7cdc484d18c0ae859742597d5aa0/httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd", size = 75377, upload-time = "2023-05-19T00:50:54.91Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", + "python_full_version == '3.9.*'", + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "anyio", version = "4.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "certifi", version = "2025.8.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "httpcore", version = "1.0.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, + { name = "idna", marker = "python_full_version >= '3.8'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -539,6 +826,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "immutables" +version = "0.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", version = "4.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/bf/113933c9d098c58cee52c68a205cd449bcc331c32156267d337125780bf6/immutables-0.19.tar.gz", hash = "sha256:df17942d60e8080835fcc5245aa6928ef4c1ed567570ec019185798195048dcf", size = 85490, upload-time = "2022-09-14T17:51:11.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/80/56651023cb47e7108c5c1e39020f936163ca40965812a69d484adaff3e83/immutables-0.19-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fef6743f8c3098ae46d9a2a3606b04a91c62e216487d91e90ce5c7419da3f803", size = 73733, upload-time = "2022-09-14T17:50:16.826Z" }, + { url = "https://files.pythonhosted.org/packages/d7/01/779f8d211bcaa6950a4f8db5ed2676ab2d581c87a83787bcc63acf5df6d9/immutables-0.19-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cfb62119b7302a37cb4a1db44234dab9acda60ba93e3c28489969722e85237b7", size = 57151, upload-time = "2022-09-14T17:50:18.188Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d2/71b2134396257aa5c17fd622582e365ee81cae9a6a57164a06037e9cc57f/immutables-0.19-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55b886e92ef5abfc4b066f404d956ca5789a2f8f738d448300fba40930a631", size = 120591, upload-time = "2022-09-14T17:50:19.392Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1a/b509dba90da0d7c842cfd72f1eceb6ffd60b71894cfcdab601a3c4fe208f/immutables-0.19-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40f1c3ab3ae690a55a2f61039705a110f0e23717d6d8a62a84600fc7cf5934dc", size = 120809, upload-time = "2022-09-14T17:50:20.811Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dc/a8f53aba8c49fdfac55802c4fb4d7dc5b3fa0445b01a97ad7636098af212/immutables-0.19-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f3096afb376b9b3651a3b92affd1896b4dcefde209f412572f7e3924f6749a49", size = 120649, upload-time = "2022-09-14T17:50:22.098Z" }, + { url = "https://files.pythonhosted.org/packages/64/7d/753a80984f8e2ee63d82c439739ffb83a387ab7857a71b02d5b1600abc5f/immutables-0.19-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:85bcb5a7c33100c1b2eeb8c71e5f80acab4c9dde074b2c2ca8e3dfb6830ce813", size = 119965, upload-time = "2022-09-14T17:50:24.192Z" }, + { url = "https://files.pythonhosted.org/packages/9b/dc/d50e8f86a84a5a3dbda05e7108d9d7d2d32b92305ea0e8d9257b3fefcc7d/immutables-0.19-cp310-cp310-win_amd64.whl", hash = "sha256:620c166e76030ca4772ea64e5190f8347a730a0af85b743820d351f211004397", size = 58723, upload-time = "2022-09-14T17:50:25.505Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ac/fe16dabaa1b3e8e8c51cd452d9a2444dc942462ed67427a57f298834eee0/immutables-0.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c1774f298db9d460e50c40dfc9cfe7dd8a0de22c22f1de9a1f9a468daa1201dc", size = 74426, upload-time = "2022-09-14T17:50:27.037Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/4356a2caeb3c5b64668cdc8ac1eb3117fb3aaf54c361b31591d2cfb932e1/immutables-0.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:24dbdc28779a2b75e06224609f4fc850ba61b7e1b74e32ec808c6430a535be2d", size = 57172, upload-time = "2022-09-14T17:50:28.531Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e7/6309ac964d2fbbfedec2ffd4d65e234ba6b8c9283c0919e11946f0679295/immutables-0.19-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b8c0a4264e3ba2f025f4517ce67f0d0869106a625dbda08758cbf4dd6b6dd1f", size = 123513, upload-time = "2022-09-14T17:50:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/d5/03/07e19933bf55f475fad836889ca46231113f39b32b987414e5084988cffa/immutables-0.19-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28d1ee66424c2db998d27ebe0a331c7e09627e54a402848b2897cb6ef4dc4d7e", size = 123625, upload-time = "2022-09-14T17:50:30.826Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d3/5305f82ae8d1655e7a5601fce246398332c1550ffed162782846e4430a8f/immutables-0.19-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6f857aec0e0455986fd1f41234c867c3daf5a89ff7f54d493d4eb3c233d36d3c", size = 122490, upload-time = "2022-09-14T17:50:32.177Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b6/3e0be90b335e9d469cf48f3749fe43fcdbedeea5c24337497f8c02c6d646/immutables-0.19-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:119c60a05cb35add45c1e592e23a5cbb9db03161bb89d1596b920d9341173982", size = 121940, upload-time = "2022-09-14T17:50:33.534Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f9/5bcd6e376d728d1f9208d6d48e12822c6312dc39ddcdc42a98a623ba0d1a/immutables-0.19-cp311-cp311-win_amd64.whl", hash = "sha256:3fbad255e404b4cbcf3477b384a1e400bd8f28cbbfc2df8d3885abe3bfc7b909", size = 58723, upload-time = "2022-09-14T17:50:34.782Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1f/1ba27437d9708a72199bd877d2e7cb9abd560aee2e4d8960faa52fd72e9b/immutables-0.19-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6660e185354a1cb59ecc130f2b85b50d666d4417be668ce6ba83d4be79f55d34", size = 57005, upload-time = "2022-09-14T17:50:36.408Z" }, + { url = "https://files.pythonhosted.org/packages/38/e6/9a1f06d1d9936bd784dc2ded8f13e27ef21474b8679b79be0a1906fa49b5/immutables-0.19-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37de95c1d79707d95f50d0ab79e067bee52381afc967ff031ac4c822c14f43a8", size = 114824, upload-time = "2022-09-14T17:50:37.577Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ad/154c84dcb517f534c74accd5811d00d41af112ccfe505b7013f32efebb9e/immutables-0.19-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed61dbc963251bec7281cdb0c148176bbd70519d21fd05bce4c484632cdc3b2c", size = 116084, upload-time = "2022-09-14T17:50:39.969Z" }, + { url = "https://files.pythonhosted.org/packages/8d/03/b95cbaaa7fbb42bc31026e83ddd674a7daf4004d1faa3428bb75575f7a32/immutables-0.19-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:7da9356a163993e01785a211b47c6a0038b48d1235b68479a0053c2c4c3cf666", size = 115488, upload-time = "2022-09-14T17:50:41.735Z" }, + { url = "https://files.pythonhosted.org/packages/93/0b/60ef6f9cca8a4da21667c70c9b1261bccaf0a9b981e937d18767151e99e4/immutables-0.19-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:41d8cae52ea527f9c6dccdf1e1553106c482496acc140523034f91877ccbc103", size = 114973, upload-time = "2022-09-14T17:50:42.968Z" }, + { url = "https://files.pythonhosted.org/packages/24/ed/96c68dcba132ad2a57b5d499b5a337395b2c25f09f348ab4135d56b9ecc9/immutables-0.19-cp36-cp36m-win_amd64.whl", hash = "sha256:e95f0826f184920adb3cdf830f409f1c1d4e943e4dc50242538c4df9d51eea72", size = 58827, upload-time = "2022-09-14T17:50:44.38Z" }, + { url = "https://files.pythonhosted.org/packages/d0/6c/8a661a22f1483bcdf86a7c1a2d2b054b312cfb3b2726f69750b8b1b323dd/immutables-0.19-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:50608784e33c88da8c0e06e75f6725865cf2e345c8f3eeb83cb85111f737e986", size = 56983, upload-time = "2022-09-14T17:50:46.003Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/637bdff7e2fab322cd97b03af01c7a3370c52bf1e39956c0cf7012b96d3b/immutables-0.19-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cbd4d9dc531ee24b2387141a5968e923bb6174d13695e730cde0887aadda557", size = 115898, upload-time = "2022-09-14T17:50:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/0a/60/afe1951d8dd21c7682ab8c9292f87c8b1232bd2a11cc2620c42b0b897bb5/immutables-0.19-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eed8988dc4ebde8d527dbe4dea68cb9fe6d43bc56df60d6015130dc4abd2ab34", size = 117043, upload-time = "2022-09-14T17:50:48.828Z" }, + { url = "https://files.pythonhosted.org/packages/48/07/bc38756afab1c920809c25dc280f4feae7878e1034ba874983d14f60f3e8/immutables-0.19-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c830c9afc6fcb4a7d6d74230d6290987e664418026a15488ad00d8a3dc5ec743", size = 116441, upload-time = "2022-09-14T17:50:49.983Z" }, + { url = "https://files.pythonhosted.org/packages/f6/11/5bdacfc5959a2fe1cdef4c649019587082f0588db3d425abe9a9bc943418/immutables-0.19-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:7c6cce2e87cd5369234b199037631cfed08e43813a1fdd750807d14404de195b", size = 115888, upload-time = "2022-09-14T17:50:51.143Z" }, + { url = "https://files.pythonhosted.org/packages/55/d2/ab10b55237e9750269502faa5ca2bd9c3c697289c0e0ded56c7cc263dfdb/immutables-0.19-cp37-cp37m-win_amd64.whl", hash = "sha256:10774f73af07b1648fa02f45f6ff88b3391feda65d4f640159e6eeec10540ece", size = 58805, upload-time = "2022-09-14T17:50:52.264Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9e/becfc7d5444d5e42f83a6da51f9b50a3fba5917a34d87504eb3f5819a032/immutables-0.19-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a208a945ea817b1455b5b0f9c33c097baf6443b50d749a3dc32ff445e41b81d2", size = 73879, upload-time = "2022-09-14T17:50:53.464Z" }, + { url = "https://files.pythonhosted.org/packages/20/55/5438434191eec4d3037965c60544c079bc61b6f38144b16a1aba9fc16728/immutables-0.19-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:25a6225efb5e96fc95d84b2d280e35d8a82a1ae72a12857177d48cc289ac1e03", size = 57231, upload-time = "2022-09-14T17:50:54.606Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b7/306752c1dcbd62b28d3a312b1055d777323aa11687255413f6bebd47a353/immutables-0.19-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c0cf0d94b08e58896acf250cbc4682499c8a256fc6d0ee5c63d76a759a6a228", size = 125018, upload-time = "2022-09-14T17:50:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d7/06e096166a707d278f07b7b98c10c0275d20910ab6244d45ce1f2fd60f29/immutables-0.19-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64c74c5171f3a97b178b880746743a07b08e7d7f6055370bf04a94d50aea0643", size = 124416, upload-time = "2022-09-14T17:50:56.894Z" }, + { url = "https://files.pythonhosted.org/packages/e5/48/47b73c75f11c2a383d54afa042a32ae7d91b8a717d25a1aa2a3cf72e06f1/immutables-0.19-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8ababf72ed2a956b28f151d605a7bb1d4e1c59113f53bf2be4a586da3977b319", size = 123748, upload-time = "2022-09-14T17:50:58.081Z" }, + { url = "https://files.pythonhosted.org/packages/d0/31/8c772d0d28818d7ad45a36e8a6dd5e6d1b1215690431548b5f2ac9f26fc0/immutables-0.19-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:52a91917c65e6b9cfef7a2d2c3b0e00432a153aa8650785b7ee0897d80226278", size = 122918, upload-time = "2022-09-14T17:50:59.169Z" }, + { url = "https://files.pythonhosted.org/packages/14/3f/ae6d020e23a596d25d7c57f34f244c5a1e57fa0df273a87dd20441d2c48d/immutables-0.19-cp38-cp38-win_amd64.whl", hash = "sha256:bbe65c23779e12e0ecc3dec2c709ad22b7cc8b163895327bc173ae06a8b73425", size = 58839, upload-time = "2022-09-14T17:51:00.395Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/3124bd7741388be2d314ba7d51793bbeed00e92c9e624a8642b06b1ddcca/immutables-0.19-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:480cc5d62efcac66f9737ae0820acd39d39e516e6fdbcf46cbdc26f11b429fd7", size = 73729, upload-time = "2022-09-14T17:51:01.533Z" }, + { url = "https://files.pythonhosted.org/packages/3b/4f/6e8489f1a43c80477475e8b95340ec27c7ada630137d2c9ede8458b0e032/immutables-0.19-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2d88ff44e131508def4740964076c3da273baeeb406c1fe139f18373ea4196dd", size = 57148, upload-time = "2022-09-14T17:51:03.339Z" }, + { url = "https://files.pythonhosted.org/packages/39/07/622f5c7ca80a6658d9590e1b9fdb9f6d7957eef9d3542099cc7501473fc8/immutables-0.19-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fa3148393101b0c4571da523929ae90a5b4bfc933c270a11b802a34a921c608", size = 120165, upload-time = "2022-09-14T17:51:04.56Z" }, + { url = "https://files.pythonhosted.org/packages/a9/67/1205510e89139f784efc72f3c1b4368b90e30d434461eb947cfd7cd91c92/immutables-0.19-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0575190a90c3fce6862ccdb09be3344741ff97a96e559893541886d372139f1c", size = 120382, upload-time = "2022-09-14T17:51:06.005Z" }, + { url = "https://files.pythonhosted.org/packages/39/df/435025bc02d58f33f84cbf598361c5fba0d48c7d4e5600c9e896eebab036/immutables-0.19-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3754b26ef18b5d1009ffdeafc17fbd877a79f0a126e1423069bd8ef51c54302d", size = 120255, upload-time = "2022-09-14T17:51:07.216Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c8/385c7267af27bafed49c283da0ff674e23e53389eca69fe9ce2b01597309/immutables-0.19-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:648142e16d49f5207ae52ee1b28dfa148206471967b9c9eaa5a9592fd32d5cef", size = 119574, upload-time = "2022-09-14T17:51:08.633Z" }, + { url = "https://files.pythonhosted.org/packages/16/72/3c22e78cffedc8cfe06c9539ead336882fe9ca91f01ae7bb688b1ca2fdd1/immutables-0.19-cp39-cp39-win_amd64.whl", hash = "sha256:199db9070ffa1a037e6650ddd63159907a210e4998f932bdf50e70615629db0c", size = 58766, upload-time = "2022-09-14T17:51:09.959Z" }, +] + [[package]] name = "importlib-metadata" version = "4.8.3" @@ -1489,6 +1827,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.6.8' and python_full_version < '3.7'", + "python_full_version < '3.6.8'", +] +dependencies = [ + { name = "pytest", version = "7.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/53/8844d99d5343eecbb6d740d708581fbf63cefd560c07c7164b12691e54eb/pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb", size = 12095, upload-time = "2021-10-15T23:29:52.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d0/d9bd672577857bb59004d7a0902abb5f27770c1d234860a08898eb058bd2/pytest_asyncio-0.16.0-py3-none-any.whl", hash = "sha256:5f2a21273c47b331ae6aa5b36087047b4899e40f03f18397c0e65fa5cca54e9b", size = 12119, upload-time = "2021-10-15T23:29:49.06Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.21.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine != 'aarch64' and platform_machine != 'arm64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'aarch64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'arm64'", + "python_full_version >= '3.7' and python_full_version < '3.7.1'", +] +dependencies = [ + { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, + { name = "typing-extensions", version = "4.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/53/57663d99acaac2fcdafdc697e52a9b1b7d6fcf36616281ff9768a44e7ff3/pytest_asyncio-0.21.2.tar.gz", hash = "sha256:d67738fc232b94b326b9d060750beb16e0074210b98dd8b58a5239fa2a154f45", size = 30656, upload-time = "2024-04-29T13:23:24.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/ce/1e4b53c213dce25d6e8b163697fbce2d43799d76fa08eea6ad270451c370/pytest_asyncio-0.21.2-py3-none-any.whl", hash = "sha256:ab664c88bb7998f711d8039cacd4884da6430886ae8bbd4eded552ed2004f16b", size = 13368, upload-time = "2024-04-29T13:23:23.126Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", +] +dependencies = [ + { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855, upload-time = "2024-08-22T08:03:18.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024, upload-time = "2024-08-22T08:03:15.536Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.9.*'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version == '3.9.*'" }, + { name = "pytest", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version == '3.10.*'", +] +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version == '3.10.*'" }, + { name = "pytest", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", version = "4.14.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-cov" version = "4.0.0" @@ -1731,6 +2155,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "rfc3986" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/30/5b1b6c28c105629cc12b33bdcbb0b11b5bb1880c6cfbd955f9e792921aa8/rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", size = 49378, upload-time = "2021-05-07T23:29:27.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/e5/63ca2c4edf4e00657584608bee1001302bbf8c5f569340b78304f2f446cb/rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97", size = 31976, upload-time = "2021-05-07T23:29:25.611Z" }, +] + +[package.optional-dependencies] +idna2008 = [ + { name = "idna", marker = "python_full_version < '3.7'" }, +] + [[package]] name = "ruff" version = "0.0.17" @@ -1838,17 +2276,16 @@ wheels = [ [[package]] name = "smartsheet-dataframe" -version = "0.3.7" +version = "1.0.0" source = { editable = "." } dependencies = [ + { name = "httpx", version = "0.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, + { name = "httpx", version = "0.24.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, + { name = "httpx", version = "0.28.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.8'" }, { name = "pandas", version = "1.1.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7.1'" }, { name = "pandas", version = "1.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.7.1' and python_full_version < '3.8'" }, { name = "pandas", version = "2.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, { name = "pandas", version = "2.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "requests", version = "2.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, - { name = "requests", version = "2.31.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, - { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, - { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, ] [package.dev-dependencies] @@ -1863,6 +2300,11 @@ dev = [ { name = "pytest", version = "7.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, { name = "pytest", version = "8.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, + { name = "pytest-asyncio", version = "0.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, + { name = "pytest-asyncio", version = "0.21.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, + { name = "pytest-asyncio", version = "0.24.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, + { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, + { name = "pytest-asyncio", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-cov", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.7'" }, { name = "pytest-cov", version = "4.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.7.*'" }, { name = "pytest-cov", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.8.*'" }, @@ -1878,8 +2320,8 @@ dev = [ [package.metadata] requires-dist = [ + { name = "httpx", specifier = ">=0.22.0" }, { name = "pandas", specifier = ">=0.24.0" }, - { name = "requests", specifier = ">=2.0.0" }, ] [package.metadata.requires-dev] @@ -1887,6 +2329,7 @@ dev = [ { name = "isort", specifier = ">=5.10.1" }, { name = "pyright", specifier = ">=0.0.13.post0" }, { name = "pytest", specifier = ">=7.0.1" }, + { name = "pytest-asyncio", specifier = ">=0.16.0" }, { name = "pytest-cov", specifier = ">=4.0.0" }, { name = "python-dotenv", specifier = ">=0.20.0" }, { name = "ruff", specifier = ">=0.0.17" }, @@ -1913,6 +2356,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/22/174eed88f8b3b5a6a0b2daca8e68a641d7c489d6852896ba927d0fadedbc/smartsheet_python_sdk-3.0.5-py2.py3-none-any.whl", hash = "sha256:339df23a98789a14ab75756c7916a3b21870a7d2ddbe853f302cc4b437fe8863", size = 229708, upload-time = "2025-04-08T12:15:48.251Z" }, ] +[[package]] +name = "sniffio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.6.8' and python_full_version < '3.7'", + "python_full_version < '3.6.8'", +] +dependencies = [ + { name = "contextvars", marker = "python_full_version < '3.7'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/44ed7978bcb1f6337a3e2bef19c941de750d73243fc9389140d62853b686/sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de", size = 17132, upload-time = "2020-10-11T18:25:37.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/b0/7b2e028b63d092804b6794595871f936aafa5e9322dcaaad50ebf67445b3/sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663", size = 10033, upload-time = "2020-10-11T18:25:36.39Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.8.*'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine != 'aarch64' and platform_machine != 'arm64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'aarch64'", + "python_full_version >= '3.7.1' and python_full_version < '3.8' and platform_machine == 'arm64'", + "python_full_version >= '3.7' and python_full_version < '3.7.1'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + [[package]] name = "tomli" version = "1.2.3"