add some client work

This commit is contained in:
Andrew Herrington
2021-12-10 18:05:49 -06:00
parent 90233c568e
commit 2bfece1e56
14 changed files with 428 additions and 19 deletions

View File

@@ -32,7 +32,7 @@ repos:
entry: flake8
language: system
types: [python]
exclude: "tests/*"
exclude: "^(tests/*|noxfile.py)"
require_serial: true
- id: pyupgrade
name: pyupgrade

View File

@@ -26,6 +26,7 @@ python_versions = ["3.10", "3.9", "3.8", "3.7"]
nox.needs_version = ">= 2021.6.6"
nox.options.sessions = (
"pre-commit",
"bandit",
"safety",
"mypy",
"tests",
@@ -139,13 +140,18 @@ def safety(session: Session) -> None:
@session(python=python_versions)
def mypy(session: Session) -> None:
"""Type-check using mypy."""
args = session.posargs or ["src", "docs/conf.py"]
args = session.posargs or ["src"]
session.install(".")
session.install("mypy", "pytest")
session.install(*mypy_type_packages)
session.run("mypy", *args)
if not session.posargs:
session.run("mypy", f"--python-executable={sys.executable}", "noxfile.py")
@session(python=python_versions[0])
def bandit(session: Session) -> None:
"""Run bandit security tests"""
args = session.posargs or ["-r", "./src"]
session.run("bandit", *args)
@session(python=python_versions)

65
poetry.lock generated
View File

@@ -752,6 +752,28 @@ toml = "*"
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"]
[[package]]
name = "pytest-async"
version = "0.1.1"
description = "pytest-async - Run your coroutine in event loop without decorator"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "pytest-asyncio"
version = "0.16.0"
description = "Pytest support for asyncio."
category = "dev"
optional = false
python-versions = ">= 3.6"
[package.dependencies]
pytest = ">=5.4.0"
[package.extras]
testing = ["coverage", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-cov"
version = "3.0.0"
@@ -767,6 +789,20 @@ pytest = ">=4.6"
[package.extras]
testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-mock"
version = "3.6.1"
description = "Thin-wrapper around the mock package for easier use with pytest"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
pytest = ">=5.0"
[package.extras]
dev = ["pre-commit", "tox", "pytest-asyncio"]
[[package]]
name = "python-dateutil"
version = "2.8.2"
@@ -842,6 +878,17 @@ urllib3 = ">=1.21.1,<1.27"
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]]
name = "respx"
version = "0.19.0"
description = "A utility for mocking out the Python HTTPX and HTTP Core libraries."
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
httpx = ">=0.21.0"
[[package]]
name = "restructuredtext-lint"
version = "1.3.2"
@@ -1231,7 +1278,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "062c8a09c8967fafd0410a702d9bdc17ef0a260965b65345c6058a3a134a05a3"
content-hash = "4609efb7758ecc2785e3e38e1b1bad140bd0311d253ffebd86b9e5d4e4d45766"
[metadata.files]
alabaster = [
@@ -1637,10 +1684,22 @@ pytest = [
{file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"},
{file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"},
]
pytest-async = [
{file = "pytest_async-0.1.1-py3-none-any.whl", hash = "sha256:11cc41eef82592951d56c2bb9b0e6ab21b2f0f00663e78d95694a80d965be930"},
{file = "pytest_async-0.1.1.tar.gz", hash = "sha256:0d6ffd3ebac2f3aa47d606dbae1984750268a89dc8caf4a908ba61c60299cdfd"},
]
pytest-asyncio = [
{file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"},
{file = "pytest_asyncio-0.16.0-py3-none-any.whl", hash = "sha256:5f2a21273c47b331ae6aa5b36087047b4899e40f03f18397c0e65fa5cca54e9b"},
]
pytest-cov = [
{file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
{file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"},
]
pytest-mock = [
{file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"},
{file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"},
]
python-dateutil = [
{file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
{file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
@@ -1772,6 +1831,10 @@ requests = [
{file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
{file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
]
respx = [
{file = "respx-0.19.0-py2.py3-none-any.whl", hash = "sha256:1ac1cc99bf892ffd3e33108ae43d71d8309a58ac226965f4bd81ec055600f265"},
{file = "respx-0.19.0.tar.gz", hash = "sha256:4a09e15803c7450d45303520ec528794c9fd77b05984263bc83b78aabbb39413"},
]
restructuredtext-lint = [
{file = "restructuredtext_lint-1.3.2.tar.gz", hash = "sha256:d3b10a1fe2ecac537e51ae6d151b223b78de9fafdd50e5eb6b08c243df173c80"},
]

View File

@@ -50,6 +50,10 @@ furo = ">=2021.11.12"
pytest-cov = "^3.0.0"
types-croniter = "^1.0.3"
types-pytz = "^2021.3.1"
pytest_async = "^0.1.1"
pytest-asyncio = "^0.16.0"
respx = "^0.19.0"
pytest-mock = "^3.6.1"
[tool.coverage.paths]
source = ["src", "*/site-packages"]

View File

@@ -1 +1,3 @@
"""Py Healthchecks.Io."""
VERSION = "0.1"

View File

@@ -0,0 +1,2 @@
"""healthchecks_io clients."""
from .asyncclient import AsyncClient # noqa: F401

View File

@@ -0,0 +1,146 @@
from abc import ABC
from abc import abstractmethod
from json import dumps
from typing import Dict
from typing import List
from typing import Optional
from urllib.parse import parse_qsl
from urllib.parse import ParseResult
from urllib.parse import unquote
from urllib.parse import urlencode
from urllib.parse import urljoin
from urllib.parse import urlparse
from weakref import finalize
from httpx import Client
from healthchecks_io.schemas import checks
class AbstractClient(ABC):
"""An abstract client class that can be implemented by client classes."""
def __init__(
self,
api_key: str,
api_url: Optional[str] = "https://healthchecks.io/api/",
api_version: Optional[int] = 1,
client: Optional[Client] = None,
) -> 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.
"""
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):
"""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]:
"""Calls the API's /checks/ endpoint to get a list of checks."""
pass
def _get_api_request_url(
self, path: str, params: Optional[Dict[str, str]] = None
) -> str:
"""Get a full request url for the healthchecks api.
Args:
path (str): Path to request from
params (Optional[Dict[str, str]], optional): URL Parameters. Defaults to None.
Returns:
str: url
"""
url = urljoin(self._api_url, path)
return self._add_url_params(url, params) if params is not None else url
@property
def is_closed(self) -> bool:
"""Is the client closed?
Returns:
bool: is the client closed
"""
return self._client.is_closed
@staticmethod
def _add_url_params(url: str, params: Dict[str, str], replace: bool = True):
"""Add GET params to provided URL being aware of existing.
:param url: string of target URL
:param params: dict containing requested params to be added
:param replace: bool True If true, replace params if they exist with new values, otherwise append
:return: string with updated URL
>> url = 'http://stackoverflow.com/test?answers=true'
>> new_params = {'answers': False, 'data': ['some','values']}
>> add_url_params(url, new_params)
'http://stackoverflow.com/test?data=some&data=values&answers=false'
"""
# Unquoting URL first so we don't loose existing args
url = unquote(url)
# Extracting url info
parsed_url = urlparse(url)
# Extracting URL arguments from parsed URL
get_args = parsed_url.query
# Converting URL arguments to dict
parsed_get_args = dict(parse_qsl(get_args))
if replace:
# Merging URL arguments dict with new params
parsed_get_args.update(params)
extra_parameters = ""
else:
# get all the duplicated keys from params and urlencode them, we'll concat this to the params string later
duplicated_params = [x for x in params if x in parsed_get_args]
# get all the args that aren't duplicated and add them to parsed_get_args
parsed_get_args.update(
{
key: params[key]
for key in [x for x in params if x not in parsed_get_args]
}
)
# if we have any duplicated parameters, urlencode them, we append them later
extra_parameters = (
f"&{urlencode({key: params[key] for key in duplicated_params}, doseq=True)}"
if len(duplicated_params) > 0
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
# URL arguments. Same thing happens inside of urlparse.
new_url = ParseResult(
parsed_url.scheme,
parsed_url.netloc,
parsed_url.path,
parsed_url.params,
encoded_get_args,
parsed_url.fragment,
).geturl()
return new_url

View File

@@ -0,0 +1,86 @@
"""An async healthchecks.io client."""
import asyncio
from typing import List
from typing import Optional
from httpx import AsyncClient as HTTPXAsyncClient
from ._abstract import AbstractClient
from .exceptions import HCAPIAuthError
from .exceptions import HCAPIError
from healthchecks_io import VERSION
from healthchecks_io.schemas import checks
class AsyncClient(AbstractClient):
"""A Healthchecks.io client implemented using httpx's Async methods."""
def __init__(
self,
api_key: str,
api_url: Optional[str] = "https://healthchecks.io/api/",
api_version: Optional[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.
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.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):
"""Calls _afinalizer_method from a sync context to work with weakref.finalizer."""
asyncio.run(self._afinalizer_method())
async def _afinalizer_method(self):
"""Finalizer coroutine that closes our client connections."""
await self._client.aclose()
async def get_checks(self, tags: Optional[List[str]] = None) -> List[checks.Check]:
"""Get a list of checks from the healthchecks api.
Args:
tags (Optional[List[str]], optional): Filters the checks and returns only
the checks that are tagged with the specified value. Defaults to None.
Raises:
HCAPIAuthError: When the API returns a 401, indicates an api key issue
HCAPIError: When the API returns anything other than a 200 or 401
Returns:
List[checks.Check]: [description]
"""
request_url = self._get_api_request_url("checks/")
if tags is not None:
for tag in tags:
request_url = self._add_url_params(
request_url, {"tag": tag}, replace=False
)
response = await self._client.get(request_url)
if response.status_code == 401:
raise HCAPIAuthError("Auth failure when getting checks")
if response.status_code != 200:
raise HCAPIError(
f"Error when reaching out to HC API at {request_url}. "
f"Status Code {response.status_code}. Response {response.text}"
)
return [
checks.Check.from_api_result(check_data)
for check_data in response.json()["checks"]
]

View File

@@ -0,0 +1,13 @@
"""healthchecks_io exceptions."""
class HCAPIError(Exception):
"""API Exception for when we have an error with the healthchecks api."""
...
class HCAPIAuthError(HCAPIError):
"""Thrown when we fail to auth to the Healthchecks api."""
...

View File

@@ -0,0 +1 @@
"""Schemas for healthchecks_io."""

View File

@@ -0,0 +1,76 @@
from urllib.parse import urljoin
import pytest
import respx
from httpx import AsyncClient as HTTPXAsyncClient
from httpx import Response
from healthchecks_io.client import AsyncClient
from healthchecks_io.client.exceptions import HCAPIAuthError
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):
assert test_async_client._client is not None
checks_url = urljoin(test_async_client._api_url, "checks/")
respx_mock.get(checks_url).mock(
return_value=Response(status_code=200, json={"checks": [fake_check_api_result]})
)
checks = await test_async_client.get_checks()
assert len(checks) == 1
assert checks[0].name == fake_check_api_result["name"]
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_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
)
checks_url = urljoin(test_async_client._api_url, "checks/")
respx_mock.get(checks_url).mock(
return_value=Response(status_code=200, json={"checks": [fake_check_api_result]})
)
checks = await test_async_client.get_checks()
assert len(checks) == 1
assert checks[0].name == fake_check_api_result["name"]
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_checks_exceptions(
fake_check_api_result, respx_mock, test_async_client
):
checks_url = urljoin(test_async_client._api_url, "checks/")
# test exceptions
respx_mock.get(checks_url).mock(return_value=Response(status_code=401))
with pytest.raises(HCAPIAuthError):
await test_async_client.get_checks()
respx_mock.get(checks_url).mock(return_value=Response(status_code=500))
with pytest.raises(HCAPIError):
await test_async_client.get_checks()
@pytest.mark.asyncio
@pytest.mark.respx
async def test_get_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(
return_value=Response(status_code=200, json={"checks": [fake_check_api_result]})
)
checks = await test_async_client.get_checks(tags=["test", "test2"])
assert len(checks) == 1
assert checks[0].name == fake_check_api_result["name"]
@pytest.mark.asyncio
def test_finalizer_closes(test_async_client):
"""Tests our finalizer works to close the method"""
assert not test_async_client.is_closed
test_async_client._finalizer_method()
assert test_async_client.is_closed

View File

@@ -4,6 +4,7 @@ from typing import Union
import pytest
from healthchecks_io.client import AsyncClient
from healthchecks_io.schemas import checks
@@ -78,3 +79,10 @@ def fake_ro_check(fake_check: checks.Check):
fake_check.update_url = None
fake_check.pause_url = None
yield fake_check
@pytest.fixture
def test_async_client():
"""An AsyncClient for testing, set to a nonsense url so we aren't pinging healtchecks."""
yield AsyncClient(api_key="test", api_url="https://localhost/api")

View File

@@ -1,5 +1,6 @@
from healthchecks_io.schemas.badges import Badges
def test_badge_from_api_result():
badges_dict = {
"svg": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M-2/backup.svg",
@@ -7,8 +8,8 @@ def test_badge_from_api_result():
"json": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M-2/backup.json",
"json3": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M/backup.json",
"shields": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M-2/backup.shields",
"shields3": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M/backup.shields"
"shields3": "https://healthchecks.io/badge/67541b37-8b9c-4d17-b952-690eae/LOegDs5M/backup.shields",
}
this_badge = Badges.from_api_result(badges_dict)
assert this_badge.svg == badges_dict['svg']
assert this_badge.json_url == badges_dict['json']
assert this_badge.svg == badges_dict["svg"]
assert this_badge.json_url == badges_dict["json"]

View File

@@ -1,11 +1,12 @@
from healthchecks_io.schemas.integrations import Integration
def test_badge_from_api_result():
int_dict = {
"id": "4ec5a071-2d08-4baa-898a-eb4eb3cd6941",
"name": "My Work Email",
"kind": "email"
"kind": "email",
}
this_integration = Integration.from_api_result(int_dict)
assert this_integration.id == int_dict['id']
assert this_integration.name == int_dict['name']
assert this_integration.id == int_dict["id"]
assert this_integration.name == int_dict["name"]