From 2443c013fee88f90f5b7badd05c9e100490a4f3f Mon Sep 17 00:00:00 2001 From: Marco Gil Date: Mon, 23 Feb 2026 15:34:48 +0100 Subject: [PATCH] PTHMINT-94: Http transport --- README.md | 2 +- examples/transport/httpx_transport.py | 80 ++++ examples/transport/request_transport.py | 71 ++++ examples/transport/urllib3_transport.py | 102 +++++ poetry.lock | 354 ++++++++++++------ pyproject.toml | 12 +- src/multisafepay/__init__.py | 4 +- src/multisafepay/client/client.py | 41 +- src/multisafepay/sdk.py | 10 +- src/multisafepay/transport/__init__.py | 17 + src/multisafepay/transport/http_transport.py | 125 +++++++ .../transport/requests_transport.py | 136 +++++++ .../recurring_manager/test_recurring.py | 45 ++- .../transport/test_custom_httpx_transport.py | 66 ++++ .../test_custom_requests_session_transport.py | 53 +++ .../test_custom_urllib3_transport.py | 63 ++++ .../unit/client/test_unit_client.py | 35 +- .../unit/transport/test_unit_transport.py | 181 +++++++++ .../test_unit_transport_selection.py | 95 +++++ tests/support/__init__.py | 0 tests/support/alt_http_transports.py | 165 ++++++++ tests/support/mock_transport.py | 84 +++++ 22 files changed, 1554 insertions(+), 187 deletions(-) create mode 100644 examples/transport/httpx_transport.py create mode 100644 examples/transport/request_transport.py create mode 100644 examples/transport/urllib3_transport.py create mode 100644 src/multisafepay/transport/__init__.py create mode 100644 src/multisafepay/transport/http_transport.py create mode 100644 src/multisafepay/transport/requests_transport.py create mode 100644 tests/multisafepay/e2e/examples/transport/test_custom_httpx_transport.py create mode 100644 tests/multisafepay/e2e/examples/transport/test_custom_requests_session_transport.py create mode 100644 tests/multisafepay/e2e/examples/transport/test_custom_urllib3_transport.py create mode 100644 tests/multisafepay/unit/transport/test_unit_transport.py create mode 100644 tests/multisafepay/unit/transport/test_unit_transport_selection.py create mode 100644 tests/support/__init__.py create mode 100644 tests/support/alt_http_transports.py create mode 100644 tests/support/mock_transport.py diff --git a/README.md b/README.md index 71c07cc..58674a2 100644 --- a/README.md +++ b/README.md @@ -70,4 +70,4 @@ If you create a pull request to suggest an improvement, we'll send you some Mult ## Want to be part of the team? Are you a developer interested in working at MultiSafepay? Check out -our [job openings](https://www.multisafepay.com/careers/#jobopenings) and feel free to get in touch! \ No newline at end of file +our [job openings](https://www.multisafepay.com/careers/#jobopenings) and feel free to get in touch! diff --git a/examples/transport/httpx_transport.py b/examples/transport/httpx_transport.py new file mode 100644 index 0000000..a036041 --- /dev/null +++ b/examples/transport/httpx_transport.py @@ -0,0 +1,80 @@ +"""Example: Injecting an httpx-based transport. + +This example demonstrates the recommended pattern for using a different HTTP +client than requests, without the SDK having to maintain “official” adapters. + +Requirements +------------ +- `pip install httpx` +- `API_KEY` in the environment (optionally via a `.env` + python-dotenv) + +Notes +----- +- `httpx.Response` already exposes `status_code`, `headers`, `.json()` and + `.raise_for_status()`, so we don't need to adapt the response object. +- We only adapt the *transport* (how requests are executed) to match the SDK's + `HTTPTransport.request(...)` contract. +""" + +from __future__ import annotations + +import os +from typing import Any, Dict, Optional + +from dotenv import load_dotenv + +from multisafepay import Sdk + + +class HttpxTransport: + """Minimal `HTTPTransport` implementation backed by httpx.""" + + def __init__(self, client: Optional[Any] = None) -> None: + import httpx + + self._httpx = httpx + self.client = client or httpx.Client() + + def request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, Any]] = None, + data: Optional[Any] = None, + **kwargs: Any, + ) -> Any: + # The SDK may pass `data` as a dict; in httpx this should be sent as `json=`. + request_kwargs: Dict[str, Any] = { + "method": method, + "url": url, + "headers": headers, + "params": params, + **kwargs, + } + + if data is not None: + if isinstance(data, (dict, list)): + request_kwargs["json"] = data + else: + request_kwargs["content"] = data + + return self.client.request(**request_kwargs) + + def close(self) -> None: + self.client.close() + + +if __name__ == "__main__": + load_dotenv() + api_key = os.getenv("API_KEY") + if not api_key: + raise SystemExit("Missing API_KEY env var") + + transport = HttpxTransport() + try: + sdk = Sdk(api_key=api_key, is_production=False, transport=transport) + gateways = sdk.get_gateway_manager().get_gateways().get_data() + print(gateways) + finally: + transport.close() diff --git a/examples/transport/request_transport.py b/examples/transport/request_transport.py new file mode 100644 index 0000000..9931f62 --- /dev/null +++ b/examples/transport/request_transport.py @@ -0,0 +1,71 @@ +"""Example: Custom requests.Session transport. + +This example shows how to configure a real `requests.Session` (Retry + connection +pooling) and inject it into the SDK via `RequestsTransport`. + +Requires the optional extra: `multisafepay[requests]`. +""" + +import os + +from dotenv import load_dotenv +from requests import Session +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from multisafepay import Sdk +from multisafepay.transport import RequestsTransport + + +def create_custom_session() -> Session: + """Create a custom requests Session with retry logic and connection pooling.""" + retry_strategy = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["GET", "POST", "PATCH", "DELETE"], + ) + + adapter = HTTPAdapter( + max_retries=retry_strategy, + pool_connections=10, + pool_maxsize=20, + ) + + session = Session() + session.mount("http://", adapter) + session.mount("https://", adapter) + + # Note: requests doesn't support a global default timeout on Session. + # Timeouts must be passed per request (or implemented in a custom transport). + session.headers.update( + { + "User-Agent": "multisafepay-python-sdk-examples", + "Accept": "application/json", + } + ) + + return session + + +# Load environment variables from a .env file +load_dotenv() + +# Retrieve the API key from the environment variables +API_KEY = os.getenv("API_KEY") + + +if __name__ == "__main__": + # Create a custom requests Session and inject it into the SDK + custom_session = create_custom_session() + transport = RequestsTransport(session=custom_session) + + multisafepay_sdk: Sdk = Sdk(API_KEY, False, transport) + gateway_manager = multisafepay_sdk.get_gateway_manager() + + get_gateways_response = gateway_manager.get_gateways() + gateway_listing = get_gateways_response.get_data() + + print(gateway_listing) + + custom_session.close() diff --git a/examples/transport/urllib3_transport.py b/examples/transport/urllib3_transport.py new file mode 100644 index 0000000..c6e5e39 --- /dev/null +++ b/examples/transport/urllib3_transport.py @@ -0,0 +1,102 @@ +"""Example: Injecting an urllib3-based transport (with response adaptation). + +Unlike requests/httpx, urllib3 is lower-level and its response object does NOT +expose the typical high-level interface (`status_code`, `.json()`, +`.raise_for_status()`). + +This is why the example shows the full pattern: +- A `Transport` that executes the request via `urllib3.PoolManager` +- A `ResponseAdapter` that translates urllib3 responses into what the SDK expects + +Requirements +------------ +- `pip install urllib3` +- `API_KEY` in the environment (optionally via a `.env` + python-dotenv) +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from dotenv import load_dotenv + +from multisafepay import Sdk + + +@dataclass +class Urllib3ResponseAdapter: + """Adapter: urllib3.HTTPResponse -> interface expected by the SDK.""" + + response: Any + + @property + def status_code(self) -> int: + return int(getattr(self.response, "status")) + + @property + def headers(self) -> Dict[str, str]: + raw = getattr(self.response, "headers", {}) + return {str(k): str(v) for k, v in dict(raw).items()} + + def json(self) -> Any: + data = getattr(self.response, "data", b"") + if data is None: + return None + text = data if isinstance(data, str) else data.decode("utf-8") + return json.loads(text) if text else {} + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise Exception(f"HTTP Error {self.status_code}") + + +class Urllib3Transport: + """Minimal `HTTPTransport` implementation backed by urllib3.""" + + def __init__(self, pool_manager: Optional[Any] = None) -> None: + urllib3 = __import__("urllib3") + self._pool = pool_manager or urllib3.PoolManager() + + def request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, Any]] = None, # noqa: ARG002 + data: Optional[Any] = None, + **kwargs: Any, + ) -> Urllib3ResponseAdapter: + # The SDK already bakes query parameters into `url`; avoid re-applying `params`. + body: Optional[bytes] + if data is None: + body = None + elif isinstance(data, (bytes, bytearray)): + body = bytes(data) + elif isinstance(data, str): + body = data.encode("utf-8") + else: + body = json.dumps(data).encode("utf-8") + + resp = self._pool.request( + method=method, + url=url, + body=body, + headers=headers, + **kwargs, + ) + return Urllib3ResponseAdapter(resp) + + +if __name__ == "__main__": + load_dotenv() + api_key = os.getenv("API_KEY") + if not api_key: + raise SystemExit("Missing API_KEY env var") + + transport = Urllib3Transport() + sdk = Sdk(api_key=api_key, is_production=False, transport=transport) + gateways = sdk.get_gateway_manager().get_gateways().get_data() + print(gateways) diff --git a/poetry.lock b/poetry.lock index b7d7c9e..cfa5303 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,24 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. + +[[package]] +name = "anyio" +version = "4.12.1" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} + +[package.extras] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] [[package]] name = "astroid" @@ -64,116 +84,137 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2025.1.31" +version = "2026.1.4" description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6" -groups = ["main"] +python-versions = ">=3.7" +groups = ["dev"] files = [ - {file = "certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"}, - {file = "certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651"}, + {file = "certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c"}, + {file = "certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120"}, ] [[package]] name = "charset-normalizer" -version = "3.4.1" +version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" -groups = ["main"] +groups = ["dev"] files = [ - {file = "charset_normalizer-3.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win32.whl", hash = "sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f"}, - {file = "charset_normalizer-3.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b"}, - {file = "charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35"}, - {file = "charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407"}, - {file = "charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win32.whl", hash = "sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487"}, - {file = "charset_normalizer-3.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win32.whl", hash = "sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e"}, - {file = "charset_normalizer-3.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win32.whl", hash = "sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5"}, - {file = "charset_normalizer-3.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765"}, - {file = "charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85"}, - {file = "charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] [[package]] @@ -324,16 +365,76 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.16" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.27.2" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, + {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" -version = "3.10" +version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.6" -groups = ["main"] +python-versions = ">=3.8" +groups = ["dev"] files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, ] [package.extras] @@ -587,7 +688,7 @@ files = [ ] [package.dependencies] -astroid = ">=3.2.4,<=3.3.0-dev0" +astroid = ">=3.2.4,<=3.3.0.dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, @@ -669,7 +770,7 @@ version = "1.0.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["dev"] files = [ {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, @@ -680,14 +781,14 @@ cli = ["click (>=5.0)"] [[package]] name = "requests" -version = "2.32.4" +version = "2.32.5" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.8" -groups = ["main"] +python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, - {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, ] [package.dependencies] @@ -727,6 +828,18 @@ files = [ {file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "tomli" version = "2.2.1" @@ -796,23 +909,26 @@ files = [ [[package]] name = "urllib3" -version = "2.5.0" +version = "2.6.3" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.9" -groups = ["main"] +groups = ["dev"] files = [ - {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, - {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, + {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, + {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, ] [package.extras] -brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[extras] +requests = [] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.14" -content-hash = "4b9d6336cc1be3bc5c2141efcd80295bcf4515aa31abd8e5d666727463f804a0" +content-hash = "c674993d91b3f8968a725891d419ca47d43bd5b102fa261852e86b20dd4528b4" diff --git a/pyproject.toml b/pyproject.toml index f671aa7..ebc229a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,10 @@ homepage = "https://multisafepay.com" [tool.poetry.dependencies] python = ">=3.9,<3.14" -requests = "^2.32.4" pydantic = "^1.10.0" -python-dotenv = "^1.0.1" -urllib3 = ">=2.5.0" + +[tool.poetry.extras] +requests = ["requests", "urllib3"] [tool.poetry.group.dev.dependencies] black = "^24.4.2" @@ -23,9 +23,13 @@ pytest = "^8.2.1" pytest-cov = "^5.0.0" ruff = "^0.4.4" pytest-rerunfailures = "^12.0" +python-dotenv = "^1.0.1" +requests = "^2.32.3" +urllib3 = "^2.2.2" +httpx = "^0.27.0" [tool.pytest.ini_options] -pythonpath = ["src"] +pythonpath = ["src", "."] [build-system] build-backend = "poetry.core.masonry.api" diff --git a/src/multisafepay/__init__.py b/src/multisafepay/__init__.py index ad51b2e..14b74cf 100644 --- a/src/multisafepay/__init__.py +++ b/src/multisafepay/__init__.py @@ -2,4 +2,6 @@ from multisafepay.sdk import Sdk -__all__ = ["Sdk"] +__all__ = [ + "Sdk", +] diff --git a/src/multisafepay/client/client.py b/src/multisafepay/client/client.py index 4635d93..38ec01d 100644 --- a/src/multisafepay/client/client.py +++ b/src/multisafepay/client/client.py @@ -10,8 +10,7 @@ from typing import Any, Dict, Optional from multisafepay.api.base.response.api_response import ApiResponse -from requests import Request, Session -from requests.exceptions import RequestException +from multisafepay.transport import HTTPTransport, RequestsTransport from ..exception.api import ApiException from .api_key import ApiKey @@ -44,7 +43,7 @@ def __init__( self: "Client", api_key: str, is_production: bool, - http_client: Optional[Session] = None, + transport: Optional[HTTPTransport] = None, locale: str = "en_US", ) -> None: """ @@ -54,16 +53,14 @@ def __init__( ---------- api_key (str): The API key for authentication. is_production (bool): Flag indicating if the client is in production mode. - http_client (Optional[Session], optional): Custom HTTP client session. Defaults to None. - request_factory (Optional[Any], optional): Factory for creating requests. Defaults to None. - stream_factory (Optional[Any], optional): Factory for creating streams. Defaults to None. + transport (Optional[HTTPTransport], optional): Custom HTTP transport implementation. + Defaults to RequestsTransport if not provided. locale (str, optional): Locale for the requests. Defaults to "en_US". - strict_mode (bool, optional): Flag indicating if strict mode is enabled. Defaults to False. """ self.api_key = ApiKey(api_key=api_key) self.url = self.LIVE_URL if is_production else self.TEST_URL - self.http_client = http_client or Session() + self.transport = transport or RequestsTransport() self.locale = locale def create_get_request( @@ -90,7 +87,6 @@ def create_get_request( return self._create_request( self.METHOD_GET, url, - params=params, context=context, ) @@ -208,7 +204,6 @@ def _create_request( self: "Client", method: str, url: str, - params: Optional[Dict[str, Any]] = None, request_body: Optional[Dict[str, Any]] = None, context: Dict[str, Any] = None, ) -> ApiResponse: @@ -219,7 +214,6 @@ def _create_request( ---------- method (str): The HTTP method. url (str): The full URL. - params (Optional[Dict[str, Any]], optional): Query parameters. Defaults to None. request_body (Optional[Dict[str, Any]], optional): The request body. Defaults to None. context (Dict[str, Any], optional): Additional context for the request. Defaults to None. @@ -233,26 +227,27 @@ def _create_request( "accept-encoding": "application/json", "Content-Type": "application/json", } - request = Request( - method, - url, - params=params, - data=request_body, - headers=headers, - ) - prepared_request = self.http_client.prepare_request(request) try: - response = self.http_client.send(prepared_request) + response = self.transport.request( + method=method, + url=url, + headers=headers, + data=request_body, + ) response.raise_for_status() - except RequestException as e: - if 500 <= response.status_code < 600: + except Exception as e: + if ( + hasattr(response, "status_code") + and 500 <= response.status_code < 600 + ): raise ApiException(f"Request failed: {e}") from e + raise context = context or {} context.update( { - "headers": request.headers, + "headers": headers, "request_body": request_body, }, ) diff --git a/src/multisafepay/sdk.py b/src/multisafepay/sdk.py index 0046dd6..1efb44a 100644 --- a/src/multisafepay/sdk.py +++ b/src/multisafepay/sdk.py @@ -20,6 +20,7 @@ from multisafepay.api.paths.transactions.transaction_manager import ( TransactionManager, ) +from multisafepay.transport import HTTPTransport from .api.paths.capture.capture_manager import CaptureManager from .api.paths.me.me_manager import MeManager @@ -39,7 +40,7 @@ def __init__( self: "Sdk", api_key: str, is_production: bool, - http_client: Optional[Client] = None, + transport: Optional[HTTPTransport] = None, locale: str = "en_US", ) -> None: """ @@ -51,8 +52,9 @@ def __init__( The API key for authenticating with the MultiSafePay API. is_production : bool Flag indicating whether to use the production environment. - http_client : Optional[Client], optional - The HTTP client to use for making requests, by default None. + transport : Optional[HTTPTransport], optional + The HTTP transport implementation to use for making requests. + If not provided, defaults to RequestsTransport, by default None. locale : str, optional The locale to use for requests, by default "en_US". @@ -60,7 +62,7 @@ def __init__( self.client = Client( api_key.strip(), is_production, - http_client, + transport, locale, ) self.recurring_manager = RecurringManager(self.client) diff --git a/src/multisafepay/transport/__init__.py b/src/multisafepay/transport/__init__.py new file mode 100644 index 0000000..02c5651 --- /dev/null +++ b/src/multisafepay/transport/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Transport layer module for HTTP communication abstraction.""" + +from .http_transport import HTTPResponse, HTTPTransport +from .requests_transport import RequestsTransport + +__all__ = [ + "HTTPTransport", + "HTTPResponse", + "RequestsTransport", +] diff --git a/src/multisafepay/transport/http_transport.py b/src/multisafepay/transport/http_transport.py new file mode 100644 index 0000000..5e2f24a --- /dev/null +++ b/src/multisafepay/transport/http_transport.py @@ -0,0 +1,125 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""HTTP Transport layer abstraction for decoupling network communication.""" + +from typing import Any, Dict, Optional, Protocol + + +class HTTPTransport(Protocol): + """ + Protocol defining the interface for HTTP transport implementations. + + This abstraction allows the SDK to be decoupled from specific HTTP client + libraries, enabling flexibility to switch between different implementations + (e.g., requests, httpx, urllib) or to provide mock implementations for testing. + + The transport layer follows the Dependency Inversion Principle, allowing + business logic to depend on abstractions rather than concrete implementations. + """ + + def request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + data: Optional[str] = None, + **kwargs: Any, + ) -> "HTTPResponse": + """ + Execute an HTTP request. + + Parameters + ---------- + method : str + The HTTP method (GET, POST, PATCH, DELETE, etc.). + url : str + The full URL for the request. + headers : Optional[Dict[str, str]], optional + HTTP headers to include in the request, by default None. + data : Optional[str], optional + Request body data, by default None. + **kwargs : Any + Additional keyword arguments for transport-specific options, + such as query params, timeout, SSL options, etc. + + Returns + ------- + HTTPResponse + The HTTP response object. + + Raises + ------ + Exception + If the request fails or encounters an error. + + """ + ... + + +class HTTPResponse(Protocol): + """ + Protocol defining the interface for HTTP response objects. + + This abstraction ensures that different transport implementations + return responses with a consistent interface. + """ + + @property + def status_code(self) -> int: + """ + Get the HTTP status code. + + Returns + ------- + int + The HTTP status code (e.g., 200, 404, 500). + + """ + ... + + @property + def headers(self) -> Dict[str, str]: + """ + Get the response headers. + + Returns + ------- + Dict[str, str] + Dictionary of response headers. + + """ + ... + + def json(self) -> Any: + """ + Parse the response body as JSON. + + Returns + ------- + Any + The parsed JSON data. + + Raises + ------ + Exception + If the response body cannot be parsed as JSON. + + """ + ... + + def raise_for_status(self) -> None: + """ + Raise an exception for HTTP error status codes (4xx, 5xx). + + Raises + ------ + Exception + If the status code indicates an error. + + """ + ... diff --git a/src/multisafepay/transport/requests_transport.py b/src/multisafepay/transport/requests_transport.py new file mode 100644 index 0000000..4bb7383 --- /dev/null +++ b/src/multisafepay/transport/requests_transport.py @@ -0,0 +1,136 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Concrete implementation of HTTPTransport using the requests library.""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, TYPE_CHECKING, cast + +_REQUESTS_IMPORT_ERROR: Optional[ImportError] = None + +if TYPE_CHECKING: # pragma: no cover + from requests import Request, Session + from requests.models import Response + +try: + from requests import Request, Session + from requests.models import Response + + _HAS_REQUESTS = True +except ImportError as exc: # pragma: no cover + # `requests` is an optional dependency. The SDK can still be used if a + # custom HTTPTransport implementation is provided. + _HAS_REQUESTS = False + _REQUESTS_IMPORT_ERROR = exc + + +def _raise_requests_missing() -> None: + raise ModuleNotFoundError( + "Optional dependency 'requests' is required for RequestsTransport. " + "Install it via 'pip install multisafepay[requests]' (or add the Poetry extra 'requests'), " + "or pass a custom HTTPTransport implementation to Sdk(..., transport=...)." + ) from _REQUESTS_IMPORT_ERROR + + +class RequestsTransport: + """ + Concrete implementation of HTTPTransport using the requests library. + + This is the default transport implementation that wraps the requests library, + providing a standardized interface for making HTTP requests. + + Attributes + ---------- + session : Session + The underlying requests Session object used for connection pooling + and request execution. + + """ + + def __init__(self, session: Optional[Session] = None) -> None: + """ + Initialize the RequestsTransport. + + Parameters + ---------- + session : Optional[Session], optional + An existing requests Session to use. If not provided, a new + Session will be created, by default None. + + """ + if not _HAS_REQUESTS: # pragma: no cover + _raise_requests_missing() + self.session = session if session is not None else Session() + + def request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + data: Optional[str] = None, + **kwargs: Any, + ) -> Response: + """ + Execute an HTTP request using the requests library. + + Parameters + ---------- + method : str + The HTTP method (GET, POST, PATCH, DELETE, etc.). + url : str + The full URL for the request. + headers : Optional[Dict[str, str]], optional + HTTP headers to include in the request, by default None. + data : Optional[str], optional + Request body data, by default None. + **kwargs : Any + Additional keyword arguments passed to requests. + + Returns + ------- + Response + The requests Response object. + + Raises + ------ + RequestException + If the request fails or encounters an error. + + """ + if not _HAS_REQUESTS: # pragma: no cover + _raise_requests_missing() + session = cast("Session", self.session) + request = Request( + method=method, + url=url, + headers=headers, + data=data, + **kwargs, + ) + prepared_request = session.prepare_request(request) + return session.send(prepared_request) + + def close(self) -> None: + """ + Close the underlying session. + + This method should be called when the transport is no longer needed + to properly clean up resources. + """ + if not _HAS_REQUESTS: # pragma: no cover + _raise_requests_missing() + session = cast("Session", self.session) + session.close() + + def __enter__(self) -> "RequestsTransport": + """Support context manager protocol.""" + return self + + def __exit__(self, *args: Any) -> None: + """Close session when exiting context.""" + self.close() diff --git a/tests/multisafepay/e2e/examples/recurring_manager/test_recurring.py b/tests/multisafepay/e2e/examples/recurring_manager/test_recurring.py index 7f02202..1a1d8d6 100644 --- a/tests/multisafepay/e2e/examples/recurring_manager/test_recurring.py +++ b/tests/multisafepay/e2e/examples/recurring_manager/test_recurring.py @@ -120,28 +120,35 @@ def test_recurring(sdk: Sdk): assert isinstance(order, Order) + recurring_id = getattr(order.payment_details, "recurring_id", None) + assert recurring_id, "Expected order.payment_details.recurring_id to be set" + recurring_manager = sdk.get_recurring_manager() - response = recurring_manager.get_list(reference) - assert isinstance(response, CustomApiResponse) - token_list = response.get_data() - - assert isinstance(token_list, list) - assert len(token_list) > 0 - - index = next( - ( - i - for i, token in enumerate(token_list) - if token.token == order.payment_details.recurring_id - ), - -1, + def _get_token_from_list() -> Token | None: + response = recurring_manager.get_list(reference) + assert isinstance(response, CustomApiResponse) + token_list = response.get_data() + assert isinstance(token_list, list) + for token in token_list: + if getattr(token, "token", None) == recurring_id: + assert isinstance(token, Token) + return token + return None + + # Token creation can be eventually consistent; poll briefly. + deadline = time.monotonic() + 15 + token: Token | None = None + while token is None and time.monotonic() < deadline: + token = _get_token_from_list() + if token is None: + time.sleep(1) + + assert token is not None, ( + "Recurring token not found in list after creating order. " + f"reference={reference!r}, recurring_id={recurring_id!r}" ) - assert index is not -1 - assert isinstance(token_list[index], Token) - token = token_list[index] - response = recurring_manager.get(token.token, reference) assert isinstance(response, CustomApiResponse) @@ -156,4 +163,4 @@ def test_recurring(sdk: Sdk): assert isinstance(delete_response, CustomApiResponse) delete_data_response = delete_response.get_body_data() assert isinstance(delete_data_response, dict) - assert delete_data_response.get("removed") == True + assert delete_data_response.get("removed") is True diff --git a/tests/multisafepay/e2e/examples/transport/test_custom_httpx_transport.py b/tests/multisafepay/e2e/examples/transport/test_custom_httpx_transport.py new file mode 100644 index 0000000..634f978 --- /dev/null +++ b/tests/multisafepay/e2e/examples/transport/test_custom_httpx_transport.py @@ -0,0 +1,66 @@ +"""E2E: injected transport using httpx (sync). + +This test performs a REAL call against the MultiSafepay test environment. +It runs only when `API_KEY` is set (see `tests/multisafepay/e2e/conftest.py`). + +What this validates +------------------- +- The SDK works end-to-end with an alternative HTTP client (httpx) via + dependency injection (`Sdk(..., transport=...)`). +- The SDK still parses gateway models correctly. + +Async note +---------- +This uses httpx in sync mode. +""" + +from __future__ import annotations + +import os + +import pytest +from dotenv import load_dotenv + +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.gateways.gateway_manager import GatewayManager +from multisafepay.api.paths.gateways.response.gateway import Gateway +from multisafepay.sdk import Sdk + +from tests.support.alt_http_transports import HttpxTransport + + +@pytest.fixture(scope="module") +def gateway_manager() -> GatewayManager: + """Create a GatewayManager using an injected httpx-backed transport. + + Skips if `httpx` is not installed or if `API_KEY` is not set. + """ + pytest.importorskip("httpx") + + load_dotenv() + api_key = os.getenv("API_KEY") + if not api_key: + pytest.skip("API_KEY env var not set") + + # Ensure resources are cleaned up after the module. + transport = HttpxTransport() + try: + sdk = Sdk(api_key=api_key, is_production=False, transport=transport) + yield sdk.get_gateway_manager() + finally: + transport.close() + + +def test_get_gateways_with_injected_httpx_transport( + gateway_manager: GatewayManager, +): + """Fetch gateways through the injected transport and validate parsed models.""" + response = gateway_manager.get_gateways() + assert isinstance(response, CustomApiResponse) + + listing = response.get_data() + assert isinstance(listing, list) + assert len(listing) > 0 + assert all(isinstance(gateway, Gateway) for gateway in listing) diff --git a/tests/multisafepay/e2e/examples/transport/test_custom_requests_session_transport.py b/tests/multisafepay/e2e/examples/transport/test_custom_requests_session_transport.py new file mode 100644 index 0000000..e724672 --- /dev/null +++ b/tests/multisafepay/e2e/examples/transport/test_custom_requests_session_transport.py @@ -0,0 +1,53 @@ +"""Test module for e2e testing. + +This test validates that a custom `requests.Session` can be injected via +`RequestsTransport` and used end-to-end against the MultiSafepay test API. +""" + +import os + +import pytest +from dotenv import load_dotenv + +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.gateways.gateway_manager import GatewayManager +from multisafepay.api.paths.gateways.response.gateway import Gateway +from multisafepay.sdk import Sdk +from multisafepay.transport import RequestsTransport + + +@pytest.fixture(scope="module") +def gateway_manager() -> GatewayManager: + """Fixture that provides a GatewayManager instance using a custom requests.Session.""" + requests = pytest.importorskip("requests") + + load_dotenv() + api_key = os.getenv("API_KEY") + if not api_key: + pytest.skip("API_KEY env var not set") + + session = requests.Session() + session.headers.update({"User-Agent": "multisafepay-sdk-tests"}) + + transport = RequestsTransport(session=session) + multisafepay_sdk = Sdk(api_key, False, transport) + + try: + yield multisafepay_sdk.get_gateway_manager() + finally: + session.close() + + +def test_get_gateways_with_custom_requests_session( + gateway_manager: GatewayManager, +): + """Retrieves gateways and validates parsed models.""" + response = gateway_manager.get_gateways() + assert isinstance(response, CustomApiResponse) + + listing = response.get_data() + assert isinstance(listing, list) + assert len(listing) > 0 + assert all(isinstance(gateway, Gateway) for gateway in listing) diff --git a/tests/multisafepay/e2e/examples/transport/test_custom_urllib3_transport.py b/tests/multisafepay/e2e/examples/transport/test_custom_urllib3_transport.py new file mode 100644 index 0000000..97008d2 --- /dev/null +++ b/tests/multisafepay/e2e/examples/transport/test_custom_urllib3_transport.py @@ -0,0 +1,63 @@ +"""E2E: injected transport using urllib3. + +This test performs a REAL call against the MultiSafepay test environment. +It runs only when `API_KEY` is set (see `tests/multisafepay/e2e/conftest.py`). + +What this validates +------------------- +- The SDK works end-to-end with an alternative HTTP client (urllib3) via + dependency injection (`Sdk(..., transport=...)`). +- The SDK still parses gateway models correctly. + +Why an adapter is needed +------------------------ +`urllib3` does not expose the same response interface as requests/httpx (e.g. +`.json()` / `.raise_for_status()` / `status_code`), so we use a small adapter +transport from `tests/support`. +""" + +from __future__ import annotations + +import os + +import pytest +from dotenv import load_dotenv + +from multisafepay.api.base.response.custom_api_response import ( + CustomApiResponse, +) +from multisafepay.api.paths.gateways.gateway_manager import GatewayManager +from multisafepay.api.paths.gateways.response.gateway import Gateway +from multisafepay.sdk import Sdk + +from tests.support.alt_http_transports import Urllib3Transport + + +@pytest.fixture(scope="module") +def gateway_manager() -> GatewayManager: + """Create a GatewayManager using an injected urllib3-backed transport. + + Skips if `urllib3` is not installed or if `API_KEY` is not set. + """ + pytest.importorskip("urllib3") + + load_dotenv() + api_key = os.getenv("API_KEY") + if not api_key: + pytest.skip("API_KEY env var not set") + + transport = Urllib3Transport() + sdk = Sdk(api_key=api_key, is_production=False, transport=transport) + return sdk.get_gateway_manager() + + +def test_get_gateways_with_injected_urllib3_transport( + gateway_manager: GatewayManager, +): + """Fetch gateways through the injected transport and validate parsed models.""" + response = gateway_manager.get_gateways() + assert isinstance(response, CustomApiResponse) + listing = response.get_data() + assert isinstance(listing, list) + assert len(listing) > 0 + assert all(isinstance(gateway, Gateway) for gateway in listing) diff --git a/tests/multisafepay/unit/client/test_unit_client.py b/tests/multisafepay/unit/client/test_unit_client.py index 5575186..f7c22d9 100644 --- a/tests/multisafepay/unit/client/test_unit_client.py +++ b/tests/multisafepay/unit/client/test_unit_client.py @@ -8,23 +8,26 @@ """Test module for unit testing.""" -from unittest.mock import Mock -from requests import Session +import pytest + +requests = pytest.importorskip("requests") + from multisafepay.client.client import Client +from multisafepay.transport import RequestsTransport -def test_initializes_with_default_http_client(): - """Test that the Client initializes with the default HTTP client.""" +def test_initializes_with_default_requests_transport(): + """Test that the Client initializes with the default requests transport.""" client = Client(api_key="mock_api_key", is_production=False) - assert isinstance(client.http_client, Session) - - -def test_initializes_with_custom_http_client(): - """Test that the Client initializes with a custom HTTP client.""" - custom_http_client = Mock() - client = Client( - api_key="mock_api_key", - is_production=False, - http_client=custom_http_client, - ) - assert client.http_client == custom_http_client + assert isinstance(client.transport, RequestsTransport) + assert isinstance(client.transport.session, requests.Session) + + +def test_initializes_with_custom_requests_session_via_transport(): + """Test that the Client can be initialized with a custom requests.Session via transport.""" + session = requests.Session() + transport = RequestsTransport(session=session) + client = Client(api_key="mock_api_key", is_production=False, transport=transport) + assert client.transport is transport + assert client.transport.session is session + session.close() diff --git a/tests/multisafepay/unit/transport/test_unit_transport.py b/tests/multisafepay/unit/transport/test_unit_transport.py new file mode 100644 index 0000000..9de28af --- /dev/null +++ b/tests/multisafepay/unit/transport/test_unit_transport.py @@ -0,0 +1,181 @@ +# Copyright (c) MultiSafepay, Inc. All rights reserved. + +# This file is licensed under the Open Software License (OSL) version 3.0. +# For a copy of the license, see the LICENSE.txt file in the project root. + +# See the DISCLAIMER.md file for disclaimer details. + +"""Test module for HTTPTransport implementations.""" + +import pytest +from unittest.mock import Mock + +from multisafepay.sdk import Sdk +from multisafepay.transport import RequestsTransport +from tests.support.mock_transport import MockResponse, MockTransport + + +@pytest.fixture +def requires_requests(): + """Skip tests when the optional `requests` dependency isn't installed.""" + return pytest.importorskip("requests") + + +class TestRequestsTransportWithRequests: + """RequestsTransport behavior when `requests` is available.""" + + def test_initializes_session_default_or_custom(self, requires_requests): + assert requires_requests is not None + custom_session = Mock() + + transport_default = RequestsTransport() + assert transport_default.session is not None + + transport_custom = RequestsTransport(session=custom_session) + assert transport_custom.session is custom_session + + def test_request_delegates_to_session(self, requires_requests): + mock_session = Mock() + prepared = object() + mock_response = Mock() + mock_session.prepare_request.return_value = prepared + mock_session.send.return_value = mock_response + + transport = RequestsTransport(session=mock_session) + response = transport.request( + method="GET", + url="https://api.example.com/resource", + headers={"Authorization": "Bearer test"}, + ) + + assert response is mock_response + mock_session.prepare_request.assert_called_once() + request_obj = mock_session.prepare_request.call_args.args[0] + assert isinstance(request_obj, requires_requests.Request) + mock_session.send.assert_called_once_with(prepared) + + def test_context_manager_closes_session(self, requires_requests): + assert requires_requests is not None + mock_session = Mock() + transport = RequestsTransport(session=mock_session) + + with transport as entered: + assert entered is transport + + mock_session.close.assert_called_once() + + +class TestRequestsTransportWithoutRequests: + """Failure modes when `requests` isn't installed and no transport is injected.""" + + def test_raises_clear_error_when_requests_missing(self, monkeypatch): + import multisafepay.transport.requests_transport as requests_transport + + monkeypatch.setattr(requests_transport, "_HAS_REQUESTS", False) + monkeypatch.setattr( + requests_transport, + "_REQUESTS_IMPORT_ERROR", + ModuleNotFoundError("No module named 'requests'"), + ) + + with pytest.raises( + ModuleNotFoundError, match="Optional dependency 'requests'" + ): + RequestsTransport() + + def test_sdk_does_not_touch_requests_when_transport_injected( + self, monkeypatch + ): + import multisafepay.client.client as client_module + + def _boom(): + raise AssertionError("RequestsTransport() should not be called") + + monkeypatch.setattr(client_module, "RequestsTransport", _boom) + + sdk = Sdk( + api_key="test_api_key", + is_production=False, + transport=MockTransport(), + ) + assert sdk.client.transport is not None + + def test_sdk_raises_when_requests_missing_and_no_transport( + self, monkeypatch + ): + import multisafepay.transport.requests_transport as requests_transport + + monkeypatch.setattr(requests_transport, "_HAS_REQUESTS", False) + monkeypatch.setattr( + requests_transport, + "_REQUESTS_IMPORT_ERROR", + ModuleNotFoundError("No module named 'requests'"), + ) + + with pytest.raises( + ModuleNotFoundError, match="multisafepay\\[requests\\]" + ): + Sdk(api_key="test_api_key", is_production=False) + + +class TestMockTransport: + """Test suite for MockTransport.""" + + @pytest.fixture + def transport(self): + return MockTransport() + + def test_request_fifo_and_history(self, transport): + transport.add_response(MockResponse(status_code=200, json_data={"id": 1})) + transport.add_response(MockResponse(status_code=201, json_data={"id": 2})) + + first = transport.request( + "GET", + "https://api.example.com/1", + headers={"Authorization": "Bearer token"}, + ) + second = transport.request("POST", "https://api.example.com/2") + assert first.json()["id"] == 1 + assert second.json()["id"] == 2 + + assert len(transport.request_history) == 2 + assert transport.request_history[0]["method"] == "GET" + assert transport.get_last_request()["url"] == "https://api.example.com/2" + + def test_request_raises_when_no_responses(self, transport): + with pytest.raises(RuntimeError, match="No mock responses available"): + transport.request("GET", "https://api.example.com") + + def test_response_factory(self): + def factory(method, _url, _kwargs): + status = 200 if method == "GET" else 201 + return MockResponse(status_code=status, json_data={"method": method}) + + transport = MockTransport(response_factory=factory) + + assert transport.request("GET", "https://api.example.com").json()["method"] == "GET" + assert transport.request("POST", "https://api.example.com").json()["method"] == "POST" + + +class TestMockResponse: + """Test suite for MockResponse.""" + + def test_values_default_and_custom(self): + default = MockResponse() + assert default.status_code == 200 + assert default.headers == {} + assert default.json() == {} + + custom = MockResponse( + status_code=201, + json_data={"id": 123, "name": "test"}, + headers={"Content-Type": "application/json"}, + ) + assert custom.status_code == 201 + assert custom.json()["id"] == 123 + assert custom.headers["Content-Type"] == "application/json" + + def test_raise_for_status(self): + MockResponse(status_code=200).raise_for_status() + with pytest.raises(Exception, match="HTTP Error 404"): + MockResponse(status_code=404).raise_for_status() diff --git a/tests/multisafepay/unit/transport/test_unit_transport_selection.py b/tests/multisafepay/unit/transport/test_unit_transport_selection.py new file mode 100644 index 0000000..7c15bf0 --- /dev/null +++ b/tests/multisafepay/unit/transport/test_unit_transport_selection.py @@ -0,0 +1,95 @@ +"""Unit tests for transport selection behavior. + +We validate the three main behaviors and their combinations: + +- Injected transport always wins (even if `requests` is missing). +- Without an injected transport, the SDK defaults to RequestsTransport. +- Without an injected transport and without `requests`, the SDK fails fast with a clear error. +""" + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from multisafepay.sdk import Sdk +from tests.support.mock_transport import MockTransport + + +def test_injected_transport_wins_when_requests_installed(monkeypatch): + """Injected transport must be used; RequestsTransport must not be instantiated.""" + import multisafepay.client.client as client_module + + def _boom(): + raise AssertionError("RequestsTransport() should not be called") + + monkeypatch.setattr(client_module, "RequestsTransport", _boom) + + injected_transport = MockTransport() + sdk = Sdk( + api_key="test_api_key", + is_production=False, + transport=injected_transport, + ) + assert sdk.client.transport is injected_transport + + +def test_injected_transport_wins_when_requests_missing(monkeypatch): + """Injected transport must be used even if `requests` is missing.""" + import multisafepay.client.client as client_module + import multisafepay.transport.requests_transport as requests_transport + + monkeypatch.setattr(requests_transport, "_HAS_REQUESTS", False) + monkeypatch.setattr( + requests_transport, + "_REQUESTS_IMPORT_ERROR", + ModuleNotFoundError("No module named 'requests'"), + ) + + def _boom(): + raise AssertionError("RequestsTransport() should not be called") + + monkeypatch.setattr(client_module, "RequestsTransport", _boom) + + injected_transport = MockTransport() + sdk = Sdk( + api_key="test_api_key", + is_production=False, + transport=injected_transport, + ) + assert sdk.client.transport is injected_transport + + +def test_defaults_to_requests_transport_when_not_injected(monkeypatch): + """When no transport is injected, the SDK should default to RequestsTransport.""" + import multisafepay.client.client as client_module + + sentinel_transport = object() + requests_transport_factory = Mock(return_value=sentinel_transport) + monkeypatch.setattr( + client_module, "RequestsTransport", requests_transport_factory + ) + + sdk = Sdk(api_key="test_api_key", is_production=False) + assert sdk.client.transport is sentinel_transport + requests_transport_factory.assert_called_once_with() + + +def test_raises_clear_error_when_requests_missing_and_not_injected( + monkeypatch, +): + """When no transport is injected and `requests` is missing, the SDK should fail fast.""" + import multisafepay.transport.requests_transport as requests_transport + + monkeypatch.setattr(requests_transport, "_HAS_REQUESTS", False) + monkeypatch.setattr( + requests_transport, + "_REQUESTS_IMPORT_ERROR", + ModuleNotFoundError("No module named 'requests'"), + ) + + with pytest.raises( + ModuleNotFoundError, match="multisafepay\\[requests\\]" + ): + Sdk(api_key="test_api_key", is_production=False) diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/support/alt_http_transports.py b/tests/support/alt_http_transports.py new file mode 100644 index 0000000..f1ba023 --- /dev/null +++ b/tests/support/alt_http_transports.py @@ -0,0 +1,165 @@ +"""Test-only HTTP transports used by integration and end-to-end tests. + +Purpose +------- +The SDK exposes a small transport contract (``HTTPTransport`` / ``HTTPResponse``) +to decouple business logic from a specific HTTP client. Production code defaults +to ``RequestsTransport``, while tests can inject alternative backends such as +``urllib3`` and ``httpx``. + +Scope +----- +This module intentionally lives under ``tests/support`` and is not distributed +as part of the published ``multisafepay`` package. + +Current SDK behavior note +------------------------- +``Client`` builds the request URL and passes it to transports. In normal SDK +flows this module therefore treats the URL as the source of truth for query +parameters. + +For consistency in tests: +- ``Urllib3Transport`` ignores ``params`` and uses the URL as provided. +- ``HttpxTransport`` forwards ``params`` when explicitly supplied. +""" + +from __future__ import annotations + +import json +import httpx +import urllib3 +from dataclasses import dataclass +from typing import Any, Dict, Optional + + +@dataclass +class Urllib3ResponseAdapter: + """Adapter that makes a urllib3 response compatible with HTTPResponse. + + urllib3 returns an HTTPResponse-like object with different attributes: + - status (int) instead of status_code + - data (bytes) instead of json() + + This adapter exposes: + - status_code + - headers as dict[str, str] + - json() parsing data as JSON + - raise_for_status() similar to requests/httpx + """ + + response: Any + + @property + def status_code(self) -> int: + return int(getattr(self.response, "status")) + + @property + def headers(self) -> Dict[str, str]: + raw = getattr(self.response, "headers", {}) + # urllib3 headers behave like a mapping; normalize to dict[str, str]. + return {str(k): str(v) for k, v in dict(raw).items()} + + def json(self) -> Any: + data = getattr(self.response, "data", b"") + if data is None: + return None + if isinstance(data, str): + text = data + else: + text = data.decode("utf-8") + return json.loads(text) if text else {} + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise Exception(f"HTTP Error {self.status_code}") + + +class Urllib3Transport: + """Simple HTTPTransport backed by urllib3.PoolManager. + + This is a test-focused example implementation. It does not try to cover + every possible option; it only provides the minimum needed to demonstrate + transport injection with another popular HTTP backend. + """ + + def __init__(self, pool_manager: Optional[Any] = None) -> None: + self._pool = pool_manager or urllib3.PoolManager() + + def request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + data: Optional[Any] = None, + **kwargs: Any, + ) -> Urllib3ResponseAdapter: + # Intentionally ignore params to avoid duplicates, + # because the SDK Client already builds the query string in url. + # body must be bytes/str. If data is dict/list, serialize it to JSON. + body: Optional[bytes] + if data is None: + body = None + elif isinstance(data, (bytes, bytearray)): + body = bytes(data) + elif isinstance(data, str): + body = data.encode("utf-8") + else: + body = json.dumps(data).encode("utf-8") + + resp = self._pool.request( + method=method, + url=url, + body=body, + headers=headers, + **kwargs, + ) + return Urllib3ResponseAdapter(resp) + + +class HttpxTransport: + """Simple HTTPTransport backed by httpx.Client. + + Used in tests with pytest.importorskip('httpx') so environments that do + not have httpx installed are not forced to install it. + """ + + def __init__(self, client: Optional[Any] = None) -> None: + self.client = client or httpx.Client() + + def request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + data: Optional[Any] = None, + **kwargs: Any, + ) -> Any: + # httpx distinguishes between raw body (content=) and JSON (json=). + # If data is dict/list send as JSON; otherwise send as content. + params = kwargs.pop("params", None) + request_kwargs: Dict[str, Any] = { + "method": method, + "url": url, + "headers": headers, + } + + if params is not None: + request_kwargs["params"] = params + + if data is not None: + if isinstance(data, (dict, list)): + request_kwargs["json"] = data + else: + request_kwargs["content"] = data + + request_kwargs.update(kwargs) + return self.client.request(**request_kwargs) + + def close(self) -> None: + self.client.close() + + def __enter__(self) -> "HttpxTransport": + return self + + def __exit__(self, *args: Any) -> None: + self.close() diff --git a/tests/support/mock_transport.py b/tests/support/mock_transport.py new file mode 100644 index 0000000..2fa407a --- /dev/null +++ b/tests/support/mock_transport.py @@ -0,0 +1,84 @@ +"""Test-only mock HTTP transport utilities. + +These helpers intentionally live under `tests/` (not `src/`) so they are not +shipped as part of the public SDK package. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional + + +@dataclass +class MockResponse: + """Minimal response object compatible with the SDK's expectations.""" + + status_code: int = 200 + json_data: Dict[str, Any] = field(default_factory=dict) + headers: Dict[str, str] = field(default_factory=dict) + + def json(self) -> Dict[str, Any]: + return self.json_data + + def raise_for_status(self) -> None: + if 400 <= self.status_code: + raise Exception(f"HTTP Error {self.status_code}") + + +ResponseFactory = Callable[[str, str, Dict[str, Any]], MockResponse] + + +class MockTransport: + """A simple FIFO mock transport with request history.""" + + def __init__( + self, response_factory: Optional[ResponseFactory] = None + ) -> None: + self.responses: List[MockResponse] = [] + self.request_history: List[Dict[str, Any]] = [] + self._response_factory = response_factory + + def add_response(self, response: MockResponse) -> None: + self.responses.append(response) + + def request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + data: Optional[str] = None, + **kwargs: Any, + ) -> MockResponse: + params = kwargs.get("params") + self.request_history.append( + { + "method": method, + "url": url, + "headers": headers or {}, + "params": params, + "data": data, + "kwargs": kwargs, + } + ) + + if self._response_factory is not None: + return self._response_factory( + method, + url, + {"headers": headers, "params": params, "data": data, **kwargs}, + ) + + if not self.responses: + raise RuntimeError("No mock responses available") + + return self.responses.pop(0) + + def get_last_request(self) -> Dict[str, Any]: + if not self.request_history: + raise RuntimeError("No requests recorded") + return self.request_history[-1] + + def reset(self) -> None: + self.responses.clear() + self.request_history.clear()