diff --git a/.gitignore b/.gitignore index 5f6a183..479dc14 100644 --- a/.gitignore +++ b/.gitignore @@ -132,6 +132,7 @@ venv/ ENV/ env.bak/ venv.bak/ +openapi-python-sdk/ # Spyder project settings .spyderproject diff --git a/Makefile b/Makefile index 95b027b..ac3c714 100644 --- a/Makefile +++ b/Makefile @@ -79,4 +79,10 @@ release: publish @echo "Tagging release $(VERSION)..." @git tag -fa "$(VERSION)" -m "Release $(VERSION)" @git push origin --tags -f - @echo "Released $(VERSION) successfully." \ No newline at end of file + @echo "Released $(VERSION) successfully." + +github-release: + @gh release create "$(VERSION)" \ + --title "Openapi® Python SDK v$(VERSION)" \ + --notes-file docs/release.md \ + --latest diff --git a/README.md b/README.md index 5083a7f..9c91bc2 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,10 @@ Before using the Openapi Python Client, you will need an account at [Openapi](ht ## Features - **Agnostic Design**: No API-specific classes, works with any OpenAPI service -- **Minimal Dependencies**: Only requires Python 3.8+ and `requests` +- **Minimal Dependencies**: Only requires Python 3.8+ and `httpx` - **OAuth Support**: Built-in OAuth client for token management - **HTTP Primitives**: GET, POST, PUT, DELETE, PATCH methods +- **Async Support**: Fully compatible with async frameworks like FastAPI and aiohttp - **Clean Interface**: Similar to the Rust SDK design ## What you can do @@ -70,7 +71,7 @@ Interaction with the Openapi platform happens in two distinct steps. Authenticate with your credentials and obtain a short-lived bearer token scoped to the endpoints you need. ```python -from openapi_python_sdk.client import OauthClient +from openapi_python_sdk import OauthClient oauth = OauthClient(username="", apikey="", test=True) @@ -92,7 +93,7 @@ oauth.delete_token(id=token) Use the token to make authenticated requests to any Openapi service. ```python -from openapi_python_sdk.client import Client +from openapi_python_sdk import Client client = Client(token=token) @@ -111,6 +112,69 @@ resp = client.request( ) ``` +### Customizing the Transport Layer + +If you need to configure custom retry logic, proxies, or use a different HTTP client (such as passing a `requests.Session` with a custom urllib3 `Retry`), you can inject it directly using the `client` parameter on any SDK class: + +```python +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry +from openapi_python_sdk import Client +import requests + +retry = Retry(total=3) +adapter = HTTPAdapter(max_retries=retry) + +session = requests.Session() +session.mount("https://", adapter) + +# Pass the custom session to the Client explicitly +client = Client("token", client=session) +``` + +### Configuring Network Timeouts + +By default, the SDK uses a 30-second timeout for all network requests to avoid hanging on slow network connections or heavy API operations. + +You can easily override it passing a `timeout` explicitly during initialization to all client variants: + +```python +from openapi_python_sdk import Client + +client = Client(token="token", timeout=60.0) # 60 seconds +``` + +## Async Usage + +The SDK provides `AsyncClient` and `AsyncOauthClient` for use with asynchronous frameworks like FastAPI or `aiohttp`. + +### Async Authentication + +```python +from openapi_python_sdk import AsyncOauthClient + +async with AsyncOauthClient(username="", apikey="", test=True) as oauth: + resp = await oauth.create_token( + scopes=["GET:test.imprese.openapi.it/advance"], + ttl=3600, + ) + token = resp["token"] +``` + +### Async Requests + +```python +from openapi_python_sdk import AsyncClient + +async with AsyncClient(token=token) as client: + resp = await client.request( + method="GET", + url="https://test.imprese.openapi.it/advance", + params={"denominazione": "altravia"}, + ) +``` + + ## Testing Install dev dependencies and run the test suite: @@ -174,4 +238,3 @@ The MIT License is a permissive open-source license that allows you to freely us In short, you are free to use this SDK in your personal, academic, or commercial projects, with minimal restrictions. The project is provided "as-is", without any warranty of any kind, either expressed or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement. For more details, see the full license text at the [MIT License page](https://choosealicense.com/licenses/mit/). - diff --git a/docs/readme-pypi.md b/docs/readme-pypi.md index 60c3790..fd93bb6 100644 --- a/docs/readme-pypi.md +++ b/docs/readme-pypi.md @@ -60,6 +60,16 @@ resp = client.request( ) ``` +### Configuring Network Timeouts + +By default, the SDK uses a 30-second timeout for all network requests. You can easily override it passing a `timeout` explicitly during initialization: + +```python +from openapi_python_sdk.client import Client + +client = Client(token="token", timeout=60.0) # 60 seconds +``` + ## Testing ```bash @@ -71,7 +81,7 @@ pytest | Method | Description | |---|---| -| `OauthClient(username, apikey, test=False)` | Initialize the OAuth client. Set `test=True` for sandbox. | +| `OauthClient(username, apikey, test=False, timeout=30.0)` | Initialize the OAuth client. Set `test=True` for sandbox. | | `create_token(scopes, ttl)` | Create a bearer token for the given scopes and TTL (seconds). | | `get_token(scope)` | Retrieve an existing token by scope. | | `delete_token(id)` | Revoke a token by ID. | @@ -82,7 +92,7 @@ pytest | Method | Description | |---|---| -| `Client(token)` | Initialize the client with a bearer token. | +| `Client(token, timeout=30.0)` | Initialize the client with a bearer token. | | `request(method, url, payload, params)` | Execute an HTTP request against any Openapi endpoint. | ## Links diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..a9d73cb --- /dev/null +++ b/docs/release.md @@ -0,0 +1,36 @@ +## Release 0.3.0 — Openapi® Python SDK Evolves + +The **0.3.0** release of the Openapi® Python SDK introduces powerful new features, critical bug fixes, and significant stability improvements that make it even easier to build robust and reliable API clients. + +### 🚀 New Features + +* **Async Support & Context Managers**: Full `httpx`-based async client support with context manager support for all clients, enabling modern asynchronous Python workflows. +* **Configurable Timeouts**: Added configurable 30-second default timeouts for both sync and async clients, with the ability to customize timeout values during OAuth client initialization. +* **Thread-Safe OAuth Client**: `OauthClient` is now thread-safe using `threading.local`, allowing shared instances across multiple threads without conflicts. +* **Custom HTTP Client Injection**: Advanced users can now inject custom HTTP clients for tailored transport layers and retry configurations. +* **Refactored Architecture**: Client classes have been extracted into separate module files without breaking the existing API, improving maintainability and clarity. + +### 🔧 Bug Fixes + +* **Query Parameter Encoding**: Fixed an issue where special characters in query parameters caused `400 Bad Request` errors by properly encoding them with `urllib`. +* **Linting & Tests**: Resolved ruff formatting issues and cleaned up unused variables in test suites. + +### 🧪 Quality & Testing + +* Added a comprehensive thread-safety verification suite for synchronous clients. +* Updated documentation with examples for configuring network timeouts and customizing transport layers. + +### 🎯 Why This Matters + +* Safer concurrent usage with thread-safe OAuth handling. +* Greater control over network behavior with configurable timeouts and custom HTTP clients. +* More predictable request handling with properly encoded query parameters. +* Cleaner codebase architecture for easier future contributions. + +### 🙏 Special Thanks + +A huge thank you to **[@Deadpool2000](https://github.com/Deadpool2000)** for their outstanding contributions to this release. Their work on thread-safety, configurable timeouts, custom HTTP client support, query parameter encoding fixes, and overall code quality has been invaluable. This release would not have been possible without their dedication and effort. + +### 🚀 Looking Ahead + +Version **0.3.0** marks another major step toward a more mature and developer-friendly Python SDK. Future releases will continue to focus on performance enhancements, broader interoperability, and even more seamless developer experiences. diff --git a/openapi_python_sdk/__init__.py b/openapi_python_sdk/__init__.py index e69de29..7ca57cc 100644 --- a/openapi_python_sdk/__init__.py +++ b/openapi_python_sdk/__init__.py @@ -0,0 +1,10 @@ +""" +Openapi Python SDK - A minimal and agnostic SDK for the Openapi marketplace. +Exports both synchronous and asynchronous clients. +""" +from .async_client import AsyncClient +from .async_oauth_client import AsyncOauthClient +from .client import Client +from .oauth_client import OauthClient + +__all__ = ["Client", "AsyncClient", "OauthClient", "AsyncOauthClient"] diff --git a/openapi_python_sdk/async_client.py b/openapi_python_sdk/async_client.py new file mode 100644 index 0000000..fd6af56 --- /dev/null +++ b/openapi_python_sdk/async_client.py @@ -0,0 +1,69 @@ +import json +from typing import Any, Dict + +import httpx + + +class AsyncClient: + """ + Asynchronous client for making authenticated requests to Openapi endpoints. + Suitable for use with FastAPI, aiohttp, etc. + """ + + def __init__(self, token: str, client: Any = None, timeout: float = 30.0): + self.client = client if client is not None else httpx.AsyncClient(timeout=timeout) + self.auth_header: str = f"Bearer {token}" + self.headers: Dict[str, str] = { + "Authorization": self.auth_header, + "Content-Type": "application/json", + } + + async def __aenter__(self): + """Enable use as an asynchronous context manager.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Ensure the underlying HTTP client is closed on exit (async).""" + await self.client.aclose() + + async def aclose(self): + """Manually close the underlying HTTP client (async).""" + await self.client.aclose() + + async def request( + self, + method: str = "GET", + url: str = None, + payload: Dict[str, Any] = None, + params: Dict[str, Any] = None, + ) -> Dict[str, Any]: + """ + Make an asynchronous HTTP request to the specified Openapi endpoint. + """ + payload = payload or {} + params = params or {} + url = url or "" + + if params: + import urllib.parse + query_string = urllib.parse.urlencode(params, doseq=True) + url = f"{url}&{query_string}" if "?" in url else f"{url}?{query_string}" + params = None + + resp = await self.client.request( + method=method, + url=url, + headers=self.headers, + json=payload, + params=params, + ) + data = resp.json() + + # Handle cases where the API might return a JSON-encoded string instead of an object + if isinstance(data, str): + try: + data = json.loads(data) + except json.JSONDecodeError: + pass + + return data diff --git a/openapi_python_sdk/async_oauth_client.py b/openapi_python_sdk/async_oauth_client.py new file mode 100644 index 0000000..1de3fbd --- /dev/null +++ b/openapi_python_sdk/async_oauth_client.py @@ -0,0 +1,69 @@ +import base64 +from typing import Any, Dict, List + +import httpx + +from .oauth_client import OAUTH_BASE_URL, TEST_OAUTH_BASE_URL + + +class AsyncOauthClient: + """ + Asynchronous client for handling Openapi authentication and token management. + Suitable for use with FastAPI, aiohttp, etc. + """ + + def __init__(self, username: str, apikey: str, test: bool = False, client: Any = None, timeout: float = 30.0): + self.client = client if client is not None else httpx.AsyncClient(timeout=timeout) + self.url: str = TEST_OAUTH_BASE_URL if test else OAUTH_BASE_URL + self.auth_header: str = ( + "Basic " + base64.b64encode(f"{username}:{apikey}".encode("utf-8")).decode() + ) + self.headers: Dict[str, Any] = { + "Authorization": self.auth_header, + "Content-Type": "application/json", + } + + async def __aenter__(self): + """Enable use as an asynchronous context manager.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Ensure the underlying HTTP client is closed on exit (async).""" + await self.client.aclose() + + async def aclose(self): + """Manually close the underlying HTTP client (async).""" + await self.client.aclose() + + async def get_scopes(self, limit: bool = False) -> Dict[str, Any]: + """Retrieve available scopes for the current user (async).""" + params = {"limit": int(limit)} + url = f"{self.url}/scopes" + resp = await self.client.get(url=url, headers=self.headers, params=params) + return resp.json() + + async def create_token(self, scopes: List[str] = [], ttl: int = 0) -> Dict[str, Any]: + """Create a new bearer token with specified scopes and TTL (async).""" + payload = {"scopes": scopes, "ttl": ttl} + url = f"{self.url}/token" + resp = await self.client.post(url=url, headers=self.headers, json=payload) + return resp.json() + + async def get_token(self, scope: str = None) -> Dict[str, Any]: + """Retrieve an existing token, optionally filtered by scope (async).""" + params = {"scope": scope or ""} + url = f"{self.url}/token" + resp = await self.client.get(url=url, headers=self.headers, params=params) + return resp.json() + + async def delete_token(self, id: str) -> Dict[str, Any]: + """Revoke/Delete a specific token by ID (async).""" + url = f"{self.url}/token/{id}" + resp = await self.client.delete(url=url, headers=self.headers) + return resp.json() + + async def get_counters(self, period: str, date: str) -> Dict[str, Any]: + """Retrieve usage counters for a specific period and date (async).""" + url = f"{self.url}/counters/{period}/{date}" + resp = await self.client.get(url=url, headers=self.headers) + return resp.json() diff --git a/openapi_python_sdk/client.py b/openapi_python_sdk/client.py index 4268bda..4ba5211 100644 --- a/openapi_python_sdk/client.py +++ b/openapi_python_sdk/client.py @@ -1,58 +1,59 @@ -import base64 +import json +import threading from typing import Any, Dict import httpx -import json +# Backward compatibility imports +from .async_client import AsyncClient # noqa: F401 +from .async_oauth_client import AsyncOauthClient # noqa: F401 +from .oauth_client import OauthClient # noqa: F401 -OAUTH_BASE_URL = "https://oauth.openapi.it" -TEST_OAUTH_BASE_URL = "https://test.oauth.openapi.it" +class Client: + """ + Synchronous client for making authenticated requests to Openapi endpoints. + """ -class OauthClient: - def __init__(self, username: str, apikey: str, test: bool = False): - self.client = httpx.Client() - self.url: str = TEST_OAUTH_BASE_URL if test else OAUTH_BASE_URL - self.auth_header: str = ( - "Basic " + base64.b64encode(f"{username}:{apikey}".encode("utf-8")).decode() - ) - self.headers: Dict[str, Any] = { + def __init__(self, token: str, client: Any = None, timeout: float = 30.0): + self._client = client + self._thread_local = threading.local() + self.timeout = timeout + self.auth_header: str = f"Bearer {token}" + self.headers: Dict[str, str] = { "Authorization": self.auth_header, "Content-Type": "application/json", } - def get_scopes(self, limit: bool = False) -> Dict[str, Any]: - params = {"limit": int(limit)} - url = f"{self.url}/scopes" - return self.client.get(url=url, headers=self.headers, params=params).json() - - def create_token(self, scopes: list[str] = [], ttl: int = 0) -> Dict[str, Any]: - payload = {"scopes": scopes, "ttl": ttl} - url = f"{self.url}/token" - return self.client.post(url=url, headers=self.headers, json=payload).json() + @property + def client(self) -> Any: + """ + Thread-safe access to the underlying HTTP client. + If a custom client was provided at initialization, it is returned. + Otherwise, a thread-local httpx.Client is created and returned. + """ + if self._client is not None: + return self._client - def get_token(self, scope: str = None) -> Dict[str, Any]: - params = {"scope": scope or ""} - url = f"{self.url}/token" - return self.client.get(url=url, headers=self.headers, params=params).json() + if not hasattr(self._thread_local, "client"): + self._thread_local.client = httpx.Client(timeout=self.timeout) + return self._thread_local.client - def delete_token(self, id: str) -> Dict[str, Any]: - url = f"{self.url}/token/{id}" - return self.client.delete(url=url, headers=self.headers).json() + @client.setter + def client(self, value: Any): + self._client = value - def get_counters(self, period: str, date: str) -> Dict[str, Any]: - url = f"{self.url}/counters/{period}/{date}" - return self.client.get(url=url, headers=self.headers).json() + def __enter__(self): + """Enable use as a synchronous context manager.""" + return self + def __exit__(self, exc_type, exc_val, exc_tb): + """Ensure the underlying HTTP client is closed on exit.""" + self.client.close() -class Client: - def __init__(self, token: str): - self.client = httpx.Client() - self.auth_header: str = f"Bearer {token}" - self.headers: Dict[str, str] = { - "Authorization": self.auth_header, - "Content-Type": "application/json", - } + def close(self): + """Manually close the underlying HTTP client.""" + self.client.close() def request( self, @@ -61,9 +62,19 @@ def request( payload: Dict[str, Any] = None, params: Dict[str, Any] = None, ) -> Dict[str, Any]: + """ + Make a synchronous HTTP request to the specified Openapi endpoint. + """ payload = payload or {} params = params or {} url = url or "" + + if params: + import urllib.parse + query_string = urllib.parse.urlencode(params, doseq=True) + url = f"{url}&{query_string}" if "?" in url else f"{url}?{query_string}" + params = None + data = self.client.request( method=method, url=url, @@ -72,9 +83,10 @@ def request( params=params, ).json() - if isinstance(data,str): + # Handle cases where the API might return a JSON-encoded string instead of an object + if isinstance(data, str): try: - data=json.loads(data) + data = json.loads(data) except json.JSONDecodeError: pass - return data \ No newline at end of file + return data diff --git a/openapi_python_sdk/oauth_client.py b/openapi_python_sdk/oauth_client.py new file mode 100644 index 0000000..a3be9db --- /dev/null +++ b/openapi_python_sdk/oauth_client.py @@ -0,0 +1,84 @@ +import base64 +import threading +from typing import Any, Dict, List + +import httpx + +OAUTH_BASE_URL = "https://oauth.openapi.it" +TEST_OAUTH_BASE_URL = "https://test.oauth.openapi.it" + + +class OauthClient: + """ + Synchronous client for handling Openapi authentication and token management. + """ + + def __init__(self, username: str, apikey: str, test: bool = False, client: Any = None, timeout: float = 30.0): + self._client = client + self._thread_local = threading.local() + self.timeout = timeout + self.url: str = TEST_OAUTH_BASE_URL if test else OAUTH_BASE_URL + self.auth_header: str = ( + "Basic " + base64.b64encode(f"{username}:{apikey}".encode("utf-8")).decode() + ) + self.headers: Dict[str, Any] = { + "Authorization": self.auth_header, + "Content-Type": "application/json", + } + + @property + def client(self) -> Any: + """ + Thread-safe access to the underlying HTTP client. + If a custom client was provided at initialization, it is returned. + Otherwise, a thread-local httpx.Client is created and returned. + """ + if self._client is not None: + return self._client + if not hasattr(self._thread_local, "client"): + self._thread_local.client = httpx.Client(timeout=self.timeout) + return self._thread_local.client + + @client.setter + def client(self, value: Any): + self._client = value + + def __enter__(self): + """Enable use as a synchronous context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Ensure the underlying HTTP client is closed on exit.""" + self.client.close() + + def close(self): + """Manually close the underlying HTTP client.""" + self.client.close() + + def get_scopes(self, limit: bool = False) -> Dict[str, Any]: + """Retrieve available scopes for the current user.""" + params = {"limit": int(limit)} + url = f"{self.url}/scopes" + return self.client.get(url=url, headers=self.headers, params=params).json() + + def create_token(self, scopes: List[str] = [], ttl: int = 0) -> Dict[str, Any]: + """Create a new bearer token with specified scopes and TTL.""" + payload = {"scopes": scopes, "ttl": ttl} + url = f"{self.url}/token" + return self.client.post(url=url, headers=self.headers, json=payload).json() + + def get_token(self, scope: str = None) -> Dict[str, Any]: + """Retrieve an existing token, optionally filtered by scope.""" + params = {"scope": scope or ""} + url = f"{self.url}/token" + return self.client.get(url=url, headers=self.headers, params=params).json() + + def delete_token(self, id: str) -> Dict[str, Any]: + """Revoke/Delete a specific token by ID.""" + url = f"{self.url}/token/{id}" + return self.client.delete(url=url, headers=self.headers).json() + + def get_counters(self, period: str, date: str) -> Dict[str, Any]: + """Retrieve usage counters for a specific period and date.""" + url = f"{self.url}/counters/{period}/{date}" + return self.client.get(url=url, headers=self.headers).json() diff --git a/poetry.lock b/poetry.lock index a2f6ff7..c5dbecb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,61 +1,105 @@ +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. + [[package]] name = "anyio" -version = "3.6.2" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -category = "main" +version = "4.13.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false -python-versions = ">=3.6.2" +python-versions = ">=3.10" +files = [ + {file = "anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708"}, + {file = "anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc"}, +] [package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" -sniffio = ">=1.1" +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] -trio = ["trio (>=0.16,<0.22)"] +trio = ["trio (>=0.32.0)"] [[package]] name = "certifi" -version = "2022.12.7" +version = "2026.2.25" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" +files = [ + {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"}, + {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] [[package]] name = "h11" version = "0.14.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] [[package]] name = "httpcore" -version = "0.17.0" +version = "0.17.3" description = "A minimal low-level HTTP client." -category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"}, + {file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"}, +] [package.dependencies] anyio = ">=3.0,<5.0" certifi = "*" h11 = ">=0.13,<0.15" -sniffio = ">=1.0.0,<2.0.0" +sniffio = "==1.*" [package.extras] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "httpx" -version = "0.24.0" +version = "0.24.1" description = "The next generation HTTP client." -category = "main" optional = false python-versions = ">=3.7" +files = [ + {file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"}, + {file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"}, +] [package.dependencies] certifi = "*" @@ -65,57 +109,203 @@ sniffio = "*" [package.extras] brotli = ["brotli", "brotlicffi"] -cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] -socks = ["socksio (>=1.0.0,<2.0.0)"] +socks = ["socksio (==1.*)"] [[package]] name = "idna" -version = "3.4" +version = "3.11" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.8" +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] -name = "sniffio" -version = "1.3.0" -description = "Sniff out which async library your code is running under" -category = "main" +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] -[metadata] -lock-version = "1.1" -python-versions = "^3.10" -content-hash = "a660465356539cfe3153796ea737bd8e8c3d28c85befb9dd71dc2d92d9ed90d7" +[[package]] +name = "packaging" +version = "26.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] -[metadata.files] -anyio = [ - {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, - {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] -certifi = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.20.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] -h11 = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "8.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] -httpcore = [ - {file = "httpcore-0.17.0-py3-none-any.whl", hash = "sha256:0fdfea45e94f0c9fd96eab9286077f9ff788dd186635ae61b312693e4d943599"}, - {file = "httpcore-0.17.0.tar.gz", hash = "sha256:cc045a3241afbf60ce056202301b4d8b6af08845e3294055eb26b09913ef903c"}, + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "ruff" +version = "0.4.10" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c2c4d0859305ac5a16310eec40e4e9a9dec5dcdfbe92697acd99624e8638dac"}, + {file = "ruff-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a79489607d1495685cdd911a323a35871abfb7a95d4f98fc6f85e799227ac46e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1dd1681dfa90a41b8376a61af05cc4dc5ff32c8f14f5fe20dba9ff5deb80cd6"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c75c53bb79d71310dc79fb69eb4902fba804a81f374bc86a9b117a8d077a1784"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18238c80ee3d9100d3535d8eb15a59c4a0753b45cc55f8bf38f38d6a597b9739"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d8f71885bce242da344989cae08e263de29752f094233f932d4f5cfb4ef36a81"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:330421543bd3222cdfec481e8ff3460e8702ed1e58b494cf9d9e4bf90db52b9d"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e9b6fb3a37b772628415b00c4fc892f97954275394ed611056a4b8a2631365e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f54c481b39a762d48f64d97351048e842861c6662d63ec599f67d515cb417f6"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67fe086b433b965c22de0b4259ddfe6fa541c95bf418499bedb9ad5fb8d1c631"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:acfaaab59543382085f9eb51f8e87bac26bf96b164839955f244d07125a982ef"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3cea07079962b2941244191569cf3a05541477286f5cafea638cd3aa94b56815"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:338a64ef0748f8c3a80d7f05785930f7965d71ca260904a9321d13be24b79695"}, + {file = "ruff-0.4.10-py3-none-win32.whl", hash = "sha256:ffe3cd2f89cb54561c62e5fa20e8f182c0a444934bf430515a4b422f1ab7b7ca"}, + {file = "ruff-0.4.10-py3-none-win_amd64.whl", hash = "sha256:67f67cef43c55ffc8cc59e8e0b97e9e60b4837c8f21e8ab5ffd5d66e196e25f7"}, + {file = "ruff-0.4.10-py3-none-win_arm64.whl", hash = "sha256:dd1fcee327c20addac7916ca4e2653fbbf2e8388d8a6477ce5b4e986b68ae6c0"}, + {file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"}, ] -httpx = [ - {file = "httpx-0.24.0-py3-none-any.whl", hash = "sha256:447556b50c1921c351ea54b4fe79d91b724ed2b027462ab9a329465d147d5a4e"}, - {file = "httpx-0.24.0.tar.gz", hash = "sha256:507d676fc3e26110d41df7d35ebd8b3b8585052450f4097401c9be59d928c63e"}, + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] -idna = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, + +[[package]] +name = "tomli" +version = "2.4.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] -sniffio = [ - {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, - {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] + +[metadata] +lock-version = "2.0" +python-versions = "^3.10" +content-hash = "86c19a5b5fbff37939a046e2d03346f96c299b0239de4f6aadfe66d63c64b024" diff --git a/pyproject.toml b/pyproject.toml index ccb5cbc..ac21072 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "openapi-python-sdk" -version = "0.2.0" +version = "0.3.0" description = "A minimal Python SDK for the Openapi® API marketplace" authors = ["Michael Cuffaro "] readme = "docs/readme-pypi.md" diff --git a/tests/test_async_client.py b/tests/test_async_client.py new file mode 100644 index 0000000..461305b --- /dev/null +++ b/tests/test_async_client.py @@ -0,0 +1,100 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from openapi_python_sdk.client import AsyncClient, AsyncOauthClient + + +class TestAsyncOauthClient(unittest.IsolatedAsyncioTestCase): + """ + Test suite for AsyncOauthClient using IsolatedAsyncioTestCase + which allows for native await calls in test methods. + """ + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + async def test_create_token(self, mock_httpx): + # Mocking the response and the post method + mock_resp = MagicMock() + mock_resp.json.return_value = {"token": "abc123"} + mock_httpx.return_value.post = AsyncMock(return_value=mock_resp) + mock_httpx.return_value.aclose = AsyncMock() + + # Testing the async context manager (__aenter__ / __aexit__) + async with AsyncOauthClient(username="user", apikey="key", test=True) as oauth: + resp = await oauth.create_token(scopes=["GET:test.example.com/api"], ttl=3600) + + self.assertEqual(resp["token"], "abc123") + mock_httpx.return_value.post.assert_called_once() + # Verify aclose was called by the context manager + mock_httpx.return_value.aclose.assert_called_once() + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + async def test_get_scopes(self, mock_httpx): + mock_resp = MagicMock() + mock_resp.json.return_value = {"scopes": ["GET:test.example.com/api"]} + mock_httpx.return_value.get = AsyncMock(return_value=mock_resp) + mock_httpx.return_value.aclose = AsyncMock() + + oauth = AsyncOauthClient(username="user", apikey="key") + resp = await oauth.get_scopes() + + self.assertIn("scopes", resp) + # Manually closing the client as we didn't use the context manager here + await oauth.aclose() + mock_httpx.return_value.aclose.assert_called_once() + + def test_custom_client_transport(self): + custom_client = MagicMock() + oauth = AsyncOauthClient(username="user", apikey="key", client=custom_client) + self.assertEqual(oauth.client, custom_client) + + +class TestAsyncClient(unittest.IsolatedAsyncioTestCase): + """ + Test suite for the generic AsyncClient. + """ + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + async def test_request_get(self, mock_httpx): + mock_resp = MagicMock() + mock_resp.json.return_value = {"data": []} + mock_httpx.return_value.request = AsyncMock(return_value=mock_resp) + mock_httpx.return_value.aclose = AsyncMock() + + async with AsyncClient(token="abc123") as client: + resp = await client.request( + method="GET", + url="https://test.imprese.openapi.it/advance", + params={"denominazione": "altravia"}, + ) + + self.assertEqual(resp, {"data": []}) + mock_httpx.return_value.request.assert_called_once() + mock_httpx.return_value.aclose.assert_called_once() + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + async def test_request_post(self, mock_httpx): + mock_resp = MagicMock() + mock_resp.json.return_value = {"result": "ok"} + mock_httpx.return_value.request = AsyncMock(return_value=mock_resp) + mock_httpx.return_value.aclose = AsyncMock() + + client = AsyncClient(token="abc123") + resp = await client.request( + method="POST", + url="https://test.postontarget.com/fields/country", + payload={"limit": 0, "query": {"country_code": "IT"}}, + ) + + self.assertEqual(resp["result"], "ok") + # Ensure cleanup + await client.aclose() + mock_httpx.return_value.aclose.assert_called_once() + + def test_custom_client_transport(self): + custom_client = MagicMock() + client = AsyncClient(token="abc123", client=custom_client) + self.assertEqual(client.client, custom_client) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_client.py b/tests/test_client.py index d9ac4dd..3b8f874 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -56,6 +56,11 @@ def test_auth_header_is_basic(self, mock_httpx): oauth = OauthClient(username="user", apikey="key") self.assertTrue(oauth.auth_header.startswith("Basic ")) + def test_custom_client_transport(self): + custom_client = MagicMock() + oauth = OauthClient(username="user", apikey="key", client=custom_client) + self.assertEqual(oauth.client, custom_client) + class TestClient(unittest.TestCase): @@ -103,12 +108,17 @@ def test_defaults_on_empty_request(self, mock_httpx): mock_httpx.return_value.request.return_value = mock_resp client = Client(token="tok") - resp = client.request() + client.request() mock_httpx.return_value.request.assert_called_once_with( method="GET", url="", headers=client.headers, json={}, params={} ) + def test_custom_client_transport(self): + custom_client = MagicMock() + client = Client(token="tok", client=custom_client) + self.assertEqual(client.client, custom_client) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_thread_safety.py b/tests/test_thread_safety.py new file mode 100644 index 0000000..5763084 --- /dev/null +++ b/tests/test_thread_safety.py @@ -0,0 +1,65 @@ +import threading +import unittest + +import httpx + +from openapi_python_sdk import Client, OauthClient + + +class TestThreadSafety(unittest.TestCase): + def test_oauth_client_thread_safety(self): + oauth = OauthClient(username="user", apikey="key") + + clients = [] + def get_client(): + clients.append(oauth.client) + + threads = [threading.Thread(target=get_client) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + # Each thread should have gotten a unique client instance + self.assertEqual(len(clients), 5) + self.assertEqual(len(set(id(c) for c in clients)), 5) + + def test_client_thread_safety(self): + client = Client(token="tok") + + clients = [] + def get_client(): + clients.append(client.client) + + threads = [threading.Thread(target=get_client) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + # Each thread should have gotten a unique client instance + self.assertEqual(len(clients), 5) + self.assertEqual(len(set(id(c) for c in clients)), 5) + + def test_shared_client_injection_still_works(self): + # If we explicitly pass a client, it SHOULD be shared (backward compatibility) + shared_engine = httpx.Client() + oauth = OauthClient(username="user", apikey="key", client=shared_engine) + + clients = [] + def get_client(): + clients.append(oauth.client) + + threads = [threading.Thread(target=get_client) for _ in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + # All threads should have the SAME instance because it was injected + self.assertEqual(len(clients), 5) + self.assertEqual(len(set(id(c) for c in clients)), 1) + self.assertEqual(id(clients[0]), id(shared_engine)) + +if __name__ == "__main__": + unittest.main()