feat(request): implement context management and timeout config

This commit is contained in:
ZhymabekRoman 2024-11-06 20:39:34 +05:00
parent ff3df2795d
commit 261f9955cc
10 changed files with 599 additions and 43 deletions

View file

@ -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"},

View file

@ -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",
]

View file

@ -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,
)

View 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

View file

@ -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

View file

@ -7,3 +7,11 @@ class MediumServiceError(BaseServiceError):
class InvalidMediumServicePathError(MediumServiceError):
pass
class InvalidMediumServiceHashError(InvalidMediumServicePathError):
pass
class InvalidMediumServiceUrlError(InvalidMediumServicePathError):
pass

View file

@ -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

View file

@ -0,0 +1,5 @@
from pydantic import BaseModel
class MediumPostDataResponse(BaseModel):
pass

View file

@ -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__(

View file

@ -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"}