diff --git a/noxfile.py b/noxfile.py index 965a89b..3ef4ffb 100644 --- a/noxfile.py +++ b/noxfile.py @@ -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: diff --git a/src/healthchecks_io/__init__.py b/src/healthchecks_io/__init__.py index 89ec522..5c35c0a 100644 --- a/src/healthchecks_io/__init__.py +++ b/src/healthchecks_io/__init__.py @@ -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", +] diff --git a/src/healthchecks_io/client/__init__.py b/src/healthchecks_io/client/__init__.py index 2c40e4c..12a4e6b 100644 --- a/src/healthchecks_io/client/__init__.py +++ b/src/healthchecks_io/client/__init__.py @@ -1,3 +1,5 @@ """healthchecks_io clients.""" from .async_client import AsyncClient # noqa: F401 from .sync_client import Client # noqa: F401 + +__all__ = ["AsyncClient", "Client"] diff --git a/src/healthchecks_io/client/_abstract.py b/src/healthchecks_io/client/_abstract.py index be563c5..c20bf46 100644 --- a/src/healthchecks_io/client/_abstract.py +++ b/src/healthchecks_io/client/_abstract.py @@ -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 diff --git a/src/healthchecks_io/client/async_client.py b/src/healthchecks_io/client/async_client.py index 075f701..f5c3325 100644 --- a/src/healthchecks_io/client/async_client.py +++ b/src/healthchecks_io/client/async_client.py @@ -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() diff --git a/src/healthchecks_io/client/sync_client.py b/src/healthchecks_io/client/sync_client.py index 3f44998..ae0b5dd 100644 --- a/src/healthchecks_io/client/sync_client.py +++ b/src/healthchecks_io/client/sync_client.py @@ -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() diff --git a/tests/client/test_async.py b/tests/client/test_async.py index ecca7c7..fec6408 100644 --- a/tests/client/test_async.py +++ b/tests/client/test_async.py @@ -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)