passing nox

This commit is contained in:
Andrew Herrington
2021-12-11 17:05:12 -06:00
parent ade2f2ebd6
commit 8bd4b23641
7 changed files with 62 additions and 231 deletions

View File

@@ -158,7 +158,7 @@ def bandit(session: Session) -> None:
def tests(session: Session) -> None:
"""Run the test suite."""
session.install(".")
session.install("coverage[toml]", "pytest", "pygments")
session.install("coverage[toml]", "pytest", "pygments", "respx", "pytest-asyncio")
try:
session.run("coverage", "run", "--parallel", "-m", "pytest", *session.posargs)
finally:

View File

@@ -7,3 +7,12 @@ from .client.exceptions import BadAPIRequestError # noqa: F401, E402
from .client.exceptions import CheckNotFoundError # noqa: F401, E402
from .client.exceptions import HCAPIAuthError # noqa: F401, E402
from .client.exceptions import HCAPIError # noqa: F401, E402
__all__ = [
"AsyncClient",
"Client",
"BadAPIRequestError",
"CheckNotFoundError",
"HCAPIAuthError",
"HCAPIError",
]

View File

@@ -1,3 +1,5 @@
"""healthchecks_io clients."""
from .async_client import AsyncClient # noqa: F401
from .sync_client import Client # noqa: F401
__all__ = ["AsyncClient", "Client"]

View File

@@ -1,9 +1,9 @@
from abc import ABC
from abc import abstractmethod
from json import dumps
from typing import Any
from typing import Dict
from typing import List
from typing import Optional
from typing import Union
from urllib.parse import parse_qsl
from urllib.parse import ParseResult
from urllib.parse import unquote
@@ -12,16 +12,12 @@ from urllib.parse import urljoin
from urllib.parse import urlparse
from weakref import finalize
from httpx import Client
from httpx import Response
from .exceptions import BadAPIRequestError
from .exceptions import CheckNotFoundError
from .exceptions import HCAPIAuthError
from .exceptions import HCAPIError
from healthchecks_io.schemas import badges
from healthchecks_io.schemas import checks
from healthchecks_io.schemas import integrations
class AbstractClient(ABC):
@@ -30,198 +26,29 @@ class AbstractClient(ABC):
def __init__(
self,
api_key: str,
api_url: Optional[str] = "https://healthchecks.io/api/",
api_version: Optional[int] = 1,
client: Optional[Client] = None,
api_url: str = "https://healthchecks.io/api/",
api_version: int = 1,
) -> None:
"""An AbstractClient that other clients can implement.
Args:
api_key (str): Healthchecks.io API key
api_url (Optional[str], optional): API URL. Defaults to "https://healthchecks.io/api/".
api_version (Optional[int], optional): Versiopn of the api to use. Defaults to 1.
client (Optional[Client], optional): A httpx.Client. If not
passed in, one will be created for this object. Defaults to None.
api_url (str): API URL. Defaults to "https://healthchecks.io/api/".
api_version (int): Versiopn of the api to use. Defaults to 1.
"""
self._api_key = api_key
self._client = client
if not api_url.endswith("/"):
api_url = f"{api_url}/"
self._api_url = urljoin(api_url, f"v{api_version}/")
self._finalizer = finalize(self, self._finalizer_method)
@abstractmethod
def _finalizer_method(self): # pragma: no cover
def _finalizer_method(self) -> None: # pragma: no cover
"""Finalizer method is called by weakref.finalize when the object is dereferenced to do cleanup of clients."""
pass
@abstractmethod
def get_checks(
self, tags: Optional[List[str]]
) -> List[checks.Check]: # pragma: no cover
"""Calls the API's /checks/ endpoint to get a list of checks."""
pass
@abstractmethod
def get_check(self, check_id: str) -> checks.Check: # pragma: no cover
"""Get a single check by id.
check_id can either be a check uuid if using a read/write api key
or a unique key if using a read only api key.
Args:
check_id (str): check's uuid or unique id
Returns:
checks.Check: the check
Raises:
HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx
CheckNotFoundError: Raised when status_code is 404
"""
pass
@abstractmethod
def pause_check(self, check_id: str) -> checks.Check: # pragma: no cover
"""Disables monitoring for a check without removing it.
The check goes into a "paused" state.
You can resume monitoring of the check by pinging it.
check_id must be a uuid, not a unique id
Args:
check_id (str): check's uuid
Returns:
checks.Check: the check just paused
Raises:
HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx
CheckNotFoundError: Raised when status_code is 404
"""
pass
@abstractmethod
def delete_check(self, check_id: str) -> checks.Check: # pragma: no cover
"""Permanently deletes the check from the user's account.
check_id must be a uuid, not a unique id
Args:
check_id (str): check's uuid
Returns:
checks.Check: the check just deleted
Raises:
HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx
CheckNotFoundError: Raised when status_code is 404
"""
pass
@abstractmethod
def get_check_pings(
self, check_id: str
) -> List[checks.CheckPings]: # pragma: no cover
"""Returns a list of pings this check has received.
This endpoint returns pings in reverse order (most recent first),
and the total number of returned pings depends on the account's
billing plan: 100 for free accounts, 1000 for paid accounts.
Args:
check_id (str): check's uuid
Returns:
List[checks.CheckPings]: list of pings this check has received
Raises:
HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx
CheckNotFoundError: Raised when status_code is 404
"""
pass
@abstractmethod
def get_check_flips(
self,
check_id: str,
seconds: Optional[int] = None,
start: Optional[int] = None,
end: Optional[int] = None,
) -> List[checks.CheckStatuses]: # pragma: no cover
"""Returns a list of "flips" this check has experienced.
A flip is a change of status (from "down" to "up," or from "up" to "down").
Raises:
HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx
CheckNotFoundError: Raised when status_code is 404
BadAPIRequestError: Raised when status_code is 400
Args:
check_id (str): check uuid
seconds (Optional[int], optional): Returns the flips from the last value seconds. Defaults to None.
start (Optional[int], optional): Returns flips that are newer than the specified UNIX timestamp.. Defaults to None.
end (Optional[int], optional): Returns flips that are older than the specified UNIX timestamp.. Defaults to None.
Returns:
List[checks.CheckStatuses]: List of status flips for this check
"""
pass
@abstractmethod
def get_integrations(
self,
) -> List[Optional[integrations.Integration]]: # pragma: no cover
"""Returns a list of integrations belonging to the project.
Raises:
HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx
Returns:
List[Optional[integrations.Integration]]: List of integrations for the project
"""
pass
@abstractmethod
def get_badges(self) -> Dict[str, badges.Badges]: # pragma: no cover
"""Returns a dict of all tags in the project, with badge URLs for each tag.
Healthchecks.io provides badges in a few different formats:
svg: returns the badge as a SVG document.
json: returns a JSON document which you can use to generate a custom badge yourself.
shields: returns JSON in a Shields.io compatible format.
In addition, badges have 2-state and 3-state variations:
svg, json, shields: reports two states: "up" and "down". It considers any checks in the grace period as still "up".
svg3, json3, shields3: reports three states: "up", "late", and "down".
The response includes a special * entry: this pseudo-tag reports the overal status
of all checks in the project.
Raises:
HCAPIAuthError: Raised when status_code == 401 or 403
HCAPIError: Raised when status_code is 5xx
Returns:
Dict[str, badges.Badges]: Dictionary of all tags in the project with badges
"""
pass
def _get_api_request_url(
self, path: str, params: Optional[Dict[str, str]] = None
self, path: str, params: Optional[Dict[str, Any]] = None
) -> str:
"""Get a full request url for the healthchecks api.
@@ -242,7 +69,7 @@ class AbstractClient(ABC):
Returns:
bool: is the client closed
"""
return self._client.is_closed
return self._client.is_closed # type: ignore
@staticmethod
def check_response(response: Response) -> Response:
@@ -280,7 +107,9 @@ class AbstractClient(ABC):
return response
@staticmethod
def _add_url_params(url: str, params: Dict[str, str], replace: bool = True):
def _add_url_params(
url: str, params: Dict[str, Union[str, int, bool]], replace: bool = True
) -> str:
"""Add GET params to provided URL being aware of existing.
:param url: string of target URL
@@ -301,9 +130,13 @@ class AbstractClient(ABC):
get_args = parsed_url.query
# Converting URL arguments to dict
parsed_get_args = dict(parse_qsl(get_args))
# we want all string values
parsed_params = {k: str(val) for k, val in params.items()}
if replace:
# Merging URL arguments dict with new params
parsed_get_args.update(params)
parsed_get_args.update(parsed_params)
extra_parameters = ""
else:
# get all the duplicated keys from params and urlencode them, we'll concat this to the params string later
@@ -311,7 +144,7 @@ class AbstractClient(ABC):
# get all the args that aren't duplicated and add them to parsed_get_args
parsed_get_args.update(
{
key: params[key]
key: parsed_params[key]
for key in [x for x in params if x not in parsed_get_args]
}
)
@@ -322,16 +155,6 @@ class AbstractClient(ABC):
else ""
)
# Bool and Dict values should be converted to json-friendly values
# you may throw this part away if you don't like it :)
parsed_get_args.update(
{
k: dumps(v)
for k, v in parsed_get_args.items()
if isinstance(v, (bool, dict))
}
)
# Converting URL argument to proper query string
encoded_get_args = f"{urlencode(parsed_get_args, doseq=True)}{extra_parameters}"
# Creating new parsed result object based on provided with new

View File

@@ -19,33 +19,32 @@ class AsyncClient(AbstractClient):
def __init__(
self,
api_key: str,
api_url: Optional[str] = "https://healthchecks.io/api/",
api_version: Optional[int] = 1,
api_url: str = "https://healthchecks.io/api/",
api_version: int = 1,
client: Optional[HTTPXAsyncClient] = None,
) -> None:
"""An AsyncClient can be used in code using asyncio to work with the Healthchecks.io api.
Args:
api_key (str): Healthchecks.io API key
api_url (Optional[str], optional): API URL. Defaults to "https://healthchecks.io/api/".
api_version (Optional[int], optional): Versiopn of the api to use. Defaults to 1.
api_url (str): API URL. Defaults to "https://healthchecks.io/api/".
api_version (int): Versiopn of the api to use. Defaults to 1.
client (Optional[HTTPXAsyncClient], optional): A httpx.Asyncclient. If not
passed in, one will be created for this object. Defaults to None.
"""
if client is None:
client = HTTPXAsyncClient()
super().__init__(
api_key=api_key, api_url=api_url, api_version=api_version, client=client
self._client: HTTPXAsyncClient = (
HTTPXAsyncClient() if client is None else client
)
super().__init__(api_key=api_key, api_url=api_url, api_version=api_version)
self._client.headers["X-Api-Key"] = self._api_key
self._client.headers["user-agent"] = f"py-healthchecks.io-async/{VERSION}"
self._client.headers["Content-type"] = "application/json"
def _finalizer_method(self):
def _finalizer_method(self) -> None:
"""Calls _afinalizer_method from a sync context to work with weakref.finalizer."""
asyncio.run(self._afinalizer_method())
async def _afinalizer_method(self):
async def _afinalizer_method(self) -> None:
"""Finalizer coroutine that closes our client connections."""
await self._client.aclose()
@@ -250,7 +249,8 @@ class AsyncClient(AbstractClient):
Dict[str, badges.Badges]: Dictionary of all tags in the project with badges
"""
request_url = self._get_api_request_url("badges/")
response = self.check_response(await self._client.get(request_url))
url = await self._client.get(request_url)
response = self.check_response(url)
return {
key: badges.Badges.from_api_result(item)
for key, item in response.json()["badges"].items()

View File

@@ -18,29 +18,26 @@ class Client(AbstractClient):
def __init__(
self,
api_key: str,
api_url: Optional[str] = "https://healthchecks.io/api/",
api_version: Optional[int] = 1,
api_url: str = "https://healthchecks.io/api/",
api_version: int = 1,
client: Optional[HTTPXClient] = None,
) -> None:
"""An AsyncClient can be used in code using asyncio to work with the Healthchecks.io api.
Args:
api_key (str): Healthchecks.io API key
api_url (Optional[str], optional): API URL. Defaults to "https://healthchecks.io/api/".
api_version (Optional[int], optional): Versiopn of the api to use. Defaults to 1.
api_url (str): API URL. Defaults to "https://healthchecks.io/api/".
api_version (int): Versiopn of the api to use. Defaults to 1.
client (Optional[HTTPXClient], optional): A httpx.Client. If not
passed in, one will be created for this object. Defaults to None.
"""
if client is None:
client = HTTPXClient()
super().__init__(
api_key=api_key, api_url=api_url, api_version=api_version, client=client
)
self._client: HTTPXClient = HTTPXClient() if client is None else client
super().__init__(api_key=api_key, api_url=api_url, api_version=api_version)
self._client.headers["X-Api-Key"] = self._api_key
self._client.headers["user-agent"] = f"py-healthchecks.io/{VERSION}"
self._client.headers["Content-type"] = "application/json"
def _finalizer_method(self):
def _finalizer_method(self) -> None:
"""Closes the httpx client."""
self._client.close()

View File

@@ -14,7 +14,7 @@ from healthchecks_io.client.exceptions import HCAPIError
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_checks_200(fake_check_api_result, respx_mock, test_async_client):
async def test_aget_checks_200(fake_check_api_result, respx_mock, test_async_client):
assert test_async_client._client is not None
checks_url = urljoin(test_async_client._api_url, "checks/")
respx_mock.get(checks_url).mock(
@@ -27,7 +27,7 @@ async def test_get_checks_200(fake_check_api_result, respx_mock, test_async_clie
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_checks_pass_in_client(fake_check_api_result, respx_mock):
async def test_aget_checks_pass_in_client(fake_check_api_result, respx_mock):
httpx_client = HTTPXAsyncClient()
test_async_client = AsyncClient(
api_key="test", api_url="http://localhost/api/", client=httpx_client
@@ -43,7 +43,7 @@ async def test_get_checks_pass_in_client(fake_check_api_result, respx_mock):
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_checks_exceptions(
async def test_aget_checks_exceptions(
fake_check_api_result, respx_mock, test_async_client
):
checks_url = urljoin(test_async_client._api_url, "checks/")
@@ -59,7 +59,7 @@ async def test_get_checks_exceptions(
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_checks_tags(fake_check_api_result, respx_mock, test_async_client):
async def test_aget_checks_tags(fake_check_api_result, respx_mock, test_async_client):
"""Test get_checks with tags"""
checks_url = urljoin(test_async_client._api_url, "checks/")
respx_mock.get(f"{checks_url}?tag=test&tag=test2").mock(
@@ -80,7 +80,7 @@ def test_finalizer_closes(test_async_client):
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_check_200(fake_check_api_result, respx_mock, test_async_client):
async def test_aget_check_200(fake_check_api_result, respx_mock, test_async_client):
assert test_async_client._client is not None
checks_url = urljoin(test_async_client._api_url, "checks/test")
respx_mock.get(checks_url).mock(
@@ -92,7 +92,7 @@ async def test_get_check_200(fake_check_api_result, respx_mock, test_async_clien
@pytest.mark.asyncio
@pytest.mark.respx
async def test_check_get_404(respx_mock, test_async_client):
async def test_acheck_get_404(respx_mock, test_async_client):
assert test_async_client._client is not None
checks_url = urljoin(test_async_client._api_url, "checks/test")
respx_mock.get(checks_url).mock(return_value=Response(status_code=404))
@@ -113,7 +113,7 @@ async def test_pause_check_200(fake_check_api_result, respx_mock, test_async_cli
@pytest.mark.asyncio
@pytest.mark.respx
async def test_check_pause_404(respx_mock, test_async_client):
async def test_acheck_pause_404(respx_mock, test_async_client):
assert test_async_client._client is not None
checks_url = urljoin(test_async_client._api_url, "checks/test/pause")
respx_mock.post(checks_url).mock(return_value=Response(status_code=404))
@@ -123,7 +123,7 @@ async def test_check_pause_404(respx_mock, test_async_client):
@pytest.mark.asyncio
@pytest.mark.respx
async def test_delete_check_200(fake_check_api_result, respx_mock, test_async_client):
async def test_adelete_check_200(fake_check_api_result, respx_mock, test_async_client):
assert test_async_client._client is not None
checks_url = urljoin(test_async_client._api_url, "checks/test")
respx_mock.delete(checks_url).mock(
@@ -135,7 +135,7 @@ async def test_delete_check_200(fake_check_api_result, respx_mock, test_async_cl
@pytest.mark.asyncio
@pytest.mark.respx
async def test_delete_pause404(respx_mock, test_async_client):
async def test_adelete_pause404(respx_mock, test_async_client):
checks_url = urljoin(test_async_client._api_url, "checks/test")
respx_mock.delete(checks_url).mock(return_value=Response(status_code=404))
with pytest.raises(CheckNotFoundError):
@@ -144,7 +144,7 @@ async def test_delete_pause404(respx_mock, test_async_client):
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_check_pings_200(
async def test_aget_check_pings_200(
fake_check_pings_api_result, respx_mock, test_async_client
):
checks_url = urljoin(test_async_client._api_url, "checks/test/pings/")
@@ -160,7 +160,7 @@ async def test_get_check_pings_200(
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_check_flips_200(
async def test_aget_check_flips_200(
fake_check_flips_api_result, respx_mock, test_async_client
):
checks_url = urljoin(test_async_client._api_url, "checks/test/flips/")
@@ -190,7 +190,7 @@ async def test_get_check_flips_params_200(
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_check_flips_400(
async def test_aget_check_flips_400(
fake_check_flips_api_result, respx_mock, test_async_client
):
flips_url = urljoin(test_async_client._api_url, "checks/test/flips/")
@@ -201,7 +201,7 @@ async def test_get_check_flips_400(
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_integrations(
async def test_aget_integrations(
fake_integrations_api_result, respx_mock, test_async_client
):
channels_url = urljoin(test_async_client._api_url, "channels/")
@@ -215,7 +215,7 @@ async def test_get_integrations(
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_badges(fake_badges_api_result, respx_mock, test_async_client):
async def test_aget_badges(fake_badges_api_result, respx_mock, test_async_client):
channels_url = urljoin(test_async_client._api_url, "badges/")
respx_mock.get(channels_url).mock(
return_value=Response(status_code=200, json=fake_badges_api_result)