From 261f9955cc93eccf263210c833a0a8de5b43cb4e Mon Sep 17 00:00:00 2001 From: ZhymabekRoman Date: Wed, 6 Nov 2024 20:39:34 +0500 Subject: [PATCH] feat(request): implement context management and timeout config --- freedium-library/pdm.lock | 90 ++++- freedium-library/pyproject.toml | 2 + .../src/freedium_library/models/request.py | 130 ++++++- .../freedium_library/models/test_request.py | 343 ++++++++++++++++++ .../src/freedium_library/services/base.py | 9 +- .../services/medium/exceptions.py | 8 + .../services/medium/medium.py | 42 ++- .../services/medium/models.py | 5 + .../services/medium/validators.py | 5 + .../services/medium/validators_hashes_test.py | 8 + 10 files changed, 599 insertions(+), 43 deletions(-) create mode 100644 freedium-library/src/freedium_library/models/test_request.py create mode 100644 freedium-library/src/freedium_library/services/medium/models.py diff --git a/freedium-library/pdm.lock b/freedium-library/pdm.lock index 367ca2a..0dc3c20 100644 --- a/freedium-library/pdm.lock +++ b/freedium-library/pdm.lock @@ -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"}, diff --git a/freedium-library/pyproject.toml b/freedium-library/pyproject.toml index 5e0571c..179ee6f 100644 --- a/freedium-library/pyproject.toml +++ b/freedium-library/pyproject.toml @@ -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", ] diff --git a/freedium-library/src/freedium_library/models/request.py b/freedium-library/src/freedium_library/models/request.py index cdd0c94..0ee0ecf 100644 --- a/freedium-library/src/freedium_library/models/request.py +++ b/freedium-library/src/freedium_library/models/request.py @@ -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, ) diff --git a/freedium-library/src/freedium_library/models/test_request.py b/freedium-library/src/freedium_library/models/test_request.py new file mode 100644 index 0000000..057863f --- /dev/null +++ b/freedium-library/src/freedium_library/models/test_request.py @@ -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 diff --git a/freedium-library/src/freedium_library/services/base.py b/freedium-library/src/freedium_library/services/base.py index 6abf9d8..8fa6523 100644 --- a/freedium-library/src/freedium_library/services/base.py +++ b/freedium-library/src/freedium_library/services/base.py @@ -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 diff --git a/freedium-library/src/freedium_library/services/medium/exceptions.py b/freedium-library/src/freedium_library/services/medium/exceptions.py index 7aeed54..ea2528d 100644 --- a/freedium-library/src/freedium_library/services/medium/exceptions.py +++ b/freedium-library/src/freedium_library/services/medium/exceptions.py @@ -7,3 +7,11 @@ class MediumServiceError(BaseServiceError): class InvalidMediumServicePathError(MediumServiceError): pass + + +class InvalidMediumServiceHashError(InvalidMediumServicePathError): + pass + + +class InvalidMediumServiceUrlError(InvalidMediumServicePathError): + pass diff --git a/freedium-library/src/freedium_library/services/medium/medium.py b/freedium-library/src/freedium_library/services/medium/medium.py index bf35894..8f740db 100644 --- a/freedium-library/src/freedium_library/services/medium/medium.py +++ b/freedium-library/src/freedium_library/services/medium/medium.py @@ -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 diff --git a/freedium-library/src/freedium_library/services/medium/models.py b/freedium-library/src/freedium_library/services/medium/models.py new file mode 100644 index 0000000..c530353 --- /dev/null +++ b/freedium-library/src/freedium_library/services/medium/models.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class MediumPostDataResponse(BaseModel): + pass diff --git a/freedium-library/src/freedium_library/services/medium/validators.py b/freedium-library/src/freedium_library/services/medium/validators.py index aeb582d..c2ef38e 100644 --- a/freedium-library/src/freedium_library/services/medium/validators.py +++ b/freedium-library/src/freedium_library/services/medium/validators.py @@ -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__( diff --git a/freedium-library/src/freedium_library/services/medium/validators_hashes_test.py b/freedium-library/src/freedium_library/services/medium/validators_hashes_test.py index d247ee2..fd91387 100644 --- a/freedium-library/src/freedium_library/services/medium/validators_hashes_test.py +++ b/freedium-library/src/freedium_library/services/medium/validators_hashes_test.py @@ -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"}