Skip to content

Commit

Permalink
feat: implements HTTP client (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
apoclyps authored Nov 15, 2023
1 parent d13b27d commit dbf5a3b
Show file tree
Hide file tree
Showing 10 changed files with 265 additions and 10 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/on-commit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/[email protected]
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler.yml
sync-labels: true

Expand Down
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ repos:
entry: mypy
language: python
types: [python]
args: ["--ignore-missing-imports", "--scripts-are-modules"]
args: ["--ignore-missing-imports", "--scripts-are-modules", "--install-types"]
require_serial: true
additional_dependencies: [types-click==7.1.2, types-freezegun==0.1.4]
additional_dependencies: [types-click==7.1.2, types-freezegun==0.1.4, types-requests==2.31.0.10]

- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.0.292
Expand Down
35 changes: 34 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,21 @@ classifiers = [
include = ["LICENSE.md"]

[tool.poetry.dependencies]
python = "^3.11"
freezegun = "1.2.2"
poetry = "^1.6.1"
python = "^3.11"
requests = "2.31.0"

[tool.poetry.group.dev.dependencies]
codespell = "2.2.6"
freezegun = "1.2.2"
mypy = "1.5.1"
pre-commit = "3.5.0"
pytest = "7.4.2"
pytest-cov = "4.1.0"
responses = "0.24.1"
ruff = "0.0.291"
tox = "4.11.3"
types-requests = "2.31.0.10"

[build-system]
requires = ["poetry-core"]
Expand All @@ -55,6 +58,7 @@ skip = "poetry.lock,.mypy_cache,.git,.ruff_cache,.coverage,menu.json,.venv,.idea

[tool.ruff]
cache-dir = ".cache/ruff"
line-length = 120
fix = true

[tool.commitizen]
Expand Down
3 changes: 2 additions & 1 deletion python_package_publish/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from python_package_publish.client import HttpClient
from python_package_publish.collections import chunk
from python_package_publish.utils import get_utc_now, get_uuid
from python_package_publish.version import __version__ # NOQA: F401

__all__ = ["chunk", "get_uuid", "get_utc_now"]
__all__ = ["chunk", "get_uuid", "get_utc_now", "HttpClient"]
147 changes: 147 additions & 0 deletions python_package_publish/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
from typing import Any

import requests
from requests.exceptions import HTTPError

from python_package_publish.errors import ServiceHTTPError


class HttpClient:
def __init__(
self,
url: str,
timeout: Any = None,
has_exception: bool = False,
) -> None:
self._base_url = url
self._has_exception = has_exception
self._timeout = timeout

def get(
self,
route: str,
headers: dict | None = None,
params: dict | None = None,
timeout: int = 30,
) -> requests.Response:
if headers is None:
headers = {}

if params is None:
params = {}

response = requests.get(
url=f"{self._base_url}/{route}",
headers=(self._headers() | headers),
timeout=timeout,
params=params,
)
if self._has_exception:
try:
response.raise_for_status()
except HTTPError as exc:
raise ServiceHTTPError(exception=exc)
else:
response.raise_for_status()
return response

def post(
self,
route: str,
data: dict | list | None = None,
headers: dict | None = None,
timeout: int = 30,
) -> requests.Response:
if headers is None:
headers = {}

response = requests.post(
f"{self._base_url}/{route}",
json=data or {},
headers=(self._headers() | headers),
timeout=timeout,
)

if self._has_exception:
try:
response.raise_for_status()
except HTTPError as exc:
raise ServiceHTTPError(exception=exc)
else:
response.raise_for_status()
return response

def put(
self, route: str, data: dict, headers: dict | None = None, timeout: int = 30
) -> requests.Response:
if headers is None:
headers = {}

response = requests.put(
f"{self._base_url}/{route}",
json=data,
headers=(self._headers() | headers),
timeout=timeout,
)

if self._has_exception:
try:
response.raise_for_status()
except HTTPError as exc:
raise ServiceHTTPError(exception=exc)
else:
response.raise_for_status()

return response

def patch(
self,
route: str,
data: dict | None = None,
headers: dict | None = None,
timeout: int = 30,
) -> requests.Response:
if headers is None:
headers = {}

response = requests.patch(
f"{self._base_url}/{route}",
json=data or {},
headers=(self._headers() | headers),
timeout=timeout,
)

if self._has_exception:
try:
response.raise_for_status()
except HTTPError as exc:
raise ServiceHTTPError(exception=exc)
else:
response.raise_for_status()

return response

def delete(
self, route: str, headers: dict | None = None, timeout: int = 30
) -> requests.Response:
if headers is None:
headers = {}

response = requests.delete(
f"{self._base_url}/{route}",
headers=(self._headers() | headers),
timeout=timeout,
)

if self._has_exception:
try:
response.raise_for_status()
except HTTPError as exc:
raise ServiceHTTPError(exception=exc)
else:
response.raise_for_status()

return response

def _headers(self) -> dict:
return {}
25 changes: 25 additions & 0 deletions python_package_publish/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from requests.exceptions import JSONDecodeError
from requests.models import HTTPError


class ServiceHTTPError(Exception):
"""Adds response & error_id to base exception"""

def __init__(self, exception: HTTPError):
super().__init__(str(exception))
self.exception = exception
self.response = exception.response
self.error_id = None

if self.response is None:
return

# set the error_id if the response has valid json and contains the `error_id`
try:
if (json := self.response.json()) and (error_id := json.get("error_id")):
self.error_id = error_id
except JSONDecodeError:
pass

def __reduce__(self) -> tuple[type, tuple[HTTPError]]:
return self.__class__, (self.exception,)
45 changes: 45 additions & 0 deletions tests/test_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import pytest
import responses

from python_package_publish.client import HttpClient
from python_package_publish.errors import ServiceHTTPError


@responses.activate
def test_service_http_error_for_522_with_no_body() -> None:
responses.add(
responses.GET,
"http://generic-url/request-path",
json=None,
status=522,
)

with pytest.raises(ServiceHTTPError) as error:
HttpClient(url="http://generic-url", has_exception=True).get("request-path")

service_http_error: ServiceHTTPError = error.value # type: ignore[annotation-unchecked]

assert isinstance(service_http_error, ServiceHTTPError)
assert service_http_error.response
assert service_http_error.response.status_code == 522
assert service_http_error.error_id is None


@responses.activate
def test_service_http_error_for_522_with_empty() -> None:
responses.add(
responses.GET,
"http://generic-url/request-path",
json={},
status=522,
)

with pytest.raises(ServiceHTTPError) as error:
HttpClient(url="http://generic-url", has_exception=True).get("request-path")

service_http_error: ServiceHTTPError = error.value # type: ignore[annotation-unchecked]

assert isinstance(service_http_error, ServiceHTTPError)
assert service_http_error.response
assert service_http_error.response.status_code == 522
assert service_http_error.error_id is None
4 changes: 2 additions & 2 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
import pytest
from freezegun import freeze_time

from python_package_publish.utils import get_utc_now
from python_package_publish.utils import get_utc_now, get_uuid


def test_get_uuid_is_valid_uuid() -> None:
"""Test that get_uuid() returns a valid uuid."""
try:
UUID("")
UUID(get_uuid())
except ValueError:
pytest.fail("get_uuid() did not return a valid uuid.")

Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ deps =
mypy
types-toml
skip_install = true
commands = mypy .
commands = mypy --install-types --non-interactive .

[testenv:ruff]
deps = ruff
Expand Down

0 comments on commit dbf5a3b

Please sign in to comment.