mirror of
https://codeberg.org/Freedium-cfd/web.git
synced 2026-03-11 09:04:37 +00:00
feat(request): implement context management and timeout config
This commit is contained in:
parent
ff3df2795d
commit
261f9955cc
10 changed files with 599 additions and 43 deletions
|
|
@ -5,7 +5,7 @@
|
|||
groups = ["default", "dev"]
|
||||
strategy = ["inherit_metadata"]
|
||||
lock_version = "4.5.0"
|
||||
content_hash = "sha256:fd3b813143614f861a04ce0e5eb385121bc26ef1459e91c6c80e50d67437c4fb"
|
||||
content_hash = "sha256:cbe7c4d9cd8848458b3af09be1a95005fbc4adf178dd7a96f83810a4b45bff66"
|
||||
|
||||
[[metadata.targets]]
|
||||
requires_python = "==3.12.*"
|
||||
|
|
@ -15,7 +15,7 @@ name = "anyio"
|
|||
version = "4.6.2.post1"
|
||||
requires_python = ">=3.9"
|
||||
summary = "High level compatibility layer for multiple asynchronous event loop implementations"
|
||||
groups = ["default"]
|
||||
groups = ["default", "dev"]
|
||||
dependencies = [
|
||||
"exceptiongroup>=1.0.2; python_version < \"3.11\"",
|
||||
"idna>=2.8",
|
||||
|
|
@ -32,7 +32,7 @@ name = "certifi"
|
|||
version = "2024.8.30"
|
||||
requires_python = ">=3.6"
|
||||
summary = "Python package for providing Mozilla's CA Bundle."
|
||||
groups = ["default"]
|
||||
groups = ["default", "dev"]
|
||||
files = [
|
||||
{file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"},
|
||||
{file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"},
|
||||
|
|
@ -65,6 +65,17 @@ files = [
|
|||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "execnet"
|
||||
version = "2.1.1"
|
||||
requires_python = ">=3.8"
|
||||
summary = "execnet: rapid multi-Python deployment"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc"},
|
||||
{file = "execnet-2.1.1.tar.gz", hash = "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "faker"
|
||||
version = "30.8.1"
|
||||
|
|
@ -85,7 +96,7 @@ name = "h11"
|
|||
version = "0.14.0"
|
||||
requires_python = ">=3.7"
|
||||
summary = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||
groups = ["default"]
|
||||
groups = ["default", "dev"]
|
||||
dependencies = [
|
||||
"typing-extensions; python_version < \"3.8\"",
|
||||
]
|
||||
|
|
@ -99,7 +110,7 @@ name = "httpcore"
|
|||
version = "1.0.6"
|
||||
requires_python = ">=3.8"
|
||||
summary = "A minimal low-level HTTP client."
|
||||
groups = ["default"]
|
||||
groups = ["default", "dev"]
|
||||
dependencies = [
|
||||
"certifi",
|
||||
"h11<0.15,>=0.13",
|
||||
|
|
@ -114,7 +125,7 @@ name = "httpx"
|
|||
version = "0.27.2"
|
||||
requires_python = ">=3.8"
|
||||
summary = "The next generation HTTP client."
|
||||
groups = ["default"]
|
||||
groups = ["default", "dev"]
|
||||
dependencies = [
|
||||
"anyio",
|
||||
"certifi",
|
||||
|
|
@ -132,7 +143,7 @@ name = "idna"
|
|||
version = "3.10"
|
||||
requires_python = ">=3.6"
|
||||
summary = "Internationalized Domain Names in Applications (IDNA)"
|
||||
groups = ["default"]
|
||||
groups = ["default", "dev"]
|
||||
files = [
|
||||
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
|
||||
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
|
||||
|
|
@ -283,6 +294,23 @@ files = [
|
|||
{file = "polyfactory-2.17.0.tar.gz", hash = "sha256:099d86f7c79c51a2caaf7c8598cc56e7b0a57c11b5918ddf699e82380735b6b7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psutil"
|
||||
version = "6.1.0"
|
||||
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
|
||||
summary = "Cross-platform lib for process and system monitoring in Python."
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "psutil-6.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6e2dcd475ce8b80522e51d923d10c7871e45f20918e027ab682f94f1c6351688"},
|
||||
{file = "psutil-6.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0895b8414afafc526712c498bd9de2b063deaac4021a3b3c34566283464aff8e"},
|
||||
{file = "psutil-6.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dcbfce5d89f1d1f2546a2090f4fcf87c7f669d1d90aacb7d7582addece9fb38"},
|
||||
{file = "psutil-6.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:498c6979f9c6637ebc3a73b3f87f9eb1ec24e1ce53a7c5173b8508981614a90b"},
|
||||
{file = "psutil-6.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d905186d647b16755a800e7263d43df08b790d709d575105d419f8b6ef65423a"},
|
||||
{file = "psutil-6.1.0-cp37-abi3-win32.whl", hash = "sha256:1ad45a1f5d0b608253b11508f80940985d1d0c8f6111b5cb637533a0e6ddc13e"},
|
||||
{file = "psutil-6.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:a8fb3752b491d246034fa4d279ff076501588ce8cbcdbb62c32fd7a377d996be"},
|
||||
{file = "psutil-6.1.0.tar.gz", hash = "sha256:353815f59a7f64cdaca1c0307ee13558a0512f6db064e92fe833784f08539c7a"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.18.0"
|
||||
|
|
@ -327,6 +355,52 @@ files = [
|
|||
{file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-httpx"
|
||||
version = "0.33.0"
|
||||
requires_python = ">=3.9"
|
||||
summary = "Send responses to httpx."
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"httpx==0.27.*",
|
||||
"pytest==8.*",
|
||||
]
|
||||
files = [
|
||||
{file = "pytest_httpx-0.33.0-py3-none-any.whl", hash = "sha256:bdd1b00a846cfe857194e4d3ba72dc08ba0d163154a4404269c9b971f357c05d"},
|
||||
{file = "pytest_httpx-0.33.0.tar.gz", hash = "sha256:4af9ab0dae5e9c14cb1e27d18af3db1f627b2cf3b11c02b34ddf26aff6b0a24c"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-xdist"
|
||||
version = "3.6.1"
|
||||
requires_python = ">=3.8"
|
||||
summary = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"execnet>=2.1",
|
||||
"pytest>=7.0.0",
|
||||
]
|
||||
files = [
|
||||
{file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"},
|
||||
{file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-xdist"
|
||||
version = "3.6.1"
|
||||
extras = ["psutil"]
|
||||
requires_python = ">=3.8"
|
||||
summary = "pytest xdist plugin for distributed testing, most importantly across multiple CPUs"
|
||||
groups = ["dev"]
|
||||
dependencies = [
|
||||
"psutil>=3.0",
|
||||
"pytest-xdist==3.6.1",
|
||||
]
|
||||
files = [
|
||||
{file = "pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7"},
|
||||
{file = "pytest_xdist-3.6.1.tar.gz", hash = "sha256:ead156a4db231eec769737f57668ef58a2084a34b2e55c4a8fa20d861107300d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
|
|
@ -409,7 +483,7 @@ name = "sniffio"
|
|||
version = "1.3.1"
|
||||
requires_python = ">=3.7"
|
||||
summary = "Sniff out which async library your code is running under"
|
||||
groups = ["default"]
|
||||
groups = ["default", "dev"]
|
||||
files = [
|
||||
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
|
||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
||||
|
|
|
|||
|
|
@ -19,4 +19,6 @@ distribution = false
|
|||
[tool.pdm.dev-dependencies]
|
||||
dev = [
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-httpx>=0.33.0",
|
||||
"pytest-xdist[psutil]>=3.6.1",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,16 +1,82 @@
|
|||
import asyncio
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from httpx import AsyncClient, Client, Response
|
||||
from httpx import (
|
||||
AsyncClient,
|
||||
AsyncHTTPTransport,
|
||||
Client,
|
||||
HTTPTransport,
|
||||
Response,
|
||||
Timeout,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestConfig:
|
||||
timeout: int = 6
|
||||
retries: int = 3
|
||||
# backoff_factor: float = 0.1 # not possible. Default value: 0.5. https://github.com/encode/httpx/discussions/1895
|
||||
|
||||
|
||||
# https://github.com/encode/httpx/discussions/1748
|
||||
class Request:
|
||||
def __init__(self):
|
||||
self._client = Client()
|
||||
self._async_client = AsyncClient()
|
||||
def __init__(self, config: Optional[RequestConfig] = None):
|
||||
self.config = config or RequestConfig()
|
||||
self._in_context_manager = False
|
||||
warnings.warn(
|
||||
"Request should be used as a context manager using 'with' or 'async with' "
|
||||
"to ensure proper resource cleanup",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
@property
|
||||
def _transport(self) -> HTTPTransport:
|
||||
return HTTPTransport(retries=self.config.retries)
|
||||
|
||||
@property
|
||||
def _client(self) -> Client:
|
||||
return Client(transport=self._transport)
|
||||
|
||||
@property
|
||||
def _async_transport(self) -> AsyncHTTPTransport:
|
||||
return AsyncHTTPTransport(retries=self.config.retries)
|
||||
|
||||
@property
|
||||
def _async_client(self) -> AsyncClient:
|
||||
return AsyncClient(transport=self._async_transport)
|
||||
|
||||
@property
|
||||
def _timeout_client(self) -> Timeout:
|
||||
timeout = Timeout(timeout=self.config.timeout)
|
||||
return timeout
|
||||
|
||||
def __enter__(self):
|
||||
self._in_context_manager = True
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self._client.close()
|
||||
|
||||
async def __aenter__(self):
|
||||
self._in_context_manager = True
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self._async_client.aclose()
|
||||
|
||||
def __del__(self):
|
||||
self._client.close()
|
||||
# self._async_client.close()
|
||||
asyncio.run(self._async_client.aclose())
|
||||
|
||||
def _check_context_manager(self):
|
||||
if not self._in_context_manager:
|
||||
warnings.warn(
|
||||
"Request is not being used as a context manager. This may lead to "
|
||||
"resource leaks. Use 'with' or 'async with' statement.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
def get(
|
||||
self,
|
||||
|
|
@ -19,8 +85,13 @@ class Request:
|
|||
headers: Optional[Dict[str, str]] = None,
|
||||
follow_redirects: bool = True,
|
||||
) -> Response:
|
||||
self._check_context_manager()
|
||||
return self._client.get(
|
||||
url, params=params, headers=headers, follow_redirects=follow_redirects
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=self._timeout_client,
|
||||
)
|
||||
|
||||
def post(
|
||||
|
|
@ -30,8 +101,13 @@ class Request:
|
|||
headers: Optional[Dict[str, str]] = None,
|
||||
follow_redirects: bool = True,
|
||||
) -> Response:
|
||||
self._check_context_manager()
|
||||
return self._client.post(
|
||||
url, json=data, headers=headers, follow_redirects=follow_redirects
|
||||
url,
|
||||
json=data,
|
||||
headers=headers,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=self._timeout_client,
|
||||
)
|
||||
|
||||
def put(
|
||||
|
|
@ -41,8 +117,13 @@ class Request:
|
|||
headers: Optional[Dict[str, str]] = None,
|
||||
follow_redirects: bool = True,
|
||||
) -> Response:
|
||||
self._check_context_manager()
|
||||
return self._client.put(
|
||||
url, json=data, headers=headers, follow_redirects=follow_redirects
|
||||
url,
|
||||
json=data,
|
||||
headers=headers,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=self._timeout_client,
|
||||
)
|
||||
|
||||
def delete(
|
||||
|
|
@ -51,8 +132,12 @@ class Request:
|
|||
headers: Optional[Dict[str, str]] = None,
|
||||
follow_redirects: bool = True,
|
||||
) -> Response:
|
||||
self._check_context_manager()
|
||||
return self._client.delete(
|
||||
url, headers=headers, follow_redirects=follow_redirects
|
||||
url,
|
||||
headers=headers,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=self._timeout_client,
|
||||
)
|
||||
|
||||
async def aget(
|
||||
|
|
@ -62,8 +147,13 @@ class Request:
|
|||
headers: Optional[Dict[str, str]] = None,
|
||||
follow_redirects: bool = True,
|
||||
) -> Response:
|
||||
self._check_context_manager()
|
||||
return await self._async_client.get(
|
||||
url, params=params, headers=headers, follow_redirects=follow_redirects
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=self._timeout_client,
|
||||
)
|
||||
|
||||
async def apost(
|
||||
|
|
@ -73,8 +163,13 @@ class Request:
|
|||
headers: Optional[Dict[str, str]] = None,
|
||||
follow_redirects: bool = True,
|
||||
) -> Response:
|
||||
self._check_context_manager()
|
||||
return await self._async_client.post(
|
||||
url, json=data, headers=headers, follow_redirects=follow_redirects
|
||||
url,
|
||||
json=data,
|
||||
headers=headers,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=self._timeout_client,
|
||||
)
|
||||
|
||||
async def aput(
|
||||
|
|
@ -84,8 +179,13 @@ class Request:
|
|||
headers: Optional[Dict[str, str]] = None,
|
||||
follow_redirects: bool = True,
|
||||
) -> Response:
|
||||
self._check_context_manager()
|
||||
return await self._async_client.put(
|
||||
url, json=data, headers=headers, follow_redirects=follow_redirects
|
||||
url,
|
||||
json=data,
|
||||
headers=headers,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=self._timeout_client,
|
||||
)
|
||||
|
||||
async def adelete(
|
||||
|
|
@ -94,6 +194,10 @@ class Request:
|
|||
headers: Optional[Dict[str, str]] = None,
|
||||
follow_redirects: bool = True,
|
||||
) -> Response:
|
||||
self._check_context_manager()
|
||||
return await self._async_client.delete(
|
||||
url, headers=headers, follow_redirects=follow_redirects
|
||||
url,
|
||||
headers=headers,
|
||||
follow_redirects=follow_redirects,
|
||||
timeout=self._timeout_client,
|
||||
)
|
||||
|
|
|
|||
343
freedium-library/src/freedium_library/models/test_request.py
Normal file
343
freedium-library/src/freedium_library/models/test_request.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
from typing import Any, Literal, Optional
|
||||
|
||||
import pytest
|
||||
from httpx import Response
|
||||
from pytest_httpx import HTTPXMock
|
||||
|
||||
from freedium_library.models.request import Request, RequestConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_response() -> dict[str, Any]:
|
||||
return {"status_code": 200, "json": {"status": "ok"}}
|
||||
|
||||
|
||||
def test_sync_context_manager(httpx_mock: HTTPXMock, mock_response: dict[str, Any]):
|
||||
httpx_mock.add_response(**mock_response)
|
||||
|
||||
with Request() as client:
|
||||
response = client.get("http://test.com")
|
||||
assert response.status_code == 200
|
||||
assert client._in_context_manager is True
|
||||
|
||||
|
||||
def test_sync_without_context_manager(
|
||||
httpx_mock: HTTPXMock, mock_response: dict[str, Any]
|
||||
):
|
||||
httpx_mock.add_response(**mock_response)
|
||||
|
||||
with pytest.warns(UserWarning, match="Request should be used as a context manager"):
|
||||
client = Request()
|
||||
|
||||
with pytest.warns(
|
||||
UserWarning, match="Request is not being used as a context manager"
|
||||
):
|
||||
response = client.get("http://test.com")
|
||||
assert response.status_code == 200
|
||||
assert client._in_context_manager is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_context_manager(
|
||||
httpx_mock: HTTPXMock, mock_response: dict[str, Any]
|
||||
):
|
||||
httpx_mock.add_response(**mock_response)
|
||||
|
||||
async with Request() as client:
|
||||
response = await client.aget("http://test.com")
|
||||
assert response.status_code == 200
|
||||
assert client._in_context_manager is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_without_context_manager(
|
||||
httpx_mock: HTTPXMock, mock_response: Response
|
||||
):
|
||||
httpx_mock.add_response(**mock_response)
|
||||
|
||||
with pytest.warns(UserWarning, match="Request should be used as a context manager"):
|
||||
client = Request()
|
||||
|
||||
with pytest.warns(
|
||||
UserWarning, match="Request is not being used as a context manager"
|
||||
):
|
||||
response = await client.aget("http://test.com")
|
||||
assert response.status_code == 200
|
||||
assert client._in_context_manager is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,async_method,data",
|
||||
[
|
||||
("get", "aget", None),
|
||||
("post", "apost", {"key": "value"}),
|
||||
("put", "aput", {"key": "value"}),
|
||||
("delete", "adelete", None),
|
||||
],
|
||||
)
|
||||
def test_http_methods(
|
||||
httpx_mock: HTTPXMock,
|
||||
mock_response: dict[str, Any],
|
||||
method: Literal["get", "post", "put", "delete"],
|
||||
async_method: Literal["aget", "apost", "aput", "adelete"],
|
||||
data: Optional[dict[Any, Any]],
|
||||
):
|
||||
httpx_mock.add_response(**mock_response)
|
||||
|
||||
with Request() as client:
|
||||
func = getattr(client, method)
|
||||
kwargs = {"url": "http://test.com"}
|
||||
if data:
|
||||
kwargs["data"] = data
|
||||
response = func(**kwargs)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"method,async_method,data",
|
||||
[
|
||||
("get", "aget", None),
|
||||
("post", "apost", {"key": "value"}),
|
||||
("put", "aput", {"key": "value"}),
|
||||
("delete", "adelete", None),
|
||||
],
|
||||
)
|
||||
async def test_async_http_methods(
|
||||
httpx_mock: HTTPXMock,
|
||||
mock_response: dict[str, Any],
|
||||
method: Literal["get", "post", "put", "delete"],
|
||||
async_method: Literal["aget", "apost", "aput", "adelete"],
|
||||
data: Optional[dict[Any, Any]],
|
||||
):
|
||||
httpx_mock.add_response(**mock_response)
|
||||
|
||||
async with Request() as client:
|
||||
func = getattr(client, async_method)
|
||||
kwargs = {"url": "http://test.com"}
|
||||
if data:
|
||||
kwargs["data"] = data
|
||||
response = await func(**kwargs)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_custom_config():
|
||||
config = RequestConfig(timeout=20, retries=5) # , backoff_factor=0.2
|
||||
client = Request(config)
|
||||
assert client.config.timeout == 20
|
||||
assert client.config.retries == 5
|
||||
# assert client.config.backoff_factor == 0.2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("context_manager", [True, False])
|
||||
def test_resource_cleanup(context_manager):
|
||||
if context_manager:
|
||||
with Request() as client:
|
||||
pass
|
||||
else:
|
||||
client = Request()
|
||||
del client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def request_client():
|
||||
return Request()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def custom_config():
|
||||
return RequestConfig(timeout=5, retries=2) # , backoff_factor=0.2
|
||||
|
||||
|
||||
def test_request_config_defaults():
|
||||
config = RequestConfig()
|
||||
assert config.timeout == 6
|
||||
assert config.retries == 3
|
||||
# assert config.backoff_factor == 0.1
|
||||
|
||||
|
||||
def test_request_custom_config():
|
||||
config = RequestConfig(timeout=5, retries=2) # , backoff_factor=0.2
|
||||
request = Request(config=config)
|
||||
assert request.config.timeout == 5
|
||||
assert request.config.retries == 2
|
||||
# assert request.config.backoff_factor == 0.2
|
||||
|
||||
|
||||
def test_get_request(request_client: Request, httpx_mock: HTTPXMock):
|
||||
url = "https://api.example.com/data"
|
||||
expected_response = {"key": "value"}
|
||||
httpx_mock.add_response(json=expected_response)
|
||||
|
||||
response = request_client.get(url)
|
||||
assert response.json() == expected_response
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url == url
|
||||
|
||||
|
||||
def test_post_request(request_client: Request, httpx_mock: HTTPXMock):
|
||||
url = "https://api.example.com/data"
|
||||
data = {"test": "data"}
|
||||
httpx_mock.add_response(json={"status": "success"})
|
||||
|
||||
response = request_client.post(url, data=data)
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url == url
|
||||
assert request.read().decode() == '{"test": "data"}'
|
||||
|
||||
|
||||
def test_put_request(request_client: Request, httpx_mock: HTTPXMock):
|
||||
url = "https://api.example.com/data"
|
||||
data = {"test": "data"}
|
||||
httpx_mock.add_response(json={"status": "updated"})
|
||||
|
||||
response = request_client.put(url, data=data)
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url == url
|
||||
assert request.read().decode() == '{"test": "data"}'
|
||||
|
||||
|
||||
def test_delete_request(request_client: Request, httpx_mock: HTTPXMock):
|
||||
url = "https://api.example.com/data"
|
||||
httpx_mock.add_response(json={"status": "deleted"})
|
||||
|
||||
response = request_client.delete(url)
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url == url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aget_request(request_client: Request, httpx_mock: HTTPXMock):
|
||||
url = "https://api.example.com/data"
|
||||
expected_response = {"key": "value"}
|
||||
httpx_mock.add_response(json=expected_response)
|
||||
|
||||
response = await request_client.aget(url)
|
||||
assert response.json() == expected_response
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url == url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apost_request(request_client: Request, httpx_mock: HTTPXMock):
|
||||
url = "https://api.example.com/data"
|
||||
data = {"test": "data"}
|
||||
httpx_mock.add_response(json={"status": "success"})
|
||||
|
||||
response = await request_client.apost(url, data=data)
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url == url
|
||||
assert request.read().decode() == '{"test": "data"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aput_request(request_client: Request, httpx_mock: HTTPXMock):
|
||||
url = "https://api.example.com/data"
|
||||
data = {"test": "data"}
|
||||
httpx_mock.add_response(json={"status": "updated"})
|
||||
|
||||
response = await request_client.aput(url, data=data)
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url == url
|
||||
assert request.read().decode() == '{"test": "data"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adelete_request(request_client: Request, httpx_mock: HTTPXMock):
|
||||
url = "https://api.example.com/data"
|
||||
httpx_mock.add_response(json={"status": "deleted"})
|
||||
|
||||
response = await request_client.adelete(url)
|
||||
request = httpx_mock.get_request()
|
||||
assert request.url == url
|
||||
|
||||
|
||||
def test_context_manager(httpx_mock: HTTPXMock):
|
||||
with Request() as client:
|
||||
url = "https://api.example.com/data"
|
||||
httpx_mock.add_response(json={"key": "value"})
|
||||
response = client.get(url)
|
||||
assert response.json() == {"key": "value"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_context_manager(httpx_mock: HTTPXMock):
|
||||
async with Request() as client:
|
||||
url = "https://api.example.com/data"
|
||||
httpx_mock.add_response(json={"key": "value"})
|
||||
response = await client.aget(url)
|
||||
assert response.json() == {"key": "value"}
|
||||
|
||||
|
||||
def test_request_with_params(request_client: Request, httpx_mock: HTTPXMock):
|
||||
url = "https://api.example.com/data"
|
||||
params = {"query": "test"}
|
||||
httpx_mock.add_response(json={"result": "success"})
|
||||
|
||||
response = request_client.get(url, params=params)
|
||||
request = httpx_mock.get_request()
|
||||
assert "query=test" in str(request.url)
|
||||
|
||||
|
||||
def test_request_with_headers(request_client: Request, httpx_mock: HTTPXMock):
|
||||
url = "https://api.example.com/data"
|
||||
headers = {"Authorization": "Bearer token"}
|
||||
httpx_mock.add_response(json={"result": "success"})
|
||||
|
||||
response = request_client.get(url, headers=headers)
|
||||
request = httpx_mock.get_request()
|
||||
assert request.headers["Authorization"] == "Bearer token"
|
||||
|
||||
|
||||
def test_invalid_json_response(request_client: Request, httpx_mock: HTTPXMock):
|
||||
url = "https://api.example.com/data"
|
||||
httpx_mock.add_response(text="Invalid JSON")
|
||||
|
||||
response = request_client.get(url)
|
||||
with pytest.raises(ValueError):
|
||||
response.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_response_async(
|
||||
request_client: Request, httpx_mock: HTTPXMock
|
||||
):
|
||||
url = "https://api.example.com/data"
|
||||
httpx_mock.add_response(text="Invalid JSON")
|
||||
|
||||
response = await request_client.aget(url)
|
||||
with pytest.raises(ValueError):
|
||||
response.json()
|
||||
|
||||
|
||||
def test_closed_context_manager_access(httpx_mock: HTTPXMock):
|
||||
mock_response_json = {"test": "hahaha"}
|
||||
httpx_mock.add_response(json=mock_response_json)
|
||||
|
||||
client = Request()
|
||||
with client:
|
||||
pass
|
||||
|
||||
response = client.get("https://api.example.com/data")
|
||||
assert response.is_closed is True
|
||||
response.json() == mock_response_json
|
||||
|
||||
response.close()
|
||||
assert response.is_closed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closed_context_manager_access_async(httpx_mock: HTTPXMock):
|
||||
mock_response_json = {"test": "hahaha"}
|
||||
httpx_mock.add_response(json=mock_response_json)
|
||||
|
||||
client = Request()
|
||||
with client:
|
||||
pass
|
||||
|
||||
response = await client.aget("https://api.example.com/data")
|
||||
assert response.is_closed is True
|
||||
response.json() == mock_response_json
|
||||
|
||||
response.aclose()
|
||||
assert response.is_closed
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from dependency_injector.wiring import Provide
|
||||
|
||||
|
|
@ -12,17 +11,17 @@ class BaseService(ABC):
|
|||
self.request = request
|
||||
|
||||
@abstractmethod
|
||||
def is_valid(self) -> bool:
|
||||
def is_valid(self, path: str) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def ais_valid(self) -> bool:
|
||||
async def ais_valid(self, path: str) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def render(self) -> Any:
|
||||
def render(self, path: str) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def arender(self) -> Any:
|
||||
async def arender(self, path: str) -> str:
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -7,3 +7,11 @@ class MediumServiceError(BaseServiceError):
|
|||
|
||||
class InvalidMediumServicePathError(MediumServiceError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMediumServiceHashError(InvalidMediumServicePathError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidMediumServiceUrlError(InvalidMediumServicePathError):
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from freedium_library.services.base import BaseService
|
|||
|
||||
from .api import MediumApiService
|
||||
from .container import MediumContainer
|
||||
from .exceptions import InvalidMediumServicePathError
|
||||
from .models import MediumPostDataResponse
|
||||
from .validators import MediumServicePathValidator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -27,7 +29,7 @@ class MediumService(BaseService):
|
|||
self.request = request
|
||||
self.api_service = api_service
|
||||
self.path_validator = path_validator
|
||||
self._content: Optional[str] = None
|
||||
_content: Optional[str] = None
|
||||
|
||||
def is_valid(self, path: str) -> bool:
|
||||
return self.path_validator.is_valid(path)
|
||||
|
|
@ -36,24 +38,30 @@ class MediumService(BaseService):
|
|||
return await self.path_validator.ais_valid(path)
|
||||
|
||||
def render(self, path: str) -> str:
|
||||
if not self.is_valid():
|
||||
raise ValueError("Invalid Medium URL")
|
||||
if self._content is None:
|
||||
response = self.request.get(self._url)
|
||||
self._content = self._process_content(response.text)
|
||||
return self._content
|
||||
if not self.is_valid(path):
|
||||
raise InvalidMediumServicePathError("Invalid Medium URL")
|
||||
|
||||
async def arender(self) -> str:
|
||||
if not await self.ais_valid():
|
||||
raise ValueError("Invalid Medium URL")
|
||||
if self._content is None:
|
||||
response = await self.request.aget(self._url)
|
||||
self._content = self._process_content(response.text)
|
||||
return self._content
|
||||
response = self.request.get(self._url)
|
||||
response_json = response.json()
|
||||
_model = self._process_response(response_json)
|
||||
_content = self._process_content(_model)
|
||||
return _content
|
||||
|
||||
def _process_content(self, content: str) -> str:
|
||||
return content
|
||||
async def arender(self, path: str) -> str:
|
||||
if not await self.ais_valid(path):
|
||||
raise InvalidMediumServicePathError("Invalid Medium URL")
|
||||
|
||||
response = await self.request.aget(self._url)
|
||||
_model = self._process_response(response.json())
|
||||
_content = self._process_content(_model)
|
||||
return _content
|
||||
|
||||
def _process_response(self, response: str) -> MediumPostDataResponse:
|
||||
return MediumPostDataResponse.model_validate_json(response)
|
||||
|
||||
def _process_content(self, data: MediumPostDataResponse) -> str:
|
||||
return "data"
|
||||
|
||||
def set_url(self, url: str) -> None:
|
||||
self._url = url
|
||||
self._content = None
|
||||
_content = None
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class MediumPostDataResponse(BaseModel):
|
||||
pass
|
||||
|
|
@ -32,6 +32,11 @@ class MediumServicePathValidator:
|
|||
path
|
||||
)
|
||||
|
||||
async def ais_valid(self, path: str) -> bool:
|
||||
return await self.url_validator.ais_valid(
|
||||
path
|
||||
) or await self.hash_validator.extract_hashes(path)
|
||||
|
||||
|
||||
class _MediumServiceURLValidator:
|
||||
def __init__(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class TestMediumServiceHashesValidator:
|
|||
def validator(self) -> _MediumServiceHashesValidator:
|
||||
Container().wire(modules=[__name__])
|
||||
MediumContainer().wire(modules=[__name__])
|
||||
|
||||
return _MediumServiceHashesValidator()
|
||||
|
||||
def test_is_valid(self, validator: _MediumServiceHashesValidator):
|
||||
|
|
@ -82,6 +83,13 @@ class TestMediumServiceHashesValidator:
|
|||
"https://12ft.io/api/proxy?q=https%3A%2F%2Fednico.medium.com%2Fpkm-weekly-2024-05-26-6601694e4147"
|
||||
) == ["6601694e4147"]
|
||||
|
||||
assert (
|
||||
validator.extract_hashes(
|
||||
"http://freedium.cfd/https://medium.com/funny-ai-quant/macroeconomic-regime-shifts-in-quant-trading-a-comprehensive-guide-2c250f243190T5BbIO-RCOq-9i3=s0-d-e1-ft"
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
def test_extract_multiple_hashes(self, validator: _MediumServiceHashesValidator):
|
||||
text = "first-515dd5a43948 second-c81e00f6320d"
|
||||
assert set(validator.extract_hashes(text)) == {"515dd5a43948", "c81e00f6320d"}
|
||||
|
|
|
|||
Loading…
Reference in a new issue